File size: 20,143 Bytes
36839fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d8120e
36839fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d8120e
36839fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d8120e
36839fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
"""Evaluation harness: run the human-verified QA set, compute metrics,
write eval/report.md.

Metrics
-------
1. Correctness        LLM-as-judge: system answer vs reference (0 / 0.5 / 1).
2. Groundedness       LLM-as-judge: fraction of claims in the answer that are
                      supported by the retrieved context.
3. Citation validity  Programmatic: (a) every cited (company, page) must exist
                      in the retrieved chunks shown to the model; (b) for
                      answers containing figures, at least one figure must
                      appear in a cited page's text.
4. Correct abstention Precision / recall of the exact abstention string on
                      unanswerable vs answerable items.

LLM calls are cached on disk (eval/.cache) keyed by content hash, so re-runs
after a crash or config tweak only pay for what changed.

Run with:  python -m src.evaluate            (uses eval/qa_set.jsonl)
           python -m src.evaluate --qa-set eval/qa_candidates.jsonl --allow-unverified
"""
from __future__ import annotations

import argparse
import hashlib
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import config
from src.answer import AnswerResult, answer, get_client

JUDGE_CORRECTNESS_SCHEMA = {
    "type": "object",
    "properties": {
        "score": {"type": "number", "enum": [0, 0.5, 1]},
        "reason": {"type": "string"},
    },
    "required": ["score", "reason"],
    "additionalProperties": False,
}

JUDGE_GROUNDEDNESS_SCHEMA = {
    "type": "object",
    "properties": {
        "total_claims": {"type": "integer"},
        "supported_claims": {"type": "integer"},
        "unsupported": {"type": "array", "items": {"type": "string"}},
    },
    "required": ["total_claims", "supported_claims", "unsupported"],
    "additionalProperties": False,
}

CORRECTNESS_PROMPT = """You are grading a question-answering system on company annual reports.

Question: {question}

Reference answer (ground truth, human-verified):
{reference}

System answer:
{answer}

Score the system answer against the reference:
- 1: factually matches the reference on the substance asked (figures must match; extra correct context is fine; citation markers are ignored)
- 0.5: partially correct (e.g. right direction but missing/imprecise figure, or answers only part of the question)
- 0: wrong figure, contradicts the reference, or fails to answer the substance

Judge only factual agreement with the reference. Formatting, wording and citation brackets do not matter."""

GROUNDEDNESS_PROMPT = """You are auditing whether an answer is grounded in its source context.

Context excerpts that were provided to the answering system:
{context}

Answer to audit:
{answer}

Split the answer into its distinct factual claims (a figure, a date, a named fact each count as one claim; ignore citation brackets like [Company, p.X]). For each claim, decide whether it is directly supported by the context excerpts. Count conservatively: a claim not traceable to the excerpts is unsupported, even if plausible.

Do NOT count statements of absence as claims, such as "Not stated in the provided documents", "the excerpts do not include X" or "X is not mentioned". They describe what the excerpts lack, not facts about the companies, and cannot be supported by quoting text. Only count positive factual claims. If the answer contains no positive factual claims, report total_claims as 0.

Report total_claims, supported_claims, and list each unsupported claim verbatim."""


@dataclass
class ItemResult:
    """Per-item evaluation outcome."""

    item: dict[str, Any]
    result: AnswerResult
    correctness: float | None = None
    correctness_reason: str = ""
    groundedness: float | None = None
    unsupported_claims: list[str] = field(default_factory=list)
    citations_total: int = 0
    citations_resolvable: int = 0
    retrieval_hit: bool | None = None  # expected page among retrieved chunks (answerable only)
    figure_supported: bool | None = None
    abstain_expected: bool = False
    abstained: bool = False


# --- caching ---------------------------------------------------------------

