File size: 8,266 Bytes
d4f8959
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# backend/verifier.py
"""
Post-generation numeric verification.

The LLM prompt says "use only the context" — but a prompt is a request,
not a guarantee. This module CHECKS: every number that appears in a
generated answer must also exist in the source material (retrieved
chunks + extracted metrics). Numbers that can't be traced are flagged as
unverified — visibly, in the response — instead of being shipped as if
they were facts.

What counts as "the same number": financial texts express one value many
ways (₹9,64,693 crore vs 9646.93 billion vs "9.6 lakh crore"), so each
candidate is normalized to an absolute value across every plausible unit
interpretation, and matching allows small rounding tolerance (an answer
saying "$394.3B" against a source "394,328 million" should verify).

What is deliberately NOT verified: years (2020-2035), small enumerators
(section "1.", "top 3", risk score "7/10"), and page references — flagging
those would bury real signal in noise.
"""

import re

# rounding slack between an answer's number and a source number —
# "$394.3B" vs 394,328M is a 0.007% gap; 1% keeps us safe on display
# rounding without accepting genuinely different figures
RELATIVE_TOLERANCE = 0.01

UNIT_MULTIPLIERS = {
    "trillion": 1_000_000_000_000,
    "lakh crore": 1_000_000_000_000,
    "lakh crores": 1_000_000_000_000,
    "lakh cr": 1_000_000_000_000,
    "billion": 1_000_000_000,
    "million": 1_000_000,
    "thousand": 1_000,
    "crore": 10_000_000,
    "crores": 10_000_000,
    "cr": 10_000_000,
    "lakh": 100_000,
    "lakhs": 100_000,
    "lac": 100_000,
    "b": 1_000_000_000,
    "m": 1_000_000,
    "k": 1_000,
}

# number, optionally preceded by a currency marker and followed by a unit
# word/suffix or % — the shapes financial narration actually produces
# (includes format_money's own output: "₹60,812.0 Cr", "₹1.23 Lakh Cr")
_NUMBER_RE = re.compile(
    r"(?P<currency>[₹$€£]|Rs\.?\s?|INR\s?|USD\s?)?"
    r"(?P<num>\d[\d,]*(?:\.\d+)?)"
    r"\s*(?P<unit>lakh\s+crores?|lakh\s+cr\b|trillion|billion|million|thousand"
    r"|crores?\b|cr\b|lakhs?\b|lac\b|%|[BMKbmk]\b)?",
    re.UNICODE | re.IGNORECASE,
)


def _parse_number(num_str: str) -> float | None:
    try:
        return float(num_str.replace(",", ""))
    except ValueError:
        return None


def _interpretations(value: float, unit: str | None) -> list:
    """All absolute values this (number, unit) pair could mean. With an
    explicit unit there's one reading; without, the bare value is used
    as-is (source-side bare numbers get the same treatment, so matching
    still works when both sides omit units)."""
    if unit and unit != "%":
        key = re.sub(r"\s+", " ", unit.lower().strip())
        mult = UNIT_MULTIPLIERS.get(key)
        if mult:
            return [value * mult]
    return [value]


def _is_ignorable(value: float, unit: str | None, currency: str | None) -> bool:
    """Years, tiny enumerators, and unit-less small numbers are noise,
    not claims worth verifying."""
    if unit == "%":
        return False  # percentages are real claims
    if unit or currency:
        return False  # explicit money/unit context — always verify
    if 1900 <= value <= 2035 and value == int(value):
        return True  # a year
    if abs(value) < 100:
        return True  # list indices, "3 companies", risk score 7...
    return False


def extract_numbers(text: str) -> list:
    """All verifiable numeric claims in a text. Returns
    [{"raw": str, "value": float, "unit": str|None, "currency": str|None,
      "absolutes": [float, ...]}]"""
    out = []
    for m in _NUMBER_RE.finditer(text):
        value = _parse_number(m.group("num"))
        if value is None:
            continue
        unit = m.group("unit")
        currency = m.group("currency")
        if _is_ignorable(value, unit, currency):
            continue
        out.append({
            "raw": m.group(0).strip(),
            "value": value,
            "unit": (unit or "").strip() or None,
            "currency": (currency or "").strip() or None,
            "absolutes": _interpretations(value, unit),
        })
    return out


