You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1042 lines
42 KiB
1042 lines
42 KiB
"""Evaluation harness for ChatService. |
|
|
|
Generates novel Swedish questions at runtime, runs them through ChatService, |
|
and uses a judge LLM to verdict each paragraph's citations against its sources. |
|
|
|
Usage: |
|
python scripts/eval_harness.py --label "gpt-oss baseline" --iterations 500 |
|
python scripts/eval_harness.py --label smoke --iterations 3 |
|
""" |
|
from __future__ import annotations |
|
|
|
import argparse |
|
import json |
|
import math |
|
import os |
|
import random |
|
import re |
|
import subprocess |
|
import sys |
|
import time |
|
import traceback |
|
|
|
import requests |
|
import uuid |
|
from pathlib import Path |
|
from typing import Any, Dict, List, Optional |
|
|
|
_ROOT = Path(__file__).resolve().parent.parent |
|
sys.path.insert(0, str(_ROOT)) |
|
|
|
from dotenv import load_dotenv |
|
load_dotenv() |
|
|
|
import types as _types |
|
# Stub the backend package before importing submodules so that |
|
# backend/__init__.py (which imports the full FastAPI app) never runs. |
|
# __path__ must be set so Python treats the stubs as packages. |
|
def _stub_pkg(name: str, path: str) -> None: |
|
if name not in sys.modules: |
|
m = _types.ModuleType(name) |
|
m.__path__ = [path] |
|
m.__package__ = name |
|
sys.modules[name] = m |
|
|
|
_stub_pkg("backend", str(_ROOT / "backend")) |
|
_stub_pkg("backend.services", str(_ROOT / "backend/services")) |
|
|
|
from postgres_client import pg |
|
from packages.llm import LLM |
|
from backend.services.chat import ChatService, SMART_MODEL, FAST_MODEL |
|
|
|
|
|
GENERATOR_SYSTEM = """Du genererar realistiska frågor som en svensk journalist eller medborgare kan ställa till ett chatgränssnitt över riksdagens anföranden (~450 000 anföranden från 1990 till idag, med speaker_name, party, date, debatt och fulltext). |
|
|
|
Variera teman brett: sakpolitik (skola, vård, försvar, klimat, migration, skatt, EU, kultur, arbetsmarknad), personfrågor, historiska skeenden, specifika händelser, citat. |
|
|
|
Du får ett komplexitetsmål (1–3) som styr hur många delfrågor frågan ska innehålla: |
|
- Nivå 1: En enkel, direkt fråga. T.ex. "Vad säger Miljöpartiet om kärnkraft?" |
|
- Nivå 2: Två relaterade vinklar i samma fråga. T.ex. "Vad säger partierna om kärnkraft, och vilka politiker driver frågan mest aktivt?" |
|
- Nivå 3: Tre vinklar eller en bredare analytisk fråga. T.ex. "Vad säger partierna om kärnkraft, vilka politiker är mest aktiva, och hur har debatten förändrats sedan 2010?" |
|
|
|
Returnera ENDAST frågan, på svenska, utan förklaring eller inledning. Ingen markdown, ingen numrering.""" |
|
|
|
JUDGE_SYSTEM = """Du är en noggrann faktakontrollant för ett system som söker i svenska riksdagsanföranden. |
|
|
|
Du får: |
|
1. Det fullständiga svaret som AI-assistenten gav. |
|
2. Ett specifikt stycke ur svaret som du ska bedöma. |
|
3. De fullständiga texterna till de tal som stycket citerar. |
|
|
|
Din uppgift: avgör om påståendena i stycket stöds av de citerade talens faktiska innehåll. |
|
|
|
Var särskilt uppmärksam på om rätt speaker_name och party tillskrivs rätt tal. Ett känt fel är t.ex. att svaret skriver "Jan Björklund (M)" men det citerade talet hölls av Helena Bargholtz (L). |
|
|
|
Returnera ENDAST ett JSON-objekt: |
|
{"verdict": "...", "cited_indices": [N, ...], "rationale": "kort motivering på svenska"} |
|
|
|
Du MÅSTE alltid skriva en rationale på minst en mening som förklarar ditt beslut, oavsett verdict. |
|
|
|
Verdict (välj EXAKT ett): |
|
- "supported": påståendet stöds av taltexterna. |
|
- "partial": delvis korrekt men något är överdrivet eller ej verifierbart mot taltexterna. |
|
- "unsupported": påståendet motsägs eller saknar stöd i taltexterna — använd detta även när |
|
rätt speaker_name citeras men innehållet som tillskrivs dem inte finns i det angivna talet. |
|
- "wrong_speaker": ENBART om namnet eller partiförkortningen i stycket INTE stämmer med vem |
|
som faktiskt höll det citerade talet enligt taltextens metadata och innehåll. Ange rätt |
|
speaker_name/party i rationale. Använd INTE detta verdict enbart för att innehållet är felaktigt |
|
— det hör till "unsupported". |
|
- "wrong_attribution": rätt speaker_name är angiven, men det specifika påståendet är hämtat från |
|
ett annat tal eller ett annat källindex än det som faktiskt citeras — t.ex. att innehållet |
|
finns i källa [7] men stycket citerar [3] av samma speaker_name.""" |
|
|
|
|
|
# --------------------------------------------------------------------------- |
|
# Question generator |
|
# --------------------------------------------------------------------------- |
|
|
|
def _fetch_random_talk_snippet() -> Optional[Dict[str, Any]]: |
|
"""Pick a random talk and return its first ~500 chars plus metadata.""" |
|
rows = pg.execute( |
|
"""SELECT id, speaker_name, party, date::text AS date, |
|
LEFT(text, 500) AS snippet |
|
FROM speeches |
|
WHERE text IS NOT NULL AND LENGTH(text) > 300 |
|
ORDER BY RANDOM() |
|
LIMIT 1""" |
|
) |
|
return rows[0] if rows else None |
|
|
|
|
|
class QuestionGenerator: |
|
"""Mixes three strategies so the harness exercises different input shapes. |
|
|
|
- talk_seed: sample a random talk snippet and ask the LLM to turn it into a |
|
question (grounds the run in real database content). |
|
- free: open-ended question from the system prompt's theme list. |
|
""" |
|
|
|
STRATEGIES = ["talk_seed", "talk_seed", "free"] # weighted toward talk_seed |
|
|
|
def __init__(self, llm: LLM, history_size: int = 20) -> None: |
|
self.llm = llm |
|
self.recent: List[str] = [] |
|
self.history_size = history_size |
|
|
|
def _prompt_free(self, complexity: int) -> str: |
|
avoid = "\n".join(f"- {q}" for q in self.recent[-self.history_size:]) or "(inga tidigare)" |
|
return ( |
|
f"Komplexitetsmål: {complexity}\n\n" |
|
f"Undvik att upprepa något av följande frågor/teman:\n{avoid}\n\n" |
|
"Skriv en ny fråga." |
|
) |
|
|
|
def _prompt_talk_seed(self, talk: Dict[str, Any], complexity: int) -> str: |
|
avoid = "\n".join(f"- {q}" for q in self.recent[-self.history_size:]) or "(inga tidigare)" |
|
return ( |
|
f"Komplexitetsmål: {complexity}\n\n" |
|
"Här är ett utdrag ur ett riksdagsanförande:\n" |
|
f"Talare: {talk.get('speaker_name')} ({talk.get('party')}) — {talk.get('date')}\n" |
|
f"Utdrag:\n\"\"\"\n{talk.get('snippet')}\n\"\"\"\n\n" |
|
"Formulera en naturlig fråga på svenska som en journalist eller medborgare " |
|
"kunde ställa, och som skulle leda till att man hittar detta eller liknande " |
|
"anföranden. Fråga gärna bredare än utdraget — t.ex. vad olika partier eller " |
|
"politiker sagt i samma sakfråga, hur diskussionen utvecklats, eller kombinera " |
|
"vinklar i enlighet med komplexitetsmålet. Nämn INTE talaren eller datumet direkt.\n\n" |
|
f"Undvik att upprepa något av följande teman:\n{avoid}" |
|
) |
|
|
|
def generate(self) -> tuple[str, int]: |
|
"""Returns (question, complexity) where complexity is 1–3.""" |
|
complexity = random.randint(1, 3) |
|
strategy = random.choice(self.STRATEGIES) |
|
prompt: Optional[str] = None |
|
if strategy == "talk_seed": |
|
talk = _fetch_random_talk_snippet() |
|
if talk: |
|
prompt = self._prompt_talk_seed(talk, complexity) |
|
if prompt is None: |
|
strategy = "free" |
|
prompt = self._prompt_free(complexity) |
|
|
|
resp = self.llm.generate( |
|
messages=[ |
|
{"role": "system", "content": GENERATOR_SYSTEM}, |
|
{"role": "user", "content": prompt}, |
|
], |
|
think=False, |
|
) |
|
text = getattr(resp, "content", str(resp)).strip() |
|
text = text.strip('"').strip("'").strip() |
|
if "\n" in text: |
|
text = text.split("\n", 1)[0].strip() |
|
self.recent.append(text) |
|
return text, complexity |
|
|
|
|
|
# --------------------------------------------------------------------------- |
|
# Trace collector — captures compact event stream |
|
# --------------------------------------------------------------------------- |
|
|
|
class TraceCollector: |
|
def __init__(self) -> None: |
|
self.events: List[Dict[str, Any]] = [] |
|
self._iter = 0 |
|
|
|
def callback(self, event: Dict[str, Any]) -> None: |
|
etype = event.get("type") |
|
compact: Dict[str, Any] = {"iter": self._iter, "type": etype} |
|
if etype == "tool_call": |
|
self._iter += 1 |
|
compact["iter"] = self._iter |
|
compact["tool"] = event.get("tool") |
|
elif etype == "status": |
|
compact["message"] = (event.get("message") or "")[:200] |
|
elif etype == "search_card": |
|
compact["hit_count"] = event.get("total", 0) |
|
compact["hit_ids"] = [ |
|
r.get("_id") for r in (event.get("results") or []) if isinstance(r, dict) |
|
] |
|
compact["speaker_ids"] = event.get("speaker_ids") or [] |
|
elif etype == "stats_card": |
|
compact["row_count"] = len(event.get("rows") or []) |
|
compact["speaker_ids"] = event.get("speaker_ids") or [] |
|
elif etype == "insight": |
|
compact["message"] = (event.get("message") or "")[:200] |
|
elif etype == "tool_speakers": |
|
compact["person_ids"] = event.get("person_ids") or [] |
|
else: |
|
return |
|
self.events.append(compact) |
|
|
|
|
|
# --------------------------------------------------------------------------- |
|
# Judge |
|
# --------------------------------------------------------------------------- |
|
|
|
_CITATION_RE = re.compile(r"\[(\d+)\]") |
|
_SPEAKER_RE = re.compile(r"\[([^\]]+)\]\(/mp/[^)]+\)\s*\(([A-ZÅÄÖ]{1,3})\)") |
|
|
|
|
|
def _check_speaker_metadata( |
|
paragraph: str, cited_sources: List[Dict[str, Any]] |
|
) -> Optional[str]: |
|
"""Deterministic check: returns a mismatch description if any speaker/party in the |
|
paragraph doesn't match the cited source metadata, else None. |
|
|
|
Only fires when a [Name](/mp/...) (PARTY) pattern is present so we don't false-positive |
|
on paragraphs that don't name a speaker explicitly. |
|
""" |
|
para_speakers = [ |
|
(m.group(1).strip(), m.group(2).strip()) for m in _SPEAKER_RE.finditer(paragraph) |
|
] |
|
if not para_speakers: |
|
return None |
|
for para_name, para_party in para_speakers: |
|
for src in cited_sources: |
|
src_spk = (src.get("speaker") or "").strip() |
|
src_party = (src.get("party") or "").strip() |
|
if not src_spk: |
|
continue |
|
name_ok = ( |
|
src_spk.lower() in para_name.lower() |
|
or para_name.lower() in src_spk.lower() |
|
) |
|
if name_ok and src_party and src_party != para_party: |
|
return ( |
|
f"Partifel: stycket anger ({para_party}) men källa [{src['n']}] " |
|
f"är ({src_party}) — speaker_name: {src_spk}" |
|
) |
|
if not name_ok: |
|
return ( |
|
f"Namnfel: stycket anger '{para_name}' ({para_party}) men " |
|
f"källa [{src['n']}] hölls av '{src_spk}' ({src_party})" |
|
) |
|
return None |
|
|
|
|
|
def _split_paragraphs(answer_md: str) -> List[str]: |
|
"""Split answer into blocks, returning only those that contain [N] citations. |
|
|
|
Splits on double newlines (paragraph breaks). Bullet lists and numbered lists |
|
that share a double-newline boundary are kept together as one block. Only |
|
blocks containing at least one [N] reference are returned — headers, intros, |
|
and transition sentences are silently dropped since the judge has nothing to |
|
verify without a citation. |
|
""" |
|
if not answer_md: |
|
return [] |
|
for marker in ("\n### Källor", "\n## Källor", "\n### Sources", "\n**Källor**"): |
|
if marker in answer_md: |
|
answer_md = answer_md.split(marker, 1)[0] |
|
break |
|
speech_chunks = [c.strip() for c in answer_md.split("\n\n") if c.strip()] |
|
return [c for c in speech_chunks if _CITATION_RE.search(c)] |
|
|
|
|
|
def _parse_judge_json(text: str) -> Optional[Dict[str, Any]]: |
|
"""Extract the first valid JSON object from a judge response. |
|
|
|
Rejects objects with an empty rationale — the judge is required to explain every verdict. |
|
""" |
|
decoder = json.JSONDecoder() |
|
for i, ch in enumerate(text): |
|
if ch == "{": |
|
try: |
|
obj, _ = decoder.raw_decode(text, i) |
|
if isinstance(obj, dict) and "verdict" in obj: |
|
if not (obj.get("rationale") or "").strip(): |
|
return None |
|
return obj |
|
except json.JSONDecodeError: |
|
continue |
|
return None |
|
|
|
|
|
def _fetch_full_talks(talk_ids: List[str]) -> Dict[str, str]: |
|
"""Return {speech_id -> talk metadata + full text} for the given ids.""" |
|
if not talk_ids: |
|
return {} |
|
bare_ids = [tid.split("/", 1)[-1] for tid in talk_ids] |
|
rows = pg.execute( |
|
"SELECT id, speaker_name, party, date::text AS date, text FROM speeches WHERE id = ANY(%s::text[])", |
|
(bare_ids,), |
|
) |
|
return { |
|
row["id"]: { |
|
"speaker_name": row["speaker_name"], |
|
"party": row["party"], |
|
"date": row["date"], |
|
"text": row["text"] or "", |
|
} |
|
for row in rows |
|
} |
|
|
|
|
|
# Combined char budget for all sources fed to the scorer. |
|
# At ~4 chars/token, 28 000 chars ≈ 7 000 tokens, safely below the 8 192-token model limit |
|
# even with a paragraph prepended. |
|
_SCORER_COMBINED_MAX_CHARS = 28_000 |
|
|
|
|
|
_SCORER_ENDPOINT = os.environ.get("SCORER_ENDPOINT", "http://localhost:8001/v1/score") |
|
_SCORER_MODEL = "BAAI/bge-reranker-v2-m3" |
|
|
|
|
|
class CitationScorer: |
|
"""Calls the vLLM /v1/score endpoint to compute how well each cited source |
|
supports the paragraph claim. |
|
|
|
Scores each (paragraph, source) pair independently and returns the maximum |
|
score across all cited sources — answering "is this claim grounded in *any* |
|
of its citations?". The raw logit is converted to a 0–1 probability via |
|
sigmoid so scores are human-readable. |
|
""" |
|
|
|
def __init__(self, endpoint: str = _SCORER_ENDPOINT, timeout: int = 10) -> None: |
|
self.endpoint = endpoint |
|
self.timeout = timeout |
|
self._available: Optional[bool] = None |
|
|
|
def _check_available(self) -> bool: |
|
if self._available is None: |
|
try: |
|
r = requests.get( |
|
self.endpoint.replace("/v1/score", "/v1/models"), timeout=3 |
|
) |
|
self._available = r.status_code == 200 |
|
except Exception: |
|
self._available = False |
|
if not self._available: |
|
print(f"[scorer] endpoint {self.endpoint} not reachable — coverage_score will be NULL") |
|
return self._available |
|
|
|
def score_all(self, paragraph: str, combined_sources: str) -> Optional[float]: |
|
"""Score the paragraph against all cited sources concatenated into one string. |
|
|
|
The combined_sources string is trimmed to _SCORER_COMBINED_MAX_CHARS before |
|
sending so the (sources + paragraph) pair fits within the model's token limit. |
|
Returns a 0–1 support probability (sigmoid of the raw logit). |
|
""" |
|
if not self._check_available(): |
|
return None |
|
try: |
|
payload = { |
|
"model": _SCORER_MODEL, |
|
"text_1": combined_sources[:_SCORER_COMBINED_MAX_CHARS], |
|
"text_2": paragraph, |
|
} |
|
r = requests.post(self.endpoint, json=payload, timeout=self.timeout) |
|
if r.status_code == 200: |
|
data = r.json().get("data", []) |
|
if data: |
|
logit = data[0].get("score", -10.0) |
|
return 1.0 / (1.0 + math.exp(-logit)) |
|
else: |
|
print(f"[scorer] HTTP {r.status_code}: {r.text[:200]}") |
|
except Exception as e: |
|
print(f"[scorer] request failed: {e}") |
|
return None |
|
|
|
|
|
class Judge: |
|
def __init__(self, llm: LLM, model_name: str, scorer: Optional["CitationScorer"] = None) -> None: |
|
self.llm = llm |
|
self.model_name = model_name |
|
self.scorer = scorer |
|
|
|
def _judge_paragraph( |
|
self, |
|
answer_md: str, |
|
paragraph: str, |
|
para_idx: int, |
|
cited_sources: List[Dict[str, Any]], |
|
) -> Optional[Dict[str, Any]]: |
|
"""One LLM call for one paragraph. Returns the verdict dict or None on failure. |
|
|
|
Runs a deterministic speaker-metadata check first; if it fires the result is stored |
|
in `metadata_mismatch` and passed to the judge as context so it can confirm or |
|
override. The LLM judge still runs regardless so we capture its independent view. |
|
""" |
|
# --- Deterministic pre-check --- |
|
metadata_mismatch = _check_speaker_metadata(paragraph, cited_sources) |
|
|
|
# Fetch full talk texts for the sources cited in this paragraph. |
|
talk_ids = [s.get("speech_id") or s.get("_id") for s in cited_sources if s.get("speech_id") or s.get("_id")] |
|
full_texts = _fetch_full_talks(talk_ids) |
|
|
|
source_block = "" |
|
combined_for_scorer_parts: List[str] = [] |
|
for s in cited_sources: |
|
n = s.get("n", "?") |
|
tid = (s.get("speech_id") or s.get("_id") or "").split("/")[-1] |
|
meta = full_texts.get(tid) |
|
if meta: |
|
source_block += ( |
|
f"\n---\nKälla [{n}]: {meta['speaker_name']} ({meta['party']}) — {meta['date']}\n" |
|
f"{meta['text']}\n" |
|
) |
|
# Scorer gets the full text (truncation handled by combined budget below). |
|
combined_for_scorer_parts.append( |
|
f"[{n}] {meta['speaker_name']} ({meta['party']}) — {meta['date']}\n{meta['text']}" |
|
) |
|
else: |
|
# Fall back to snippet if full text not found |
|
snippet = s.get("snippet", "") |
|
source_block += ( |
|
f"\n---\nKälla [{n}]: {s.get('speaker')} ({s.get('party')}) — {s.get('date')}\n" |
|
f"{snippet}\n" |
|
) |
|
if snippet: |
|
combined_for_scorer_parts.append( |
|
f"[{n}] {s.get('speaker')} ({s.get('party')}) — {s.get('date')}\n{snippet}" |
|
) |
|
|
|
# --- Coverage score (cross-encoder) --- |
|
# All cited sources are concatenated into one string so the model scores |
|
# the paragraph against the full body of cited evidence. The combined |
|
# text is capped at _SCORER_COMBINED_MAX_CHARS to stay within the 8 192- |
|
# token model limit (paragraph text is passed as text_2, so it doesn't |
|
# count toward this budget). |
|
coverage_score: Optional[float] = None |
|
if self.scorer and combined_for_scorer_parts: |
|
combined_sources = "\n\n".join(combined_for_scorer_parts) |
|
coverage_score = self.scorer.score_all(paragraph, combined_sources) |
|
|
|
mismatch_note = ( |
|
f"\nOBS: En automatisk förhandskontroll flaggade följande avvikelse i metadata: " |
|
f"{metadata_mismatch}\n" |
|
if metadata_mismatch |
|
else "" |
|
) |
|
prompt = ( |
|
f"Det fullständiga svaret från AI-assistenten:\n\n{answer_md}\n\n" |
|
f"---\n\nVi fokuserar nu ENBART på detta stycke:\n\n{paragraph}\n\n" |
|
f"---\n\nStycket refererar till följande tal (fulltext):{source_block}\n" |
|
f"---\n{mismatch_note}\n" |
|
"Bedöm om påståendena i stycket stöds av taltexterna ovan.\n" |
|
"Returnera ENDAST ett JSON-objekt med verdict, cited_indices och rationale." |
|
) |
|
|
|
resp = self.llm.generate( |
|
messages=[ |
|
{"role": "system", "content": JUDGE_SYSTEM}, |
|
{"role": "user", "content": prompt}, |
|
], |
|
think=False, |
|
) |
|
text = getattr(resp, "content", str(resp)) |
|
item = _parse_judge_json(text) |
|
if not item: |
|
return None |
|
return { |
|
"paragraph_idx": para_idx, |
|
"paragraph_text": paragraph, |
|
"cited_indices": item.get("cited_indices") or [s["n"] for s in cited_sources], |
|
"verdict": item.get("verdict") or "unsupported", |
|
"rationale": (item.get("rationale") or "")[:1000], |
|
"metadata_mismatch": metadata_mismatch, |
|
"coverage_score": coverage_score, |
|
} |
|
|
|
def verdict(self, answer_md: str, sources: List[Dict[str, Any]]) -> List[Dict[str, Any]]: |
|
"""Judge each cited paragraph individually. One LLM call per paragraph.""" |
|
paragraphs = _split_paragraphs(answer_md) |
|
if not paragraphs: |
|
return [] |
|
|
|
# Build a lookup: citation number → source dict |
|
source_by_n: Dict[int, Dict[str, Any]] = {s["n"]: s for s in sources if "n" in s} |
|
|
|
out = [] |
|
for para_idx, paragraph in enumerate(paragraphs): |
|
# Find which [N] indices this paragraph references |
|
cited_ns = [int(m) for m in _CITATION_RE.findall(paragraph)] |
|
cited_sources = [source_by_n[n] for n in cited_ns if n in source_by_n] |
|
if not cited_sources: |
|
continue # no resolvable sources — skip |
|
|
|
try: |
|
result = self._judge_paragraph(answer_md, paragraph, para_idx, cited_sources) |
|
if result: |
|
out.append(result) |
|
except Exception as e: |
|
print(f"[judge] paragraph {para_idx} failed: {e}") |
|
|
|
return out |
|
|
|
|
|
# --------------------------------------------------------------------------- |
|
# DB helpers |
|
# --------------------------------------------------------------------------- |
|
|
|
def _git_sha() -> str: |
|
try: |
|
return subprocess.check_output( |
|
["git", "rev-parse", "--short", "HEAD"], cwd=_ROOT, text=True |
|
).strip() |
|
except Exception: |
|
return "unknown" |
|
|
|
|
|
def ensure_migration() -> None: |
|
sql = (_ROOT / "_postgres/migrations/add_eval_tables.sql").read_text() |
|
pg.execute_void(sql) |
|
|
|
|
|
def create_run(label: str, config: Dict[str, Any]) -> str: |
|
row = pg.execute( |
|
"INSERT INTO eval_runs (label, config) VALUES (%s, %s) RETURNING id", |
|
(label, json.dumps(config, default=str)), |
|
) |
|
return str(row[0]["id"]) |
|
|
|
|
|
def finalize_run(run_id: str, num_questions: int) -> None: |
|
pg.execute_void( |
|
"UPDATE eval_runs SET finished_at = NOW(), num_questions = %s WHERE id = %s", |
|
(num_questions, run_id), |
|
) |
|
|
|
|
|
def insert_question( |
|
run_id: str, |
|
question: str, |
|
qtype: Optional[str], |
|
complexity: int, |
|
answer: Optional[str], |
|
tool_trace: List[Dict[str, Any]], |
|
sources: List[Dict[str, Any]], |
|
num_iterations: int, |
|
duration_ms: int, |
|
error: Optional[str], |
|
) -> str: |
|
row = pg.execute( |
|
"""INSERT INTO eval_questions |
|
(run_id, question, question_type, complexity, answer, tool_trace, sources, |
|
num_iterations, duration_ms, error) |
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""", |
|
( |
|
run_id, |
|
question, |
|
qtype, |
|
complexity, |
|
answer, |
|
json.dumps(tool_trace, default=str), |
|
json.dumps(sources, default=str), |
|
num_iterations, |
|
duration_ms, |
|
error, |
|
), |
|
) |
|
return str(row[0]["id"]) |
|
|
|
|
|
def insert_judgments( |
|
question_id: str, judgments: List[Dict[str, Any]], judge_model: str |
|
) -> None: |
|
for j in judgments: |
|
pg.execute_void( |
|
"""INSERT INTO eval_judgments |
|
(question_id, paragraph_idx, paragraph_text, cited_indices, |
|
verdict, rationale, judge_model, metadata_mismatch, coverage_score) |
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)""", |
|
( |
|
question_id, |
|
j["paragraph_idx"], |
|
j["paragraph_text"], |
|
j["cited_indices"], |
|
j["verdict"], |
|
j["rationale"], |
|
judge_model, |
|
j.get("metadata_mismatch"), |
|
j.get("coverage_score"), |
|
), |
|
) |
|
|
|
|
|
# --------------------------------------------------------------------------- |
|
# Compact source extraction |
|
# --------------------------------------------------------------------------- |
|
|
|
def compact_sources(sources: List[Dict[str, Any]]) -> List[Dict[str, Any]]: |
|
return [ |
|
{ |
|
"n": i + 1, |
|
"speech_id": s.get("_id"), |
|
"speaker": s.get("speaker"), |
|
"party": s.get("party"), |
|
"date": s.get("date"), |
|
"heading": s.get("heading"), |
|
"person_id": s.get("person_id"), |
|
"snippet": (s.get("snippet") or "")[:400], |
|
} |
|
for i, s in enumerate(sources or []) |
|
] |
|
|
|
|
|
# --------------------------------------------------------------------------- |
|
# Main loop |
|
# --------------------------------------------------------------------------- |
|
|
|
def backfill_scores(scorer: CitationScorer, run_id: Optional[str] = None) -> None: |
|
"""Populate coverage_score for all eval_judgments rows where it is NULL. |
|
|
|
Fetches question sources from the DB, re-downloads full talk texts, and calls |
|
the scorer. Optionally scoped to a single run_id; defaults to all rows. |
|
""" |
|
if not scorer._check_available(): |
|
print("[backfill] scorer not available — aborting") |
|
return |
|
|
|
where = "WHERE j.coverage_score IS NULL" |
|
params: tuple = () |
|
if run_id: |
|
where += " AND q.run_id = %s" |
|
params = (run_id,) |
|
|
|
rows = pg.execute( |
|
f"""SELECT j.id, j.paragraph_text, j.cited_indices, q.sources |
|
FROM eval_judgments j |
|
JOIN eval_questions q ON q.id = j.question_id |
|
{where} |
|
ORDER BY j.created_at""", |
|
params or None, |
|
) |
|
print(f"[backfill] {len(rows)} judgments to score") |
|
|
|
for n, row in enumerate(rows, 1): |
|
sources = row["sources"] or [] |
|
cited = row["cited_indices"] or [] |
|
paragraph = row["paragraph_text"] or "" |
|
|
|
cited_sources = [s for s in sources if s.get("n") in cited] |
|
talk_ids = [s.get("speech_id") or s.get("_id") for s in cited_sources if s.get("speech_id") or s.get("_id")] |
|
full_texts = _fetch_full_talks(talk_ids) |
|
|
|
parts: List[str] = [] |
|
for s in cited_sources: |
|
idx = s.get("n", "?") |
|
tid = (s.get("speech_id") or s.get("_id") or "").split("/")[-1] |
|
meta = full_texts.get(tid) |
|
if meta: |
|
parts.append(f"[{idx}] {meta['speaker_name']} ({meta['party']}) — {meta['date']}\n{meta['text']}") |
|
elif s.get("snippet"): |
|
parts.append(f"[{idx}] {s.get('speaker')} ({s.get('party')})\n{s['snippet']}") |
|
|
|
if not parts: |
|
continue |
|
|
|
combined = "\n\n".join(parts) |
|
score = scorer.score_all(paragraph, combined) |
|
if score is not None: |
|
pg.execute_void( |
|
"UPDATE eval_judgments SET coverage_score = %s WHERE id = %s", |
|
(score, row["id"]), |
|
) |
|
if n % 100 == 0: |
|
print(f"[backfill] {n}/{len(rows)} done") |
|
|
|
print(f"[backfill] complete — {len(rows)} rows processed") |
|
|
|
|
|
def rejudge_run(run_id: str, judge: "Judge", judge_model: str, only_missing: bool = True) -> None: |
|
"""Re-run the judge on all answered questions in a run. |
|
|
|
Args: |
|
only_missing: if True (default), skip questions that already have judgments. |
|
Pass False to wipe and redo all. |
|
""" |
|
filter_sql = ( |
|
"AND NOT EXISTS (SELECT 1 FROM eval_judgments j WHERE j.question_id = q.id)" |
|
if only_missing else "" |
|
) |
|
questions = pg.execute( |
|
f"""SELECT q.id, q.answer, q.sources |
|
FROM eval_questions q |
|
WHERE q.run_id = %s AND q.answer IS NOT NULL {filter_sql} |
|
ORDER BY q.created_at""", |
|
(run_id,), |
|
) |
|
print(f"[rejudge] {len(questions)} questions to (re)judge in run {run_id}") |
|
for n, row in enumerate(questions, 1): |
|
q_id = str(row["id"]) |
|
sources = row["sources"] or [] |
|
if not only_missing: |
|
pg.execute_void("DELETE FROM eval_judgments WHERE question_id = %s", (q_id,)) |
|
try: |
|
judgments = judge.verdict(row["answer"], sources) |
|
insert_judgments(q_id, judgments, judge_model) |
|
verdicts = [j["verdict"] for j in judgments] |
|
print(f"[rejudge {n}/{len(questions)}] {q_id[:8]}… → {verdicts}") |
|
except Exception as e: |
|
print(f"[rejudge {n}/{len(questions)}] {q_id[:8]}… failed: {e}") |
|
|
|
|
|
def replay_run( |
|
src_run_id: str, |
|
label: str, |
|
judge: "Judge", |
|
judge_model: str, |
|
chat: "ChatService", |
|
use_editor: bool = False, |
|
sleep_ms: int = 0, |
|
max_consecutive_errors: int = 10, |
|
error_backoff_s: int = 30, |
|
) -> None: |
|
"""Re-run ChatService on every question from an existing run and store results as a new run. |
|
|
|
The source run's questions are replayed in order. Each question gets a fresh |
|
ChatService call (with the new config, e.g. use_editor), new answer, new sources, |
|
and new judgments — all stored under a new run_id so the two runs can be compared |
|
side-by-side. The source run is never modified. |
|
""" |
|
src_questions = pg.execute( |
|
"""SELECT id, question, complexity FROM eval_questions |
|
WHERE run_id = %s AND question IS NOT NULL |
|
ORDER BY created_at""", |
|
(src_run_id,), |
|
) |
|
if not src_questions: |
|
print(f"[replay] no questions found in source run {src_run_id}") |
|
return |
|
|
|
config = { |
|
"smart_model": SMART_MODEL, |
|
"fast_model": FAST_MODEL, |
|
"judge_model": judge_model, |
|
"git_sha": _git_sha(), |
|
"use_editor": use_editor, |
|
"replay_of": src_run_id, |
|
} |
|
run_id = create_run(label, config) |
|
os.environ["EVAL_RUN_ID"] = run_id |
|
print(f"[replay] new run_id={run_id} label={label!r} replaying {len(src_questions)} questions from {src_run_id[:8]}…") |
|
|
|
completed = 0 |
|
consecutive_errors = 0 |
|
|
|
for i, src_q in enumerate(src_questions): |
|
question = src_q["question"] |
|
complexity = src_q["complexity"] or 1 |
|
|
|
q_uuid = str(uuid.uuid4()) |
|
os.environ["EVAL_QUESTION_ID"] = q_uuid |
|
print(f"\n[replay {i+1}/{len(src_questions)}] complexity={complexity} q={question!r}") |
|
|
|
trace = TraceCollector() |
|
t0 = time.time() |
|
answer = None |
|
sources_compact: List[Dict[str, Any]] = [] |
|
err_msg: Optional[str] = None |
|
try: |
|
result = chat.get_chat_response( |
|
messages=[{"role": "user", "content": question}], |
|
event_callback=trace.callback, |
|
use_editor=use_editor, |
|
) |
|
answer = result.get("answer") |
|
sources_compact = compact_sources(result.get("sources") or []) |
|
except Exception as e: |
|
err_msg = f"{type(e).__name__}: {e}" |
|
traceback.print_exc() |
|
consecutive_errors += 1 |
|
if consecutive_errors >= max_consecutive_errors: |
|
print(f"[replay] {consecutive_errors} consecutive errors — aborting.") |
|
insert_question( |
|
run_id=run_id, question=question, qtype=None, |
|
complexity=complexity, answer=None, tool_trace=trace.events, |
|
sources=[], num_iterations=trace._iter, |
|
duration_ms=int((time.time() - t0) * 1000), error=err_msg, |
|
) |
|
break |
|
print(f"[replay] backing off {error_backoff_s}s (consecutive={consecutive_errors})") |
|
time.sleep(error_backoff_s) |
|
duration_ms = int((time.time() - t0) * 1000) |
|
|
|
question_id = insert_question( |
|
run_id=run_id, |
|
question=question, |
|
qtype=None, |
|
complexity=complexity, |
|
answer=answer, |
|
tool_trace=trace.events, |
|
sources=sources_compact, |
|
num_iterations=trace._iter, |
|
duration_ms=duration_ms, |
|
error=err_msg, |
|
) |
|
os.environ["EVAL_QUESTION_ID"] = question_id |
|
|
|
if answer: |
|
try: |
|
judgments = judge.verdict(answer, sources_compact) |
|
insert_judgments(question_id, judgments, judge_model) |
|
verdicts = [j["verdict"] for j in judgments] |
|
print(f"[replay] verdicts={verdicts} ms={duration_ms} iters={trace._iter}") |
|
except Exception as e: |
|
print(f"[replay] judge failed (non-fatal): {e}") |
|
|
|
if not err_msg: |
|
consecutive_errors = 0 |
|
completed += 1 |
|
|
|
if sleep_ms: |
|
time.sleep(sleep_ms / 1000.0) |
|
|
|
os.environ.pop("EVAL_QUESTION_ID", None) |
|
finalize_run(run_id, completed) |
|
print(f"\n[replay] done. new run_id={run_id} completed={completed}/{len(src_questions)}") |
|
|
|
|
|
def main() -> None: |
|
ap = argparse.ArgumentParser() |
|
ap.add_argument("--label", default=None, help="Human-readable run label (required unless --rejudge-run is set).") |
|
ap.add_argument("--iterations", type=int, default=100) |
|
ap.add_argument("--judge-model", default=None, help="Model for judge + generator (default: LLM_MODEL_SMART).") |
|
ap.add_argument("--sleep-ms", type=int, default=0, help="Sleep between iterations.") |
|
ap.add_argument("--max-consecutive-errors", type=int, default=10, |
|
help="Abort after this many consecutive hard failures (default: 10).") |
|
ap.add_argument("--error-backoff-s", type=int, default=30, |
|
help="Seconds to wait after a hard failure before retrying (default: 30).") |
|
ap.add_argument("--rejudge-run", metavar="RUN_ID", |
|
help="Re-run the judge on answered questions in this run (skips generation). " |
|
"By default only fills in missing judgments; combine with --rejudge-all to redo everything.") |
|
ap.add_argument("--rejudge-all", action="store_true", |
|
help="With --rejudge-run: wipe and redo ALL judgments, not just missing ones.") |
|
ap.add_argument("--backfill-scores", action="store_true", |
|
help="Populate coverage_score for all judgments where it is NULL, then exit. " |
|
"Combine with --rejudge-run to scope to a single run.") |
|
ap.add_argument("--replay-run", metavar="RUN_ID", |
|
help="Re-run ChatService on every question from an existing run and store " |
|
"results as a new run (requires --label). Use with --use-editor to " |
|
"compare editor vs no-editor on identical questions.") |
|
ap.add_argument("--use-editor", action="store_true", |
|
help="Run the editor fact-check + language-polish pass on every answer. " |
|
"Uses the smart model by default (or the provider's editor model if set).") |
|
args = ap.parse_args() |
|
|
|
if not args.rejudge_run and not args.replay_run and not args.label and not args.backfill_scores: |
|
ap.error("--label is required when not using --rejudge-run, --replay-run, or --backfill-scores") |
|
if args.replay_run and not args.label: |
|
ap.error("--replay-run requires --label for the new run") |
|
|
|
ensure_migration() |
|
|
|
judge_model = args.judge_model or SMART_MODEL |
|
llm_url = os.getenv("LLM_DIRECT_URL") |
|
|
|
generator_llm = LLM( |
|
model=judge_model, |
|
system_message=GENERATOR_SYSTEM, |
|
temperature=0.9, |
|
base_url=llm_url, |
|
) |
|
judge_llm = LLM( |
|
model=judge_model, |
|
system_message=JUDGE_SYSTEM, |
|
temperature=0.1, |
|
base_url=llm_url, |
|
) |
|
scorer = CitationScorer() |
|
generator = QuestionGenerator(generator_llm) |
|
judge = Judge(judge_llm, judge_model, scorer=scorer) |
|
|
|
# --backfill-scores: populate coverage_score on existing judgments, then exit. |
|
if args.backfill_scores: |
|
backfill_scores(scorer, run_id=args.rejudge_run or None) |
|
return |
|
|
|
# --replay-run: re-run ChatService on an existing run's questions under a new label. |
|
if args.replay_run: |
|
replay_run( |
|
src_run_id=args.replay_run, |
|
label=args.label, |
|
judge=judge, |
|
judge_model=judge_model, |
|
chat=ChatService(), |
|
use_editor=bool(args.use_editor), |
|
sleep_ms=args.sleep_ms, |
|
max_consecutive_errors=args.max_consecutive_errors, |
|
error_backoff_s=args.error_backoff_s, |
|
) |
|
return |
|
|
|
# --rejudge-run mode: skip generation, just re-run judge on existing questions. |
|
if args.rejudge_run: |
|
rejudge_run(args.rejudge_run, judge, judge_model, only_missing=not args.rejudge_all) |
|
return |
|
|
|
config = { |
|
"smart_model": SMART_MODEL, |
|
"fast_model": FAST_MODEL, |
|
"judge_model": judge_model, |
|
"git_sha": _git_sha(), |
|
"llm_url": llm_url, |
|
"use_editor": bool(args.use_editor), |
|
} |
|
run_id = create_run(args.label, config) |
|
os.environ["EVAL_RUN_ID"] = run_id |
|
print(f"[eval] run_id={run_id} label={args.label!r} iterations={args.iterations}") |
|
|
|
chat = ChatService() |
|
completed = 0 |
|
consecutive_errors = 0 |
|
|
|
for i in range(args.iterations): |
|
try: |
|
# ---------------------------------------------------------------- |
|
# Generate question |
|
# ---------------------------------------------------------------- |
|
try: |
|
question, complexity = generator.generate() |
|
except Exception as e: |
|
print(f"[eval] generator failed iter={i}: {e}") |
|
consecutive_errors += 1 |
|
if consecutive_errors >= args.max_consecutive_errors: |
|
print(f"[eval] {consecutive_errors} consecutive errors — aborting.") |
|
break |
|
time.sleep(args.error_backoff_s) |
|
continue |
|
if not question: |
|
continue |
|
|
|
q_uuid = str(uuid.uuid4()) |
|
os.environ["EVAL_QUESTION_ID"] = q_uuid |
|
print(f"\n[eval {i+1}/{args.iterations}] complexity={complexity} q={question!r}") |
|
|
|
# ---------------------------------------------------------------- |
|
# Run ChatService |
|
# ---------------------------------------------------------------- |
|
trace = TraceCollector() |
|
t0 = time.time() |
|
answer = None |
|
sources_compact: List[Dict[str, Any]] = [] |
|
err_msg: Optional[str] = None |
|
try: |
|
result = chat.get_chat_response( |
|
messages=[{"role": "user", "content": question}], |
|
event_callback=trace.callback, |
|
use_editor=bool(args.use_editor), |
|
) |
|
answer = result.get("answer") |
|
sources_compact = compact_sources(result.get("sources") or []) |
|
except Exception as e: |
|
err_msg = f"{type(e).__name__}: {e}" |
|
traceback.print_exc() |
|
consecutive_errors += 1 |
|
if consecutive_errors >= args.max_consecutive_errors: |
|
print(f"[eval] {consecutive_errors} consecutive errors — aborting.") |
|
# Still try to persist what we have before exiting. |
|
insert_question( |
|
run_id=run_id, question=question, qtype=None, |
|
complexity=complexity, answer=None, tool_trace=trace.events, |
|
sources=[], num_iterations=trace._iter, |
|
duration_ms=int((time.time() - t0) * 1000), error=err_msg, |
|
) |
|
break |
|
print(f"[eval] backing off {args.error_backoff_s}s (consecutive={consecutive_errors})") |
|
time.sleep(args.error_backoff_s) |
|
duration_ms = int((time.time() - t0) * 1000) |
|
|
|
# ---------------------------------------------------------------- |
|
# Persist question |
|
# ---------------------------------------------------------------- |
|
question_id = insert_question( |
|
run_id=run_id, |
|
question=question, |
|
qtype=None, |
|
complexity=complexity, |
|
answer=answer, |
|
tool_trace=trace.events, |
|
sources=sources_compact, |
|
num_iterations=trace._iter, |
|
duration_ms=duration_ms, |
|
error=err_msg, |
|
) |
|
# Re-sync env with the DB-assigned id so downstream events carry the real id. |
|
os.environ["EVAL_QUESTION_ID"] = question_id |
|
|
|
# ---------------------------------------------------------------- |
|
# Judge |
|
# ---------------------------------------------------------------- |
|
if answer: |
|
try: |
|
judgments = judge.verdict(answer, sources_compact) |
|
insert_judgments(question_id, judgments, judge_model) |
|
verdicts = [j["verdict"] for j in judgments] |
|
print(f"[eval] verdicts={verdicts} ms={duration_ms} iters={trace._iter}") |
|
except Exception as e: |
|
print(f"[eval] judge failed (non-fatal): {e}") |
|
|
|
# Reset error streak on any successful iteration. |
|
if not err_msg: |
|
consecutive_errors = 0 |
|
completed += 1 |
|
|
|
except Exception as e: |
|
# Outermost safety net — should never be reached, but keeps the loop alive. |
|
print(f"[eval] unexpected error iter={i}: {e}") |
|
traceback.print_exc() |
|
consecutive_errors += 1 |
|
if consecutive_errors >= args.max_consecutive_errors: |
|
print(f"[eval] {consecutive_errors} consecutive errors — aborting.") |
|
break |
|
time.sleep(args.error_backoff_s) |
|
|
|
if args.sleep_ms: |
|
time.sleep(args.sleep_ms / 1000.0) |
|
|
|
os.environ.pop("EVAL_QUESTION_ID", None) |
|
finalize_run(run_id, completed) |
|
print(f"\n[eval] done. run_id={run_id} completed={completed}") |
|
print( |
|
"Inspect: " |
|
f"SELECT verdict, COUNT(*) FROM eval_judgments j " |
|
f"JOIN eval_questions q ON q.id=j.question_id " |
|
f"WHERE q.run_id='{run_id}' GROUP BY verdict;" |
|
) |
|
|
|
|
|
if __name__ == "__main__": |
|
main()
|
|
|