crossfoot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crossfoot/__init__.py +29 -0
- crossfoot/cli.py +116 -0
- crossfoot/expressions.py +270 -0
- crossfoot/extract.py +184 -0
- crossfoot/integrations/__init__.py +1 -0
- crossfoot/integrations/deepeval_metric.py +51 -0
- crossfoot/integrations/promptfoo_assert.py +45 -0
- crossfoot/verify.py +987 -0
- crossfoot-0.1.0.dist-info/METADATA +244 -0
- crossfoot-0.1.0.dist-info/RECORD +14 -0
- crossfoot-0.1.0.dist-info/WHEEL +5 -0
- crossfoot-0.1.0.dist-info/entry_points.txt +2 -0
- crossfoot-0.1.0.dist-info/licenses/LICENSE +21 -0
- crossfoot-0.1.0.dist-info/top_level.txt +1 -0
crossfoot/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""crossfoot: fail-closed numeric grounding checks for LLM output."""
|
|
2
|
+
from .extract import Mention, extract
|
|
3
|
+
from .expressions import find_equations
|
|
4
|
+
from .verify import (
|
|
5
|
+
ARITHMETIC_ERROR,
|
|
6
|
+
WRONG_ROW,
|
|
7
|
+
CONTRADICTED,
|
|
8
|
+
DERIVED,
|
|
9
|
+
EXACT,
|
|
10
|
+
PASSING,
|
|
11
|
+
ROUNDED,
|
|
12
|
+
UNCITED,
|
|
13
|
+
UNKNOWN_CITATION,
|
|
14
|
+
UNSUPPORTED,
|
|
15
|
+
WRONG_RECORD,
|
|
16
|
+
Checker,
|
|
17
|
+
Claim,
|
|
18
|
+
Report,
|
|
19
|
+
check,
|
|
20
|
+
load_sources,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"check", "Checker", "extract", "load_sources", "Claim", "Report", "Mention", "PASSING",
|
|
27
|
+
"EXACT", "ROUNDED", "DERIVED", "WRONG_RECORD", "CONTRADICTED", "UNSUPPORTED", "UNCITED",
|
|
28
|
+
"UNKNOWN_CITATION", "ARITHMETIC_ERROR", "WRONG_ROW", "find_equations",
|
|
29
|
+
]
|
crossfoot/cli.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
crossfoot check --output answer.txt --sources sources.json [--require-citations]
|
|
4
|
+
crossfoot eval cases.jsonl [--min-accuracy 1.0]
|
|
5
|
+
|
|
6
|
+
``eval`` runs a labelled set of cases. Each JSONL line is::
|
|
7
|
+
|
|
8
|
+
{"id": "...", "output": "...", "sources": [...] | "sources_file": "x.json",
|
|
9
|
+
"expect": "pass" | "fail", "options": {"require_citations": true}}
|
|
10
|
+
|
|
11
|
+
``expect`` defaults to "pass", so a set of real model outputs works as a CI gate.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
from .verify import check
|
|
22
|
+
|
|
23
|
+
_OPTION_KEYS = {"require_citations", "allow_rounding", "allow_derived", "max_derivation_operands",
|
|
24
|
+
"share_scale_hints", "check_table_rows"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _add_options(parser: argparse.ArgumentParser) -> None:
|
|
28
|
+
parser.add_argument("--require-citations", action="store_true",
|
|
29
|
+
help="fail numbers in sentences that cite no source")
|
|
30
|
+
parser.add_argument("--strict", action="store_true",
|
|
31
|
+
help="disallow rounding of directly quoted values")
|
|
32
|
+
parser.add_argument("--no-derived", action="store_true",
|
|
33
|
+
help="disallow derived figures (%% change, share, sums, differences)")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _options(args: argparse.Namespace) -> Dict[str, Any]:
|
|
37
|
+
return {
|
|
38
|
+
"require_citations": args.require_citations,
|
|
39
|
+
"allow_rounding": not args.strict,
|
|
40
|
+
"allow_derived": not args.no_derived,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_json(path: Path) -> Any:
|
|
45
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
49
|
+
output = sys.stdin.read() if args.output == "-" else Path(args.output).read_text(encoding="utf-8")
|
|
50
|
+
report = check(output, _read_json(Path(args.sources)), **_options(args))
|
|
51
|
+
print(json.dumps(report.to_dict(), indent=2) if args.json else report.summary())
|
|
52
|
+
return 0 if report.passed else 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _cmd_eval(args: argparse.Namespace) -> int:
|
|
56
|
+
cases_path = Path(args.cases)
|
|
57
|
+
lines = [l for l in cases_path.read_text(encoding="utf-8").splitlines() if l.strip()]
|
|
58
|
+
if not lines:
|
|
59
|
+
print(f"No cases found in {cases_path}")
|
|
60
|
+
return 1
|
|
61
|
+
|
|
62
|
+
agreed = 0
|
|
63
|
+
for number, line in enumerate(lines, start=1):
|
|
64
|
+
case = json.loads(line)
|
|
65
|
+
case_id = case.get("id", f"case-{number}")
|
|
66
|
+
if "sources_file" in case:
|
|
67
|
+
sources = _read_json(cases_path.parent / case["sources_file"])
|
|
68
|
+
else:
|
|
69
|
+
sources = case.get("sources", [])
|
|
70
|
+
options = {k: v for k, v in (case.get("options") or {}).items() if k in _OPTION_KEYS}
|
|
71
|
+
expect = case.get("expect", "pass")
|
|
72
|
+
if expect not in ("pass", "fail"):
|
|
73
|
+
raise ValueError(f"{case_id}: expect must be 'pass' or 'fail', got {expect!r}")
|
|
74
|
+
|
|
75
|
+
report = check(case["output"], sources, **options)
|
|
76
|
+
got = "pass" if report.passed else "fail"
|
|
77
|
+
ok = got == expect
|
|
78
|
+
agreed += ok
|
|
79
|
+
print(f"{'ok ' if ok else 'MISS'} {case_id:<28} expected={expect:<4} got={got}")
|
|
80
|
+
if not ok or args.verbose:
|
|
81
|
+
print(" " + report.summary().replace("\n", "\n "))
|
|
82
|
+
|
|
83
|
+
accuracy = agreed / len(lines)
|
|
84
|
+
print(f"\n{agreed}/{len(lines)} cases as expected (accuracy {accuracy:.2%}, "
|
|
85
|
+
f"threshold {args.min_accuracy:.2%})")
|
|
86
|
+
return 0 if accuracy >= args.min_accuracy else 1
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
90
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
91
|
+
sys.stdout.reconfigure(errors="replace") # currency symbols on legacy consoles
|
|
92
|
+
|
|
93
|
+
parser = argparse.ArgumentParser(prog="crossfoot",
|
|
94
|
+
description="Fail-closed numeric grounding checks for LLM output.")
|
|
95
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
96
|
+
|
|
97
|
+
p_check = sub.add_parser("check", help="check one output against its sources")
|
|
98
|
+
p_check.add_argument("--output", required=True, help="text file with the LLM output, or - for stdin")
|
|
99
|
+
p_check.add_argument("--sources", required=True, help="JSON file: list of strings or {id, text, fields}")
|
|
100
|
+
p_check.add_argument("--json", action="store_true", help="print a JSON report")
|
|
101
|
+
_add_options(p_check)
|
|
102
|
+
p_check.set_defaults(func=_cmd_check)
|
|
103
|
+
|
|
104
|
+
p_eval = sub.add_parser("eval", help="run a labelled JSONL case set (CI gate)")
|
|
105
|
+
p_eval.add_argument("cases", help="path to a .jsonl file")
|
|
106
|
+
p_eval.add_argument("--min-accuracy", type=float, default=1.0,
|
|
107
|
+
help="fail if fewer than this fraction of cases match expectations")
|
|
108
|
+
p_eval.add_argument("-v", "--verbose", action="store_true", help="print every report")
|
|
109
|
+
p_eval.set_defaults(func=_cmd_eval)
|
|
110
|
+
|
|
111
|
+
args = parser.parse_args(argv)
|
|
112
|
+
return args.func(args)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
sys.exit(main())
|
crossfoot/expressions.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Find and re-check arithmetic written out in text.
|
|
2
|
+
|
|
3
|
+
LLM answers often show their working::
|
|
4
|
+
|
|
5
|
+
(7167.1 - 6383.0) / 6383.0 * 100 = 12.28%
|
|
6
|
+
($81 + $120) / 2 = $201 / 2 = $100.5 million
|
|
7
|
+
$7630.0 million - $6710.4 million = $918.2 million <- wrong: 919.6
|
|
8
|
+
|
|
9
|
+
``find_equations`` locates ``left = right`` pairs, evaluates the left side with
|
|
10
|
+
a small recursive-descent parser (never ``eval``), and reports whether the
|
|
11
|
+
stated right side agrees at its stated precision.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import List, Optional, Sequence, Tuple
|
|
18
|
+
|
|
19
|
+
from .extract import mask_dates
|
|
20
|
+
|
|
21
|
+
_SCALES = {"thousand": 1e3, "k": 1e3, "million": 1e6, "mn": 1e6, "m": 1e6, "billion": 1e9, "bn": 1e9}
|
|
22
|
+
_UNIT_FACTORS = (1e3, 1e6, 1e9)
|
|
23
|
+
_REL_EPS = 1e-9
|
|
24
|
+
|
|
25
|
+
_TOKEN_RE = re.compile(
|
|
26
|
+
r"(?P<num>(?<![A-Za-z0-9_.])(?:HK\$|US\$|[$€£₹])?\s?"
|
|
27
|
+
r"(?P<digits>(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?)(?![\d]|\.\d)"
|
|
28
|
+
r"(?:\s?(?P<scale>thousand|million|billion|mn|bn|m|k)(?![A-Za-z]))?"
|
|
29
|
+
r"(?:\s?(?P<pct>%))?)"
|
|
30
|
+
r"|(?P<op>[+*/×÷]|[-−–]|\bx(?=\s)|\btimes\b)"
|
|
31
|
+
r"|(?P<lp>[(\[])"
|
|
32
|
+
r"|(?P<rp>[)\]])"
|
|
33
|
+
r"|(?P<eq>=|≈|~)"
|
|
34
|
+
r"|(?P<ws>\s+)",
|
|
35
|
+
re.IGNORECASE,
|
|
36
|
+
)
|
|
37
|
+
_ADD = {"+"}
|
|
38
|
+
_SUB = {"-", "−", "–"}
|
|
39
|
+
_MUL = {"*", "×", "x", "times"}
|
|
40
|
+
_DIV = {"/", "÷"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Token:
|
|
45
|
+
kind: str # num | op | lp | rp | eq | ws
|
|
46
|
+
text: str
|
|
47
|
+
start: int
|
|
48
|
+
end: int
|
|
49
|
+
value: float = 0.0
|
|
50
|
+
decimals: int = 0
|
|
51
|
+
scale: float = 1.0
|
|
52
|
+
has_scale: bool = False
|
|
53
|
+
percent: bool = False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Equation:
|
|
58
|
+
left_value: float
|
|
59
|
+
left_span: Tuple[int, int]
|
|
60
|
+
left_tokens: List[Token]
|
|
61
|
+
right_value: float
|
|
62
|
+
right_tokens: List[Token]
|
|
63
|
+
right_number: Optional[Token] # set when the right side is a single number
|
|
64
|
+
consistent: bool
|
|
65
|
+
operands: List[Token] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _make_token(m: "re.Match[str]") -> Token:
|
|
69
|
+
for kind in ("num", "op", "lp", "rp", "eq", "ws"):
|
|
70
|
+
if m.group(kind) is not None:
|
|
71
|
+
break
|
|
72
|
+
tok = Token(kind=kind, text=m.group(0), start=m.start(), end=m.end())
|
|
73
|
+
if kind == "num":
|
|
74
|
+
digits = m.group("digits").replace(",", "")
|
|
75
|
+
scale_word = (m.group("scale") or "").lower()
|
|
76
|
+
tok.decimals = len(digits.split(".", 1)[1]) if "." in digits else 0
|
|
77
|
+
tok.scale = _SCALES.get(scale_word, 1.0)
|
|
78
|
+
tok.has_scale = bool(scale_word)
|
|
79
|
+
tok.percent = m.group("pct") is not None
|
|
80
|
+
tok.value = float(digits) * tok.scale
|
|
81
|
+
# The span may include a leading space; extract() mentions start inside it.
|
|
82
|
+
tok.text = m.group(0).strip()
|
|
83
|
+
return tok
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def tokenize_runs(text: str) -> List[List[Token]]:
|
|
87
|
+
"""Maximal runs of arithmetic tokens; any other character ends a run."""
|
|
88
|
+
text = mask_dates(text)
|
|
89
|
+
runs: List[List[Token]] = []
|
|
90
|
+
current: List[Token] = []
|
|
91
|
+
pos = 0
|
|
92
|
+
while pos < len(text):
|
|
93
|
+
m = _TOKEN_RE.match(text, pos)
|
|
94
|
+
if not m or m.end() == pos:
|
|
95
|
+
if current:
|
|
96
|
+
runs.append(current)
|
|
97
|
+
current = []
|
|
98
|
+
pos += 1
|
|
99
|
+
continue
|
|
100
|
+
tok = _make_token(m)
|
|
101
|
+
if tok.kind != "ws":
|
|
102
|
+
current.append(tok)
|
|
103
|
+
pos = m.end()
|
|
104
|
+
if current:
|
|
105
|
+
runs.append(current)
|
|
106
|
+
return runs
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _Parser:
|
|
110
|
+
def __init__(self, tokens: Sequence[Token]) -> None:
|
|
111
|
+
self.tokens = tokens
|
|
112
|
+
self.i = 0
|
|
113
|
+
|
|
114
|
+
def _peek(self) -> Optional[Token]:
|
|
115
|
+
return self.tokens[self.i] if self.i < len(self.tokens) else None
|
|
116
|
+
|
|
117
|
+
def parse(self) -> Optional[float]:
|
|
118
|
+
value = self._expr()
|
|
119
|
+
if value is None or self.i != len(self.tokens):
|
|
120
|
+
return None
|
|
121
|
+
return value
|
|
122
|
+
|
|
123
|
+
def _expr(self) -> Optional[float]:
|
|
124
|
+
value = self._term()
|
|
125
|
+
while value is not None:
|
|
126
|
+
tok = self._peek()
|
|
127
|
+
if tok is None or tok.kind != "op" or tok.text not in _ADD | _SUB:
|
|
128
|
+
break
|
|
129
|
+
self.i += 1
|
|
130
|
+
right = self._term()
|
|
131
|
+
if right is None:
|
|
132
|
+
return None
|
|
133
|
+
value = value + right if tok.text in _ADD else value - right
|
|
134
|
+
return value
|
|
135
|
+
|
|
136
|
+
def _term(self) -> Optional[float]:
|
|
137
|
+
value = self._factor()
|
|
138
|
+
while value is not None:
|
|
139
|
+
tok = self._peek()
|
|
140
|
+
if tok is None or tok.kind != "op" or tok.text.lower() not in _MUL | _DIV:
|
|
141
|
+
break
|
|
142
|
+
self.i += 1
|
|
143
|
+
right = self._factor()
|
|
144
|
+
if right is None:
|
|
145
|
+
return None
|
|
146
|
+
if tok.text in _DIV:
|
|
147
|
+
if right == 0:
|
|
148
|
+
return None
|
|
149
|
+
value = value / right
|
|
150
|
+
else:
|
|
151
|
+
value = value * right
|
|
152
|
+
return value
|
|
153
|
+
|
|
154
|
+
def _factor(self) -> Optional[float]:
|
|
155
|
+
tok = self._peek()
|
|
156
|
+
if tok is None:
|
|
157
|
+
return None
|
|
158
|
+
if tok.kind == "op" and tok.text in _SUB:
|
|
159
|
+
self.i += 1
|
|
160
|
+
inner = self._factor()
|
|
161
|
+
return None if inner is None else -inner
|
|
162
|
+
if tok.kind == "num":
|
|
163
|
+
self.i += 1
|
|
164
|
+
return tok.value
|
|
165
|
+
if tok.kind == "lp":
|
|
166
|
+
self.i += 1
|
|
167
|
+
inner = self._expr()
|
|
168
|
+
close = self._peek()
|
|
169
|
+
if inner is None or close is None or close.kind != "rp":
|
|
170
|
+
return None
|
|
171
|
+
self.i += 1
|
|
172
|
+
return inner
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _trim(tokens: List[Token]) -> List[Token]:
|
|
177
|
+
"""Drop unmatched brackets at either end: '(a - b' / 'a - b)'."""
|
|
178
|
+
tokens = list(tokens)
|
|
179
|
+
while tokens and tokens[-1].kind == "rp" and \
|
|
180
|
+
sum(t.kind == "rp" for t in tokens) > sum(t.kind == "lp" for t in tokens):
|
|
181
|
+
tokens.pop()
|
|
182
|
+
while tokens and tokens[0].kind == "lp" and \
|
|
183
|
+
sum(t.kind == "lp" for t in tokens) > sum(t.kind == "rp" for t in tokens):
|
|
184
|
+
tokens.pop(0)
|
|
185
|
+
return tokens
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _is_expression(tokens: Sequence[Token]) -> bool:
|
|
189
|
+
nums = sum(t.kind == "num" for t in tokens)
|
|
190
|
+
ops = sum(t.kind == "op" and not (t.text in _SUB and i == 0) for i, t in enumerate(tokens))
|
|
191
|
+
return nums >= 2 and ops >= 1
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _evaluate(tokens: List[Token]) -> Optional[float]:
|
|
195
|
+
tokens = _trim(tokens)
|
|
196
|
+
if not _is_expression(tokens):
|
|
197
|
+
return None
|
|
198
|
+
return _Parser(tokens).parse()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _left_side(tokens: List[Token]) -> Optional[Tuple[List[Token], float]]:
|
|
202
|
+
"""Longest suffix of the part before '=' that is a complete expression."""
|
|
203
|
+
for k in range(len(tokens)):
|
|
204
|
+
sub = _trim(tokens[k:])
|
|
205
|
+
value = _evaluate(sub)
|
|
206
|
+
if value is not None:
|
|
207
|
+
return sub, value
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _right_side(tokens: List[Token]) -> Optional[Tuple[List[Token], float, Optional[Token]]]:
|
|
212
|
+
"""Longest prefix that is an expression, else the leading (signed) number."""
|
|
213
|
+
for k in range(len(tokens), 1, -1):
|
|
214
|
+
sub = _trim(tokens[:k])
|
|
215
|
+
value = _evaluate(sub)
|
|
216
|
+
if value is not None:
|
|
217
|
+
return sub, value, None
|
|
218
|
+
rest = [t for t in tokens if t.kind != "lp"]
|
|
219
|
+
if rest and rest[0].kind == "num":
|
|
220
|
+
return [rest[0]], rest[0].value, rest[0]
|
|
221
|
+
if len(rest) >= 2 and rest[0].kind == "op" and rest[0].text in _SUB and rest[1].kind == "num":
|
|
222
|
+
return rest[:2], -rest[1].value, rest[1]
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _agrees(left: float, left_tokens: Sequence[Token], right: float, right_number: Optional[Token]) -> bool:
|
|
227
|
+
candidates = [left]
|
|
228
|
+
left_scaled = any(t.kind == "num" and t.has_scale for t in left_tokens)
|
|
229
|
+
if right_number is not None:
|
|
230
|
+
tol = 0.5 * (10.0 ** -right_number.decimals) * right_number.scale
|
|
231
|
+
if right_number.percent:
|
|
232
|
+
candidates.append(left * 100) # "(a / b) = 12.3%"
|
|
233
|
+
if right_number.has_scale and not left_scaled:
|
|
234
|
+
candidates.append(left * right_number.scale) # "$201 / 2 = $100.5 million"
|
|
235
|
+
if left_scaled and not right_number.has_scale:
|
|
236
|
+
candidates.extend(left / f for f in _UNIT_FACTORS)
|
|
237
|
+
else:
|
|
238
|
+
tol = 0.005 * max(abs(left), abs(right))
|
|
239
|
+
candidates.extend([left * f for f in _UNIT_FACTORS] + [left / f for f in _UNIT_FACTORS])
|
|
240
|
+
return any(abs(c - right) <= tol + _REL_EPS * max(1.0, abs(right)) for c in candidates)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def find_equations(text: str) -> List[Equation]:
|
|
244
|
+
"""All ``left = right`` pairs whose left side is an arithmetic expression."""
|
|
245
|
+
equations: List[Equation] = []
|
|
246
|
+
for run in tokenize_runs(text):
|
|
247
|
+
parts: List[List[Token]] = [[]]
|
|
248
|
+
for tok in run:
|
|
249
|
+
if tok.kind == "eq":
|
|
250
|
+
parts.append([])
|
|
251
|
+
else:
|
|
252
|
+
parts[-1].append(tok)
|
|
253
|
+
for i in range(len(parts) - 1):
|
|
254
|
+
left = _left_side(parts[i])
|
|
255
|
+
right = _right_side(parts[i + 1])
|
|
256
|
+
if left is None or right is None:
|
|
257
|
+
continue
|
|
258
|
+
left_tokens, left_value = left
|
|
259
|
+
right_tokens, right_value, right_number = right
|
|
260
|
+
equations.append(Equation(
|
|
261
|
+
left_value=left_value,
|
|
262
|
+
left_span=(left_tokens[0].start, left_tokens[-1].end),
|
|
263
|
+
left_tokens=left_tokens,
|
|
264
|
+
right_value=right_value,
|
|
265
|
+
right_tokens=right_tokens,
|
|
266
|
+
right_number=right_number,
|
|
267
|
+
consistent=_agrees(left_value, left_tokens, right_value, right_number),
|
|
268
|
+
operands=[t for t in left_tokens if t.kind == "num"],
|
|
269
|
+
))
|
|
270
|
+
return equations
|
crossfoot/extract.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Extract numeric mentions (amounts, percentages, years) from text.
|
|
2
|
+
|
|
3
|
+
Every mention records the precision it was *stated* with, so later checks can
|
|
4
|
+
tell legitimate rounding ("$3.2bn" for 3,214,000,000) from false precision
|
|
5
|
+
("$3,214 million" when the source only says "$3.2bn").
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
_SCALES = {
|
|
14
|
+
"thousand": 1e3, "k": 1e3,
|
|
15
|
+
"lakh": 1e5,
|
|
16
|
+
"million": 1e6, "mn": 1e6, "m": 1e6,
|
|
17
|
+
"crore": 1e7, "cr": 1e7,
|
|
18
|
+
"billion": 1e9, "bn": 1e9, "b": 1e9,
|
|
19
|
+
"trillion": 1e12, "tn": 1e12,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_CURRENCIES = {
|
|
23
|
+
"$": "$", "us$": "USD", "usd": "USD",
|
|
24
|
+
"hk$": "HKD", "hkd": "HKD",
|
|
25
|
+
"€": "EUR", "eur": "EUR",
|
|
26
|
+
"£": "GBP", "gbp": "GBP",
|
|
27
|
+
"₹": "INR", "inr": "INR",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_NUMBER_RE = re.compile(
|
|
31
|
+
# "-$3,079" (sign attached). "$5,829 - $5,735" is subtraction, not a negative.
|
|
32
|
+
r"(?P<neg0>(?<![A-Za-z0-9])[-−](?=(?:HK\$|US\$|USD|HKD|EUR|GBP|INR|[$€£₹])))?"
|
|
33
|
+
r"(?:(?P<cur>HK\$|US\$|USD|HKD|EUR|GBP|INR|[$€£₹])\s?)?"
|
|
34
|
+
r"(?P<neg>(?<![A-Za-z0-9])[-−])?"
|
|
35
|
+
# Do not start inside identifiers such as FY2024, Q3, R1 or inside 3.5 / 3,200.
|
|
36
|
+
r"(?<![A-Za-z0-9_.,])"
|
|
37
|
+
r"(?P<num>\d{1,3}(?:,\d{2})+,\d{3}(?:\.\d+)?" # Indian grouping: 1,23,456
|
|
38
|
+
r"|\d{1,3}(?:,\d{3})+(?:\.\d+)?" # Western grouping: 1,234,567
|
|
39
|
+
r"|\d+(?:\.\d+)?)"
|
|
40
|
+
r"(?!\d|\.\d)" # whole numbers only; not part of a version or IP: 8.4.1.1, 10.0.0.1
|
|
41
|
+
r"(?:\s?(?P<scale>trillion|billion|million|thousand|lakh|crore|bn|tn|mn|cr|k|m|b)(?![A-Za-z]))?"
|
|
42
|
+
r"(?:\s?(?P<pct>%|percentage points?|per\s?cent|percent|pp|bps|basis points?)(?![A-Za-z]))?",
|
|
43
|
+
re.IGNORECASE,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
_LIST_MARKER_RE = re.compile(r"[.)](?:\s|$)")
|
|
47
|
+
# "... settings. 2." / "steps: 1." - a step number after the end of a sentence.
|
|
48
|
+
_AFTER_SENTENCE_END_RE = re.compile(r"(?:^|[.!?:;])\s*$")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Mention:
|
|
53
|
+
"""A number found in text, normalised to a single float value."""
|
|
54
|
+
|
|
55
|
+
raw: str
|
|
56
|
+
value: float
|
|
57
|
+
kind: str # "money" | "number" | "percent" | "pp" | "bps" | "year"
|
|
58
|
+
currency: Optional[str]
|
|
59
|
+
tolerance: float # half a unit of the last stated digit, in value units
|
|
60
|
+
start: int
|
|
61
|
+
end: int
|
|
62
|
+
scaled: bool = False # True if a scale word (k, m, bn, million...) was stated
|
|
63
|
+
scale: float = 1.0 # the stated scale factor (1e6 for "million")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse(match: "re.Match[str]") -> Mention:
|
|
67
|
+
num_text = match.group("num")
|
|
68
|
+
digits = num_text.replace(",", "")
|
|
69
|
+
decimals = len(digits.split(".", 1)[1]) if "." in digits else 0
|
|
70
|
+
base = float(digits)
|
|
71
|
+
|
|
72
|
+
scale_text = (match.group("scale") or "").lower()
|
|
73
|
+
scale = _SCALES.get(scale_text, 1.0)
|
|
74
|
+
|
|
75
|
+
cur_text = match.group("cur")
|
|
76
|
+
currency = _CURRENCIES.get(cur_text.lower()) if cur_text else None
|
|
77
|
+
|
|
78
|
+
pct_text = (match.group("pct") or "").lower()
|
|
79
|
+
if pct_text:
|
|
80
|
+
if pct_text.startswith(("bps", "basis")):
|
|
81
|
+
kind = "bps"
|
|
82
|
+
elif pct_text.startswith(("pp", "percentage")):
|
|
83
|
+
kind = "pp"
|
|
84
|
+
else:
|
|
85
|
+
kind = "percent"
|
|
86
|
+
scale = 1.0 # "12 m%" is not a thing; ignore any scale word
|
|
87
|
+
elif currency:
|
|
88
|
+
kind = "money"
|
|
89
|
+
elif not scale_text and decimals == 0 and "," not in num_text and 1900 <= base <= 2100:
|
|
90
|
+
kind = "year"
|
|
91
|
+
else:
|
|
92
|
+
kind = "number"
|
|
93
|
+
|
|
94
|
+
sign = -1.0 if (match.group("neg") or match.group("neg0")) else 1.0
|
|
95
|
+
return Mention(
|
|
96
|
+
raw=match.group(0).strip(),
|
|
97
|
+
value=sign * base * scale,
|
|
98
|
+
kind=kind,
|
|
99
|
+
currency=currency,
|
|
100
|
+
tolerance=0.5 * (10.0 ** -decimals) * scale,
|
|
101
|
+
start=match.start(),
|
|
102
|
+
end=match.end(),
|
|
103
|
+
scaled=bool(scale_text) and not pct_text,
|
|
104
|
+
scale=scale,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# 11/30/04, 2019-06-30, 30.06.2019: dates, not quantities.
|
|
109
|
+
_DATE_RE = re.compile(
|
|
110
|
+
r"(?<![\d/.-])(?:\d{1,2}/\d{1,2}/\d{2,4}|\d{4}-\d{2}-\d{2}|\d{1,2}\.\d{1,2}\.\d{4})(?![\d/.-]\d)"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def mask_dates(text: str) -> str:
|
|
115
|
+
"""Replace numeric dates with spaces, keeping offsets unchanged."""
|
|
116
|
+
return _DATE_RE.sub(lambda m: " " * len(m.group(0)), text)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_MONTH = (r"(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|"
|
|
120
|
+
r"sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)")
|
|
121
|
+
_MONTH_BEFORE_RE = re.compile(r"\b" + _MONTH + r"\.?\s*$", re.IGNORECASE)
|
|
122
|
+
_MONTH_AFTER_RE = re.compile(r"\s*(?:st|nd|rd|th)?\s+(?:of\s+)?" + _MONTH + r"\b", re.IGNORECASE)
|
|
123
|
+
_OBJECT = r"(?:(?:it|this|that|the\s+\w+)\s+)?"
|
|
124
|
+
_MULTIPLY_BEFORE_RE = re.compile(
|
|
125
|
+
r"(?:[*×]|\bx|\btimes|\bmultipl(?:y|ied|ying)\s+" + _OBJECT + r"by)\s*$", re.IGNORECASE)
|
|
126
|
+
_DIVIDE_BEFORE_RE = re.compile(
|
|
127
|
+
r"(?:/|÷|\bdivid(?:e|ed|ing)\s+" + _OBJECT + r"by)\s*$", re.IGNORECASE)
|
|
128
|
+
_OPERATOR_AFTER_RE = re.compile(r"\s*[*×]")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_date_day(text: str, match: "re.Match[str]", mention: Mention) -> bool:
|
|
132
|
+
if mention.kind != "number" or mention.currency or mention.scaled or not (1 <= mention.value <= 31):
|
|
133
|
+
return False
|
|
134
|
+
if mention.value != int(mention.value):
|
|
135
|
+
return False
|
|
136
|
+
before = text[max(0, match.start() - 12):match.start()]
|
|
137
|
+
return bool(_MONTH_BEFORE_RE.search(before) or _MONTH_AFTER_RE.match(text, match.end()))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _is_formula_constant(text: str, match: "re.Match[str]", mention: Mention) -> bool:
|
|
141
|
+
"""Arithmetic notation, not a claim: the 100 in "(a / b) * 100" or
|
|
142
|
+
"multiplying by 100", and the 2 in "/ 2" or "divide by 2"."""
|
|
143
|
+
if mention.kind != "number" or mention.currency or mention.scaled:
|
|
144
|
+
return False
|
|
145
|
+
value = mention.value
|
|
146
|
+
before = text[max(0, match.start() - 40):match.start()]
|
|
147
|
+
if value in (100.0, 1000.0):
|
|
148
|
+
return bool(_MULTIPLY_BEFORE_RE.search(before) or _OPERATOR_AFTER_RE.match(text, match.end()))
|
|
149
|
+
if value == int(value) and 2 <= value <= 12:
|
|
150
|
+
return bool(_DIVIDE_BEFORE_RE.search(before))
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def extract(text: str) -> List[Mention]:
|
|
155
|
+
"""Return all numeric mentions in ``text`` in order of appearance.
|
|
156
|
+
|
|
157
|
+
Skipped as non-claims: numbered-list markers ("1. ", "2) ") at the start
|
|
158
|
+
of a line, numbers inside identifiers (FY2024, Q3, R1), the day in dates
|
|
159
|
+
("December 31, 2007", "31st March"), numeric dates ("11/30/04"),
|
|
160
|
+
version strings ("8.4.1.1") and formula constants ("* 100", "/ 2").
|
|
161
|
+
"""
|
|
162
|
+
mentions: List[Mention] = []
|
|
163
|
+
text = mask_dates(text)
|
|
164
|
+
for match in _NUMBER_RE.finditer(text):
|
|
165
|
+
if not match.group("num"):
|
|
166
|
+
continue
|
|
167
|
+
line_start = text.rfind("\n", 0, match.start()) + 1
|
|
168
|
+
plain_marker = (
|
|
169
|
+
not (match.group("cur") or match.group("scale") or match.group("pct"))
|
|
170
|
+
and "." not in match.group("num") and "," not in match.group("num")
|
|
171
|
+
and _LIST_MARKER_RE.match(text, match.end()) is not None
|
|
172
|
+
)
|
|
173
|
+
is_list_marker = plain_marker and (
|
|
174
|
+
text[line_start:match.start()].strip() == ""
|
|
175
|
+
or (int(match.group("num")) <= 99
|
|
176
|
+
and _AFTER_SENTENCE_END_RE.search(text, max(0, match.start() - 3), match.start()) is not None)
|
|
177
|
+
)
|
|
178
|
+
if is_list_marker:
|
|
179
|
+
continue
|
|
180
|
+
mention = _parse(match)
|
|
181
|
+
if _is_date_day(text, match, mention) or _is_formula_constant(text, match, mention):
|
|
182
|
+
continue
|
|
183
|
+
mentions.append(mention)
|
|
184
|
+
return mentions
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional integrations with eval frameworks (DeepEval, promptfoo)."""
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""DeepEval metric wrapper.
|
|
2
|
+
|
|
3
|
+
from deepeval import assert_test
|
|
4
|
+
from deepeval.test_case import LLMTestCase
|
|
5
|
+
from crossfoot.integrations.deepeval_metric import NumericGroundingMetric
|
|
6
|
+
|
|
7
|
+
case = LLMTestCase(input="...", actual_output=answer, retrieval_context=chunks)
|
|
8
|
+
assert_test(case, [NumericGroundingMetric(require_citations=True)])
|
|
9
|
+
|
|
10
|
+
Sources are taken from ``retrieval_context`` (falling back to ``context``);
|
|
11
|
+
chunk N can be cited as ``[N]`` or ``[SN]``.
|
|
12
|
+
|
|
13
|
+
Requires ``pip install crossfoot[deepeval]``. Written against DeepEval's
|
|
14
|
+
``BaseMetric`` interface; check it against the DeepEval version you use.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from deepeval.metrics import BaseMetric
|
|
21
|
+
from deepeval.test_case import LLMTestCase
|
|
22
|
+
|
|
23
|
+
from ..verify import check
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NumericGroundingMetric(BaseMetric):
|
|
27
|
+
def __init__(self, threshold: float = 1.0, **check_options: Any) -> None:
|
|
28
|
+
self.threshold = threshold
|
|
29
|
+
self.check_options = check_options
|
|
30
|
+
self.score = 0.0
|
|
31
|
+
self.reason = ""
|
|
32
|
+
self.success = False
|
|
33
|
+
self.report = None
|
|
34
|
+
|
|
35
|
+
def measure(self, test_case: LLMTestCase, *args: Any, **kwargs: Any) -> float:
|
|
36
|
+
sources = list(test_case.retrieval_context or test_case.context or [])
|
|
37
|
+
self.report = check(test_case.actual_output or "", sources, **self.check_options)
|
|
38
|
+
self.score = self.report.score
|
|
39
|
+
self.success = self.report.passed and self.score >= self.threshold
|
|
40
|
+
self.reason = self.report.summary()
|
|
41
|
+
return self.score
|
|
42
|
+
|
|
43
|
+
async def a_measure(self, test_case: LLMTestCase, *args: Any, **kwargs: Any) -> float:
|
|
44
|
+
return self.measure(test_case)
|
|
45
|
+
|
|
46
|
+
def is_successful(self) -> bool:
|
|
47
|
+
return self.success
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def __name__(self) -> str: # DeepEval reads the metric name from here
|
|
51
|
+
return "Numeric Grounding (crossfoot)"
|