mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
114 lines
5 KiB
Python
114 lines
5 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Evaluate the AI assistant against a live WROLPi.
|
||
|
|
|
||
|
|
Prompt and mode changes have so far been validated by anecdote; this scores every answer on
|
||
|
|
mechanical properties that do not need a judge:
|
||
|
|
|
||
|
|
- the stream finished with `done` (no error, no timeout)
|
||
|
|
- at least one tool was called, and the FIRST tool was one the question expects
|
||
|
|
- the answer contains a relative WROLPi link when one is expected
|
||
|
|
- the answer contains no external URLs (the modes forbid them)
|
||
|
|
|
||
|
|
Run it before and after any prompt/tool change:
|
||
|
|
|
||
|
|
python3 scripts/ai_eval.py --base https://localhost:8443 [--mode research] [--timeout 600]
|
||
|
|
|
||
|
|
Exit code 1 when any question errors out; the score table is the real output. Requires only
|
||
|
|
`requests` (in WROLPi's requirements). The suite reads the library map's world: questions are
|
||
|
|
library-agnostic (browse/status/help) so they work on any WROLPi with some content."""
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
import re
|
||
|
|
import sys
|
||
|
|
import time
|
||
|
|
|
||
|
|
import requests
|
||
|
|
|
||
|
|
requests.packages.urllib3.disable_warnings() # noqa Self-signed certs.
|
||
|
|
|
||
|
|
# (mode, question, expected first tools (any of), expect a WROLPi link in the answer)
|
||
|
|
QUESTIONS = [
|
||
|
|
('help', 'How do I download videos?', {'search_help'}, True),
|
||
|
|
('help', 'What are Tags?', {'search_help'}, True),
|
||
|
|
('help', 'How do I add my own files to WROLPi?', {'search_help'}, True),
|
||
|
|
('research', 'What are my three newest archives?', {'search_archives'}, True),
|
||
|
|
('research', 'What video channels do I have?', {'list_collections', 'search_videos'}, False),
|
||
|
|
('research', 'Find videos about cooking', {'search_videos', 'search_all'}, False),
|
||
|
|
('research', 'Summarize my newest videos', {'search_videos'}, True),
|
||
|
|
('system', 'Is everything running?', {'get_system_status', 'list_services'}, False),
|
||
|
|
('system', 'How full are my drives?', {'get_system_status', 'list_disks'}, False),
|
||
|
|
('system', 'Why would downloads fail?', {'get_system_status', 'search_help'}, False),
|
||
|
|
]
|
||
|
|
|
||
|
|
EXTERNAL_URL = re.compile(r'https?://(?!localhost|127\.0\.0\.1)')
|
||
|
|
RELATIVE_LINK = re.compile(r'(^|[\s(\[])/(videos|archives|docs|epub|media|download|system|modules)[/\w]')
|
||
|
|
|
||
|
|
|
||
|
|
def run_question(base: str, mode: str, question: str, timeout: int) -> dict:
|
||
|
|
started = time.time()
|
||
|
|
tools, answer, error = [], None, None
|
||
|
|
try:
|
||
|
|
response = requests.post(f'{base}/api/chat/', verify=False, stream=True, timeout=timeout,
|
||
|
|
json=dict(mode=mode, messages=[dict(role='user', content=question)]))
|
||
|
|
if response.status_code != 200:
|
||
|
|
return dict(error=f'HTTP {response.status_code}', tools=tools, elapsed=time.time() - started)
|
||
|
|
event = None
|
||
|
|
for raw_line in response.iter_lines(decode_unicode=True):
|
||
|
|
if raw_line.startswith('event:'):
|
||
|
|
event = raw_line[6:].strip()
|
||
|
|
elif raw_line.startswith('data:'):
|
||
|
|
data = json.loads(raw_line[5:].strip())
|
||
|
|
if event == 'tool_call':
|
||
|
|
tools.append(data.get('tool'))
|
||
|
|
elif event == 'done':
|
||
|
|
answer = data.get('content')
|
||
|
|
elif event == 'error':
|
||
|
|
error = data.get('message')
|
||
|
|
except Exception as e:
|
||
|
|
error = str(e)[:120]
|
||
|
|
return dict(answer=answer, error=error, tools=tools, elapsed=time.time() - started)
|
||
|
|
|
||
|
|
|
||
|
|
def score(result: dict, expected_tools: set, expect_link: bool) -> dict:
|
||
|
|
answer = result.get('answer') or ''
|
||
|
|
checks = {
|
||
|
|
'done': result.get('error') is None and bool(answer),
|
||
|
|
'used_tool': bool(result['tools']),
|
||
|
|
'right_tool': bool(result['tools']) and result['tools'][0] in expected_tools,
|
||
|
|
'no_external_urls': not EXTERNAL_URL.search(answer),
|
||
|
|
}
|
||
|
|
if expect_link:
|
||
|
|
checks['has_link'] = bool(RELATIVE_LINK.search(answer))
|
||
|
|
return checks
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
||
|
|
parser.add_argument('--base', default='https://localhost:8443')
|
||
|
|
parser.add_argument('--mode', help='Only run one mode')
|
||
|
|
parser.add_argument('--timeout', type=int, default=600)
|
||
|
|
args = parser.parse_args()
|
||
|
|
|
||
|
|
questions = [q for q in QUESTIONS if not args.mode or q[0] == args.mode]
|
||
|
|
passed = total = errors = 0
|
||
|
|
for mode, question, expected_tools, expect_link in questions:
|
||
|
|
result = run_question(args.base, mode, question, args.timeout)
|
||
|
|
checks = score(result, expected_tools, expect_link)
|
||
|
|
passed += sum(checks.values())
|
||
|
|
total += len(checks)
|
||
|
|
if result.get('error'):
|
||
|
|
errors += 1
|
||
|
|
flags = ' '.join(f'{name}={"Y" if ok else "N"}' for name, ok in checks.items())
|
||
|
|
print(f'[{mode:8}] {result["elapsed"]:6.1f}s {flags} {question}')
|
||
|
|
if not all(checks.values()):
|
||
|
|
print(f' tools={result["tools"]}')
|
||
|
|
detail = result.get('error') or (result.get('answer') or '')[:200]
|
||
|
|
print(f' {detail}')
|
||
|
|
|
||
|
|
print(f'\nScore: {passed}/{total} checks passed across {len(questions)} questions; {errors} hard errors.')
|
||
|
|
return 1 if errors else 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == '__main__':
|
||
|
|
sys.exit(main())
|