def _cache_path(kind: str, key: str) -> Path:
    config.EVAL_CACHE_DIR.mkdir(parents=True, exist_ok=True)
    return config.EVAL_CACHE_DIR / f"{kind}-{key}.json"


def _cache_key(*parts: str) -> str:
    return hashlib.sha256("||".join(parts).encode("utf-8")).hexdigest()[:24]


def cached_json_call(kind: str, key: str, fn) -> dict[str, Any]:
    path = _cache_path(kind, key)
    if path.exists():
        return json.loads(path.read_text(encoding="utf-8"))
    value = fn()
    path.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
    return value


# --- LLM judge -------------------------------------------------------------

def judge_json(prompt: str, schema: dict[str, Any]) -> dict[str, Any]:
    """One structured-output judge call; response is guaranteed valid JSON."""
    response = get_client().messages.create(
        model=config.JUDGE_MODEL,
        max_tokens=config.MAX_JUDGE_TOKENS,
        temperature=0.0,
        messages=[{"role": "user", "content": prompt}],
        output_config={"format": {"type": "json_schema", "schema": schema}},
    )
    text = next(b.text for b in response.content if b.type == "text")
    return json.loads(text)


# --- metric helpers --------------------------------------------------------

NUMBER_RE = re.compile(r"\d[\d,.\s]*\d|\d")


def _norm_ws(s: str) -> str:
    return re.sub(r"\s+", " ", s.replace(" ", " "))


def _answer_figures(text: str) -> list[str]:
    """Numeric strings in the answer body (citation brackets stripped)."""
    body = re.sub(r"\[[^\]]*\]", " ", text)
    figures = []
    for m in NUMBER_RE.finditer(body):
        fig = m.group().strip(" .,")
        if len(fig.replace(",", "").replace(" ", "")) >= 2:  # skip bare single digits
            figures.append(fig)
    return figures


def check_citations(res: AnswerResult) -> tuple[int, int, bool | None]:
    """Programmatic citation validity.

    Returns (total citations, resolvable citations, figure_supported):
    - resolvable: the cited (company, page) pair is among the retrieved chunks
      the model actually saw.
    - figure_supported: True if at least one numeric figure from the answer
      appears verbatim in the text of a cited page (None if the answer has no
      figures or no citations).
    """
    retrieved = {(c.company, c.page): _norm_ws(c.text) for c in res.chunks}
    total = len(res.citations)
    resolvable = sum(1 for c in res.citations if (c.company, c.page) in retrieved)

    figures = _answer_figures(res.answer)
    if not figures or total == 0:
        return total, resolvable, None
    cited_text = " ".join(
        retrieved.get((c.company, c.page), "") for c in res.citations
    )
    supported = any(_norm_ws(f) in cited_text for f in figures)
    return total, resolvable, supported


# --- main loop -------------------------------------------------------------

def load_qa_set(path: Path, allow_unverified: bool) -> list[dict[str, Any]]:
    items = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
    unverified = [i["id"] for i in items if not i.get("verified")]
    if unverified and not allow_unverified:
        raise SystemExit(
            f"Refusing to evaluate: {len(unverified)} items are not human-verified "
            f"(ids {unverified[:10]}...). Use --allow-unverified for a dry run."
        )
    return items