def _source_values(source_text: str, metrics: dict | None) -> list:
    """Every absolute value present in the source material. Source numbers
    get ALL unit interpretations (a source table saying '9,64,693' next to
    a 'crore' header may surface as a bare number in chunk text) plus the
    bare reading."""
    values = []

    for m in _NUMBER_RE.finditer(source_text or ""):
        v = _parse_number(m.group("num"))
        if v is None:
            continue
        unit = m.group("unit")
        values.extend(_interpretations(v, unit))
        values.append(v)  # bare reading always allowed on the source side
        # bare source numbers might be in implicit crore/million (Indian
        # statement tables state the unit once in the header, far from the
        # number) — allow those readings too so real matches aren't missed
        if not unit:
            values.append(v * 10_000_000)   # crore
            values.append(v * 100_000)      # lakh
            values.append(v * 1_000_000)    # million
    if metrics:
        for metric in metrics.values():
            if isinstance(metric, dict):
                if metric.get("value") is not None:
                    values.append(float(metric["value"]))
                for alt in metric.get("alternatives", []):
                    if isinstance(alt, dict) and alt.get("value") is not None:
                        values.append(float(alt["value"]))
            elif isinstance(metric, (int, float)):
                values.append(float(metric))

    return values


def _matches(candidates: list, source_values: list) -> bool:
    for c in candidates:
        for s in source_values:
            if s == 0:
                if c == 0:
                    return True
                continue
            if abs(c - s) / abs(s) <= RELATIVE_TOLERANCE:
                return True
    return False


def verify_answer(answer: str, source_text: str, metrics: dict | None = None) -> dict:
    """Check every numeric claim in `answer` against the source material.

    Returns:
        {"all_verified": bool,
         "checked": int,
         "unverified": [{"raw", "value", "unit"}, ...],
         "note": str}
    """
    claims = extract_numbers(answer)
    if not claims:
        return {
            "all_verified": True,
            "checked": 0,
            "unverified": [],
            "note": "No numeric claims to verify."
        }

    sources = _source_values(source_text, metrics)

    unverified = []
    for claim in claims:
        if not _matches(claim["absolutes"], sources):
            unverified.append({
                "raw": claim["raw"],
                "value": claim["value"],
                "unit": claim["unit"],
            })

    all_verified = not unverified
    return {
        "all_verified": all_verified,
        "checked": len(claims),
        "unverified": unverified,
        "note": (
            "Every number in this answer was found in the source filing."
            if all_verified else
            f"{len(unverified)} of {len(claims)} numbers could NOT be traced "
            f"to the source filing — treat them as unreliable."
        )
    }


if __name__ == "__main__":
    source = (
        "The Consolidated total revenue from operations for the year was "
        "₹ 9,64,693 crore, compared to ₹ 9,01,064 crore in the previous year. "
        "Profit after tax was ₹ 60,812 crore. Attrition was 12.5%."
    )
    metrics = {
        "revenue": {"value": 9_64_693 * 10_000_000, "confidence": "high"},
        "profit_after_tax": {"value": 60_812 * 10_000_000, "confidence": "high"},
    }

    good = "Revenue was ₹9,64,693 crore and PAT was ₹60,812 crore. Attrition stood at 12.5%."
    bad = "Revenue was ₹9,64,693 crore. The company also spent ₹5,000 crore on R&D and grew 45%."

    def safe(obj):  # Windows cp1252 consoles choke on the ₹ glyph
        return str(obj).encode("ascii", "replace").decode("ascii")

    print("GOOD:", safe(verify_answer(good, source, metrics)))
    print("BAD:", safe(verify_answer(bad, source, metrics)))