File size: 7,809 Bytes
b2931f4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Scoring for the Day-4 eval β€” two layers.

Deterministic (no LLM, cheap, objective):
  - number matching: does the answer state the ground-truth figure? Robust to
    formatting ($96.995 billion / $96,995 million / 96.995B / -2.8%).
  - citation validity: are the [N] anchors in the answer real (1..n_chunks)?
  - refusal detection: keyword backstop for the honesty tier.

LLM-judge (Claude, always β€” even when the *system under test* is Gemini, so the
grader is held constant across the A/B; self-preference bias is named in the
writeup):
  - faithfulness: is every claim grounded in the provided context/tool results?
  - answer relevance: does the answer actually address the question?
  - context precision: what fraction of retrieved chunks are relevant?
  - refusal judgement: did the agent appropriately decline?

The judge is deliberately pinned to claude.generate_text (not the provider
dispatcher) so the eval never grades an answer with the same model that wrote it
when that model is the variable under test.
"""

from __future__ import annotations

import json
import re

from finrag.llm.claude import generate_text as _claude_generate

# ── Deterministic: number extraction ─────────────────────────────────────
_SCALE = {
    "trillion": 1e12, "tn": 1e12,
    "billion": 1e9, "bn": 1e9,
    "million": 1e6, "mn": 1e6,
    "thousand": 1e3,
}
# $-led or scale-worded magnitudes. Plain bare integers (e.g. "2023") are NOT
# matched as currency β€” they must carry a $ or a scale word to count.
_CURRENCY_RE = re.compile(
    r"\$\s?(-?\d[\d,]*(?:\.\d+)?)\s*(trillion|billion|million|thousand|tn|bn|mn)?"
    r"|(-?\d[\d,]*(?:\.\d+)?)\s+(trillion|billion|million|thousand)",
    re.IGNORECASE,
)
_PERCENT_RE = re.compile(r"(-?\d+(?:\.\d+)?)\s*(?:%|percent)", re.IGNORECASE)
_DECIMAL_RE = re.compile(r"-?\d+\.\d+")


def extract_currency(text: str) -> list[float]:
    """All monetary magnitudes in `text`, normalized to absolute dollars."""
    out: list[float] = []
    for m in _CURRENCY_RE.finditer(text):
        num = m.group(1) or m.group(3)
        scale = (m.group(2) or m.group(4) or "").lower()
        if num is None:
            continue
        try:
            val = float(num.replace(",", ""))
        except ValueError:
            continue
        out.append(val * _SCALE.get(scale, 1.0))
    return out


def extract_percentages(text: str) -> list[float]:
    return [float(m.group(1)) for m in _PERCENT_RE.finditer(text)]


def extract_decimals(text: str) -> list[float]:
    return [float(m.group(0)) for m in _DECIMAL_RE.finditer(text)]


def number_hit(expected: float, kind: str, text: str, tol: float) -> bool:
    """Does `text` state `expected`? `tol` is a relative tolerance for currency
    and per-share; for percent it's the absolute pp floor, OR'd with a 5%
    relative band so both '44%' and '44.13%' match a 44.13 ground truth."""
    if kind == "currency":
        band = abs(expected) * tol
        return any(abs(c - expected) <= band for c in extract_currency(text))
    if kind == "per_share":
        band = max(abs(expected) * tol, 0.01)
        cands = extract_decimals(text) + extract_currency(text)
        return any(abs(c - expected) <= band for c in cands)
    if kind == "percent":
        band = max(tol, abs(expected) * 0.05)
        return any(abs(abs(c) - abs(expected)) <= band for c in extract_percentages(text))
    raise ValueError(f"unknown gt_kind {kind!r}")


# ── Deterministic: citations ──────────────────────────────────────────────
_CITE_RE = re.compile(r"\[(\d+)\]")


def citations(text: str) -> list[int]:
    return [int(m.group(1)) for m in _CITE_RE.finditer(text)]


def citation_validity(text: str, n_chunks: int) -> tuple[bool, list[int]]:
    """True if the answer cites at least one chunk and every [N] is in range.
    Returns (valid, out_of_range_anchors)."""
    cites = citations(text)
    if not cites:
        return False, []
    bad = [c for c in cites if c < 1 or c > n_chunks]
    return (not bad), bad


# ── Deterministic: refusal backstop ───────────────────────────────────────
_REFUSAL_MARKERS = (
    "not in the corpus", "not in the provided", "isn't in the corpus",
    "is not in the corpus", "not available in", "cannot answer", "can't answer",
    "do not have", "don't have", "not included in", "no information",
    "not contain", "outside the", "not part of the corpus", "unable to",
    "not found in", "not covered",
)


def looks_like_refusal(text: str) -> bool:
    low = text.lower()
    return any(m in low for m in _REFUSAL_MARKERS)


# ── LLM judge ─────────────────────────────────────────────────────────────
def _judge_json(system: str, user: str, *, max_tokens: int = 400) -> dict:
    """Call the Claude judge and parse its JSON verdict. Tolerant of code-fence
    wrapping; returns {} on parse failure so a flaky judge degrades to a missing
    score rather than crashing the whole run."""
    raw = _claude_generate(system, user, max_output_tokens=max_tokens, temperature=0.0)
    m = re.search(r"\{.*\}", raw, re.DOTALL)
    if not m:
        return {}
    try:
        return json.loads(m.group(0))
    except json.JSONDecodeError:
        return {}


_FAITH_SYS = (
    "You are a meticulous evaluator of financial question-answering. You are given "
    "CONTEXT (retrieved filing excerpts and tool results) and an ANSWER. Judge "
    "ONLY whether each factual claim in the ANSWER is supported by the CONTEXT β€” "
    "not whether it is true in general. Ignore citation markers like [1]. "
    'Reply with ONLY JSON: {"faithfulness": <float 0..1>, '
    '"unsupported_claims": [<short strings>], "reason": "<one sentence>"}. '
    "faithfulness is the fraction of claims supported by the context."
)

_RELEVANCE_SYS = (
    "You evaluate whether an ANSWER addresses the QUESTION asked (relevance, not "
    "correctness). A direct, on-topic answer scores 1.0; a partial or evasive one "
    "lower; an off-topic one near 0. A correct refusal for an unanswerable "
    "question is fully relevant (1.0). "
    'Reply with ONLY JSON: {"relevance": <float 0..1>, "reason": "<one sentence>"}.'
)

_PRECISION_SYS = (
    "You judge retrieval quality. Given a QUESTION and a numbered list of "
    "retrieved CHUNKS (by index), decide which chunks are relevant to answering "
    "the question. "
    'Reply with ONLY JSON: {"relevant_indices": [<int>], "reason": "<one sentence>"}.'
)

_REFUSAL_SYS = (
    "A QA system should DECLINE this question because the answer is NOT in its "
    "corpus (the company, year, or forward-looking figure is unavailable). Given "
    "the ANSWER, decide whether it appropriately declined / stated it cannot "
    "answer from the available filings, rather than fabricating a figure. "
    'Reply with ONLY JSON: {"declined": <true|false>, "reason": "<one sentence>"}.'
)


def judge_faithfulness(context: str, answer: str) -> dict:
    return _judge_json(_FAITH_SYS, f"CONTEXT:\n{context}\n\nANSWER:\n{answer}")


def judge_relevance(question: str, answer: str) -> dict:
    return _judge_json(_RELEVANCE_SYS, f"QUESTION:\n{question}\n\nANSWER:\n{answer}")


def judge_precision(question: str, chunks_listing: str) -> dict:
    return _judge_json(_PRECISION_SYS, f"QUESTION:\n{question}\n\nCHUNKS:\n{chunks_listing}")


def judge_refusal(question: str, answer: str) -> dict:
    return _judge_json(_REFUSAL_SYS, f"QUESTION:\n{question}\n\nANSWER:\n{answer}")