"""Evaluate FinChat against the FinanceBench benchmark (corpus-aligned subset). Runs FinChat on every FinanceBench 10-K question whose company + fiscal year is present in our EDGAR corpus, then grades each answer against FinanceBench's gold answer with an LLM-as-judge. Writes eval/results.md. FinanceBench is a deliberately hard, expert-written benchmark, so the goal is an honest, measured score on real questions — not a perfect one. Run from the project root: python -m eval.run_eval """ from __future__ import annotations import re import sys import time from pathlib import Path # Make `src` and `eval` importable no matter how this is launched. sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from datasets import load_dataset from langchain_core.prompts import ChatPromptTemplate from langchain_groq import ChatGroq from src import config from src.rag import answer, get_llm # Cap the number of questions so a full run fits Groq's free-tier daily token # budget. Sampled with an even stride across companies for representativeness. MAX_QUESTIONS = 30 # Grade with a small, cheap, high-rate-limit model (judging is easy); this # keeps the expensive 70B model's token budget for answering. JUDGE_MODEL = "llama-3.1-8b-instant" _judge_llm = None def get_judge_llm() -> ChatGroq: global _judge_llm if _judge_llm is None: _judge_llm = ChatGroq(model=JUDGE_MODEL, temperature=0.0, max_retries=5) return _judge_llm # Windows consoles default to cp1252 and crash when print() emits Unicode # (curly quotes etc. from FinanceBench questions). Force UTF-8 output. try: sys.stdout.reconfigure(encoding="utf-8", errors="replace") except Exception: pass # FinanceBench company name -> ticker (aligned with config.TARGET_FILINGS). FB_COMPANY_TO_TICKER = { "AMD": "AMD", "American Express": "AXP", "Boeing": "BA", "PepsiCo": "PEP", "Amcor": "AMCR", "3M": "MMM", "Johnson & Johnson": "JNJ", "CVS Health": "CVS", "Pfizer": "PFE", "AES Corporation": "AES", "Verizon": "VZ", "Best Buy": "BBY", "Adobe": "ADBE", "Ulta Beauty": "ULTA", "Coca-Cola": "KO", "Microsoft": "MSFT", "Nike": "NKE", "Corning": "GLW", } JUDGE_PROMPT = ChatPromptTemplate.from_messages( [ ( "system", "You grade a financial question-answering system against a reference " "answer from the FinanceBench benchmark.\n" "Grade CORRECT if the system answer agrees with the reference on the " "key fact(s) or number(s) (minor wording or rounding is fine), " "PARTIAL if it is partially right or incomplete, and INCORRECT if it " "is wrong, empty, or says it cannot find the answer.\n" "Respond in EXACTLY this format:\n" "VERDICT: \n" "REASON: ", ), ( "human", "QUESTION:\n{question}\n\nREFERENCE ANSWER:\n{reference}\n\n" "SYSTEM ANSWER:\n{system}", ), ] ) SCORE = {"CORRECT": 1.0, "PARTIAL": 0.5, "INCORRECT": 0.0} def judge(question: str, reference: str, system: str) -> tuple[str, str]: text = (JUDGE_PROMPT | get_judge_llm()).invoke( {"question": question, "reference": reference, "system": system} ).content v = re.search(r"VERDICT:\s*(CORRECT|PARTIAL|INCORRECT)", text, re.I) r = re.search(r"REASON:\s*(.+)", text, re.I) verdict = v.group(1).upper() if v else "INCORRECT" reason = r.group(1).strip() if r else "(unparsed)" return verdict, reason def select_questions() -> list[dict]: """FinanceBench 10-K questions whose (company, fiscal year) is in our corpus.""" ingested = {(t, str(y)) for t, _name, y in config.TARGET_FILINGS} fb = load_dataset("PatronusAI/financebench", split="train") picked = [] for ex in fb: if "10K" not in str(ex.get("doc_name", "")): continue ticker = FB_COMPANY_TO_TICKER.get(str(ex.get("company"))) if ticker and (ticker, str(ex.get("doc_period"))) in ingested: picked.append(ex) return picked def main() -> None: rows = select_questions() if len(rows) > MAX_QUESTIONS: # even-stride sample stride = len(rows) // MAX_QUESTIONS rows = rows[::stride][:MAX_QUESTIONS] print(f"Evaluating {len(rows)} corpus-aligned FinanceBench 10-K questions.\n") results = [] total = 0.0 for ex in rows: res = answer(ex["question"]) verdict, reason = judge(ex["question"], ex.get("answer", ""), res["answer"]) total += SCORE[verdict] results.append((ex, res, verdict, reason)) print(f"[{verdict:9}] {ex.get('company')}: {ex['question'][:70]}") time.sleep(1.0) # ease off the free-tier rate limit n = len(rows) accuracy = total / n if n else 0.0 correct = sum(1 for _e, _r, v, _j in results if v == "CORRECT") partial = sum(1 for _e, _r, v, _j in results if v == "PARTIAL") lines = [ "# FinChat Evaluation — FinanceBench (corpus-aligned subset)\n", "FinChat is graded by an LLM-as-judge against gold answers from the " "[FinanceBench](https://huggingface.co/datasets/PatronusAI/financebench) " "benchmark, on every 10-K question whose company + fiscal year is in the " "corpus. FinanceBench is expert-written and intentionally hard.\n", f"- **Questions evaluated:** {n}", f"- **CORRECT:** {correct} **PARTIAL:** {partial} " f"**INCORRECT:** {n - correct - partial}", f"- **Score:** {total:.1f} / {n}", f"- **Accuracy (CORRECT=1.0, PARTIAL=0.5):** {accuracy:.0%}\n", ] # Accuracy by FinanceBench question type -> shows the qualitative-vs-numeric # split (metrics-generated questions require computation over tables). by_type: dict[str, list[float]] = {} for ex, _res, verdict, _reason in results: agg = by_type.setdefault(str(ex.get("question_type", "unknown")), [0.0, 0]) agg[0] += SCORE[verdict] agg[1] += 1 print("\nAccuracy by question type:") lines += ["**Accuracy by FinanceBench question type:**\n", "| Question type | Accuracy | N |", "|---|---|---|"] for qt, (s, c) in sorted(by_type.items()): print(f" {qt:24} {s / c:.0%} ({c})") lines.append(f"| {qt} | {s / c:.0%} | {c} |") lines += ["\n| # | Company | FY | Verdict | Question |", "|---|---------|----|---------|----------|"] for i, (ex, _res, verdict, _reason) in enumerate(results, 1): q = ex["question"].replace("|", "\\|") lines.append( f"| {i} | {ex.get('company')} | {ex.get('doc_period')} | {verdict} | {q} |" ) lines += ["\n---\n", "## Detailed results\n"] for i, (ex, res, verdict, reason) in enumerate(results, 1): lines += [ f"### {i}. {ex['question']}", f"- **Company / FY:** {ex.get('company')} {ex.get('doc_period')} | " f"**Routed to:** {res['routed_to']} | **Verdict:** {verdict}", f"- **Judge:** {reason}", f"- **FinanceBench gold:** {ex.get('answer', '')}", f"- **FinChat:** {res['answer']}\n", ] out_path = Path(__file__).resolve().parent / "results.md" out_path.write_text("\n".join(lines), encoding="utf-8") print(f"\nAccuracy: {accuracy:.0%} ({total:.1f}/{n}) -> wrote {out_path}") if __name__ == "__main__": main()