def evaluate_item(item: dict[str, Any]) -> ItemResult:
    question = item["question"]
    from src.answer import SYSTEM_PROMPT
    # The answer prompt is part of the key so prompt changes always produce fresh answers.
    from src.retrieve import RERANK_PROMPT
    retrieval_cfg = f"{config.RETRIEVAL_MODE}|{config.FILTER_BY_COMPANY}|{config.RERANK}|{config.RERANK_CANDIDATES}|{RERANK_PROMPT if config.RERANK else ''}"
    key = _cache_key(config.ANSWER_MODEL, str(config.TOP_K), retrieval_cfg, SYSTEM_PROMPT, question)

    def _run() -> dict[str, Any]:
        r = answer(question)
        return {
            "answer": r.answer,
            "abstained": r.abstained,
            "citations": [{"company": c.company, "page": c.page} for c in r.citations],
            "chunks": [
                {"score": c.score, "company": c.company, "doc": c.doc,
                 "page": c.page, "text": c.text, "id": c.chunk_id}
                for c in r.chunks
            ],
        }

    raw = cached_json_call("answer", key, _run)
    from src.answer import Citation
    from src.retrieve import RetrievedChunk

    # Recompute abstention from the answer text (prefix match) rather than
    # trusting the cached flag, so detection fixes apply to cached runs too.
    raw["abstained"] = raw["answer"].strip().startswith(config.ABSTAIN_STRING)
    res = AnswerResult(
        question=question,
        answer=raw["answer"],
        citations=[Citation(**c) for c in raw["citations"]],
        chunks=[RetrievedChunk(score=c["score"], company=c["company"], doc=c["doc"],
                               page=c["page"], text=c["text"], chunk_id=c["id"])
                for c in raw["chunks"]],
        abstained=raw["abstained"],
    )

    out = ItemResult(item=item, result=res,
                     abstain_expected=item["type"] == "unanswerable",
                     abstained=res.abstained)

    if item["type"] == "answerable":
        expected = {(s["company"], p) for s in item.get("expected_sources", []) for p in s["pages"]}
        retrieved = {(c.company, c.page) for c in res.chunks}
        out.retrieval_hit = bool(expected & retrieved) if expected else None

    if item["type"] == "answerable" and not res.abstained:
        cj = cached_json_call(
            "correctness",
            _cache_key(config.JUDGE_MODEL, CORRECTNESS_PROMPT, question, item["reference_answer"], res.answer),
            lambda: judge_json(
                CORRECTNESS_PROMPT.format(question=question,
                                          reference=item["reference_answer"],
                                          answer=res.answer),
                JUDGE_CORRECTNESS_SCHEMA,
            ),
        )
        out.correctness = float(cj["score"])
        out.correctness_reason = cj["reason"]
    elif item["type"] == "answerable" and res.abstained:
        out.correctness = 0.0
        out.correctness_reason = "System abstained on an answerable question."

    # Judge answers, and also abstentions that carry an explanation, so a
    # hallucinated explanation is caught just like a hallucinated answer.
    has_text = res.answer.strip() != config.ABSTAIN_STRING
    if not res.abstained or has_text:
        context = "\n\n".join(
            f"[{c.company}, p.{c.page}] {c.text}" for c in res.chunks
        )
        gj = cached_json_call(
            "groundedness",
            # Prompt text is part of the key so a prompt change never reuses stale verdicts.
            _cache_key(config.JUDGE_MODEL, GROUNDEDNESS_PROMPT, res.answer, str(len(res.chunks))),
            lambda: judge_json(
                GROUNDEDNESS_PROMPT.format(context=context, answer=res.answer),
                JUDGE_GROUNDEDNESS_SCHEMA,
            ),
        )
        total = gj["total_claims"]
        # An answer with no positive factual claims (e.g. a bare statement of
        # absence) has nothing to ground, so it is not scored rather than scored 0.
        out.groundedness = (min(gj["supported_claims"], total) / total) if total > 0 else None
        out.unsupported_claims = gj["unsupported"]
        out.citations_total, out.citations_resolvable, out.figure_supported = check_citations(res)

    return out


