mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 10:51:36 -04:00
Response-quality work, all aimed at giving the model enough context to answer accurately: - Every chat's system prompt now carries a live library map (today's date, counts, and the exact names of channels, domains, zims, and inventories, capped and cached 60s). Exact spellings are in context before the first token, and the list_collections round-trip — minutes on slow hardware — is usually unnecessary. - The request is budgeted against the model's real context: oldest turns are trimmed first, the current question is never dropped, and an over-budget turn gets a clear "start a new conversation" error instead of a silently truncated prompt. cache_prompt reuses the KV cache for the stable prefix, which is most of the latency on a Pi. - context_size plumbing fixed: selecting a model adopts its catalog default (the 4B was running at 8k instead of 16k), and llama-server runs --parallel 1 so the single user gets the whole context. - temperature is configurable in ai.yaml (default 0.3 — small models call tools far more reliably near 0.2-0.3 than at server defaults). - One worked tool-use example per mode is prepended to every conversation; small models imitate far better than they obey. - scripts/ai_eval.py scores the assistant against a live WROLPi on mechanical checks (finished, called the right tool first, included a WROLPi link, no external URLs) so prompt changes stop being validated by anecdote. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
113 lines
5 KiB
Python
Executable file
113 lines
5 KiB
Python
Executable file
#!/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())
|