"""FinSight v3 prompt & retrieval evaluation harness. Zero-dependency (stdlib only), JSON-driven, and gracefully degrading so it can run anywhere from a laptop with no credentials up to full CI. It measures the three things v3's accuracy rests on: 1. detection — resolve_ticker() maps names/aliases to the right symbol. Needs a DB connection (the companies directory), no vector index and no API key. 2. retrieval — search_passages(ticker=...) never leaks another company's filings (no cross-company contamination). Needs the DB + populated pgvector index + the embedding model. 3. answer — run_agent() produces a grounded answer end-to-end: every figure verifies against its source and the expected topic keywords appear. Needs the DB + a GEMINI_API_KEY. Each layer that can't run (missing DB / index / key) is SKIPPED, not failed. The process exits non-zero if any case FAILS, so it can gate CI. Run from finsight-v3/backend: python -m eval.run_eval # full run python -m eval.run_eval --no-answer # skip the LLM layer (no API cost) python -m eval.run_eval --only detection """ import argparse import json import sys from pathlib import Path CASES = json.loads((Path(__file__).parent / "cases.json").read_text(encoding="utf-8")) PASS, FAIL, SKIP = "PASS", "FAIL", "SKIP" class Tally: def __init__(self) -> None: self.passed = 0 self.failed = 0 self.skipped = 0 def record(self, status: str) -> None: if status == PASS: self.passed += 1 elif status == FAIL: self.failed += 1 else: self.skipped += 1 def _line(status: str, label: str, detail: str = "") -> None: tail = f" — {detail}" if detail else "" print(f" [{status}] {label}{tail}") def _db_available() -> tuple[bool, str]: try: from db import query query("select 1") return True, "" except Exception as exc: # noqa: BLE001 — any failure means "can't reach DB" return False, str(exc).splitlines()[0] def _skip_layer(name: str, cases: list, reason: str, tally: Tally) -> None: print(f"\n{name} — SKIPPED ({reason})") for case in cases: tally.record(SKIP) # ---------------------------------------------------------------- layers def run_detection(tally: Tally) -> None: print("\ndetection — resolve_ticker() name/alias correctness") from tools import resolve_ticker for case in CASES["detection"]: got = resolve_ticker(case["input"]) if got == case["expected"]: tally.record(PASS) _line(PASS, f"{case['input']!r} -> {got}") else: tally.record(FAIL) _line(FAIL, f"{case['input']!r} -> {got}", f"expected {case['expected']}") def run_retrieval(tally: Tally) -> None: print("\nretrieval — search_passages() company scoping (no contamination)") from retrieval import search_passages for case in CASES["retrieval"]: ticker = case["ticker"].upper() hits = search_passages(case["query"], ticker=ticker, k=6) stray = sorted({h["ticker"].upper() for h in hits if h["ticker"].upper() != ticker}) if stray: tally.record(FAIL) _line(FAIL, f"{ticker}: {case['query']!r}", f"leaked {stray}") else: tally.record(PASS) note = f"{len(hits)} passages, all {ticker}" if hits else "no chunks (still clean)" _line(PASS, f"{ticker}: {case['query']!r}", note) def run_answer(tally: Tally) -> None: print("\nanswer — run_agent() grounded end-to-end answers") from agent import run_agent for case in CASES["answer"]: question = case["question"] try: events = list(run_agent([{"role": "user", "content": question}])) except Exception as exc: # noqa: BLE001 tally.record(FAIL) _line(FAIL, question, f"agent raised: {str(exc).splitlines()[0]}") continue answer_event = next((e for e in reversed(events) if e.type == "answer"), None) if answer_event is None: error_event = next((e for e in events if e.type == "error"), None) reason = error_event.data.get("message", "no answer") if error_event else "no answer" tally.record(FAIL) _line(FAIL, question, reason) continue answer = (answer_event.data.get("answer") or "").strip() verification = answer_event.data.get("verification", {}) unverified = verification.get("unverified", []) missing = [kw for kw in case["expect_keywords"] if kw.lower() not in answer.lower()] problems = [] if not answer: problems.append("empty answer") if unverified: problems.append(f"unverified figures {unverified}") if missing: problems.append(f"missing keywords {missing}") if problems: tally.record(FAIL) _line(FAIL, question, "; ".join(problems)) else: tally.record(PASS) checked = verification.get("figures_checked", 0) _line(PASS, question, f"{checked} figures, all verified") # ---------------------------------------------------------------- driver def main() -> int: parser = argparse.ArgumentParser(description="FinSight v3 eval harness") parser.add_argument("--no-answer", action="store_true", help="skip the LLM answer layer (no API cost)") parser.add_argument("--only", choices=["detection", "retrieval", "answer"], help="run only one layer") args = parser.parse_args() layers = ["detection", "retrieval", "answer"] if args.only: layers = [args.only] db_ok, db_reason = _db_available() from config import get_settings key_ok = bool(get_settings().gemini_api_key) tally = Tally() print("=" * 72) print("FinSight v3 evaluation") print(f" database: {'reachable' if db_ok else f'unavailable ({db_reason})'}") print(f" gemini key: {'present' if key_ok else 'missing'}") print("=" * 72) if "detection" in layers: if db_ok: run_detection(tally) else: _skip_layer("detection", CASES["detection"], "no database", tally) if "retrieval" in layers: if not db_ok: _skip_layer("retrieval", CASES["retrieval"], "no database", tally) else: try: run_retrieval(tally) except Exception as exc: # noqa: BLE001 — missing index / embedding model _skip_layer("retrieval", CASES["retrieval"], f"index/embedder unavailable: {str(exc).splitlines()[0]}", tally) if "answer" in layers: if args.no_answer: _skip_layer("answer", CASES["answer"], "--no-answer", tally) elif not db_ok: _skip_layer("answer", CASES["answer"], "no database", tally) elif not key_ok: _skip_layer("answer", CASES["answer"], "no GEMINI_API_KEY", tally) else: run_answer(tally) print("\n" + "=" * 72) print(f"{tally.passed} passed, {tally.failed} failed, {tally.skipped} skipped") print("=" * 72) return 1 if tally.failed else 0 if __name__ == "__main__": sys.exit(main())