def compute_summary(results: list[ItemResult]) -> dict[str, Any]:
    answerable = [r for r in results if r.item["type"] == "answerable"]
    unanswerable = [r for r in results if r.item["type"] == "unanswerable"]

    correctness = [r.correctness for r in answerable if r.correctness is not None]
    groundedness = [r.groundedness for r in results if r.groundedness is not None]

    cit_total = sum(r.citations_total for r in results)
    cit_ok = sum(r.citations_resolvable for r in results)
    fig_checked = [r for r in results if r.figure_supported is not None]
    fig_ok = sum(1 for r in fig_checked if r.figure_supported)

    true_abstain = sum(1 for r in unanswerable if r.abstained)
    false_abstain = sum(1 for r in answerable if r.abstained)
    all_abstain = true_abstain + false_abstain

    return {
        "n_items": len(results),
        "n_answerable": len(answerable),
        "n_unanswerable": len(unanswerable),
        "correctness_mean": sum(correctness) / len(correctness) if correctness else None,
        "correctness_full": sum(1 for c in correctness if c == 1.0),
        "correctness_partial": sum(1 for c in correctness if c == 0.5),
        "correctness_zero": sum(1 for c in correctness if c == 0.0),
        "groundedness_mean": sum(groundedness) / len(groundedness) if groundedness else None,
        "citations_total": cit_total,
        "citations_resolvable": cit_ok,
        "citation_validity": cit_ok / cit_total if cit_total else None,
        "figure_support_rate": fig_ok / len(fig_checked) if fig_checked else None,
        "abstention_recall": true_abstain / len(unanswerable) if unanswerable else None,
        "abstention_precision": true_abstain / all_abstain if all_abstain else None,
        "false_abstentions": false_abstain,
        "retrieval_hit_rate": (sum(1 for r in answerable if r.retrieval_hit) / len([r for r in answerable if r.retrieval_hit is not None])) if any(r.retrieval_hit is not None for r in answerable) else None,
        "retrieval_hits": sum(1 for r in answerable if r.retrieval_hit),
    }


def _fmt(x: float | None, pct: bool = True) -> str:
    if x is None:
        return "n/a"
    return f"{x:.1%}" if pct else f"{x:.3f}"


def write_report(results: list[ItemResult], summary: dict[str, Any], qa_path: Path,
                 out_path: Path = config.EVAL_REPORT_PATH) -> None:
    lines: list[str] = []
    lines.append("# FinDocRAG evaluation report\n")
    lines.append(f"- QA set: `{qa_path.name}`: {summary['n_items']} items "
                 f"({summary['n_answerable']} answerable, {summary['n_unanswerable']} unanswerable)")
    lines.append(f"- Answer model: `{config.ANSWER_MODEL}` | Judge model: `{config.JUDGE_MODEL}`")
    lines.append(f"- Retrieval: `{config.RETRIEVAL_MODE}`"
                 + (f", company filter" if config.FILTER_BY_COMPANY else "")
                 + (f", LLM rerank of top {config.RERANK_CANDIDATES} candidates (`{config.RERANK_MODEL}`)" if config.RERANK and config.RETRIEVAL_MODE == "hybrid" else ""))
    lines.append(f"- Embeddings: `{config.EMBEDDING_MODEL}` | top_k={config.TOP_K} | "
                 f"chunks: {config.CHUNK_SIZE_TOKENS}/{config.CHUNK_OVERLAP_TOKENS} tokens\n")

    lines.append("## Scorecard\n")
    lines.append("| Metric | Score | Notes |")
    lines.append("|---|---|---|")
    lines.append(f"| Retrieval hit rate (programmatic, answerable) | {_fmt(summary['retrieval_hit_rate'])} | "
                 f"{summary['retrieval_hits']}/{summary['n_answerable']} questions where a verified source page was among the retrieved chunks |")
    lines.append(f"| Correctness (LLM judge, answerable) | {_fmt(summary['correctness_mean'])} | "
                 f"{summary['correctness_full']} full / {summary['correctness_partial']} partial / "
                 f"{summary['correctness_zero']} wrong |")
    lines.append(f"| Groundedness (LLM judge, answers + abstention explanations) | {_fmt(summary['groundedness_mean'])} | "
                 f"share of claims supported by retrieved context |")
    lines.append(f"| Citation validity (programmatic) | {_fmt(summary['citation_validity'])} | "
                 f"{summary['citations_resolvable']}/{summary['citations_total']} citations resolve "
                 f"to retrieved (company, page) |")
    lines.append(f"| Figure-in-cited-page rate (programmatic) | {_fmt(summary['figure_support_rate'])} | "
                 f"answers with figures where a figure appears verbatim in a cited page |")
    lines.append(f"| Abstention recall | {_fmt(summary['abstention_recall'])} | "
                 f"unanswerable questions correctly refused |")
    lines.append(f"| Abstention precision | {_fmt(summary['abstention_precision'])} | "
                 f"{summary['false_abstentions']} false abstention(s) on answerable questions |\n")

    failures = [
        r for r in results
        if (r.correctness is not None and r.correctness < 1.0)
        or (r.groundedness is not None and r.groundedness < 1.0)
        or (r.abstain_expected and not r.abstained)
        or (not r.abstain_expected and r.abstained)
    ]
    lines.append("## Failure cases\n")
    if not failures:
        lines.append("None. Every item scored full marks on every metric.\n")
    else:
        lines.append("| # | Type | Question | Issue |")
        lines.append("|---|---|---|---|")
        for r in failures:
            issues = []
            if r.abstain_expected and not r.abstained:
                issues.append("answered instead of abstaining")
            if not r.abstain_expected and r.abstained:
                issues.append("abstained instead of answering")
            if r.correctness is not None and r.correctness < 1.0 and not r.abstained:
                issues.append(f"correctness {r.correctness:g}: {r.correctness_reason}")
            if r.groundedness is not None and r.groundedness < 1.0:
                issues.append(f"unsupported claims: {'; '.join(r.unsupported_claims[:3])}")
            q = r.item["question"]
            issue_text = " | ".join(issues).replace("|", "/")[:400]
            lines.append(f"| {r.item['id']} | {r.item['type']} | {q} | {issue_text} |")
        lines.append("")

    lines.append("## Method notes\n")
    lines.append("- Correctness and groundedness use an LLM judge "
                 f"(`{config.JUDGE_MODEL}`, temperature 0, structured JSON output).")
    lines.append("- Citation validity and figure support are purely programmatic checks "
                 "against the chunk page metadata, with no model involved.")
    lines.append("- Abstention is detected when the answer starts with the configured abstention sentence; any explanation after it is judged for groundedness and its citations are checked like any other answer.")
    lines.append("- Statements of absence (\"X is not stated\") are not counted as claims by the groundedness judge.")
    lines.append("- All LLM calls are cached in `eval/.cache/`; delete it to force a fresh run.")

    out_path.write_text("\n".join(lines), encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser(description="Run the FinDocRAG evaluation harness.")
    parser.add_argument("--qa-set", type=Path, default=config.QA_SET_PATH)
    parser.add_argument("--allow-unverified", action="store_true",
                        help="Evaluate items not yet human-verified (dry runs only).")
    parser.add_argument("--report", type=Path, default=config.EVAL_REPORT_PATH,
                        help="Where to write the markdown report (e.g. eval/report_heldout.md).")
    args = parser.parse_args()

    items = load_qa_set(args.qa_set, allow_unverified=args.allow_unverified)
    print(f"Evaluating {len(items)} items from {args.qa_set} ...")

    results: list[ItemResult] = []
    for i, item in enumerate(items, 1):
        r = evaluate_item(item)
        results.append(r)
        tag = "ABSTAIN" if r.abstained else f"corr={r.correctness if r.correctness is not None else '-'}"
        print(f"  [{i:2d}/{len(items)}] id={item['id']:3d} {item['type']:12s} {tag}")

    summary = compute_summary(results)
    write_report(results, summary, args.qa_set, args.report)

    print("\n--- Summary ---")
    for k, v in summary.items():
        print(f"  {k}: {v}")
    print(f"\nReport written to {args.report}")


if __name__ == "__main__":
    main()