extractscore 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.
- extractscore/__init__.py +37 -0
- extractscore/__main__.py +5 -0
- extractscore/align.py +111 -0
- extractscore/cli.py +255 -0
- extractscore/compare.py +167 -0
- extractscore/config.py +176 -0
- extractscore/diff.py +145 -0
- extractscore/errors.py +32 -0
- extractscore/gate.py +43 -0
- extractscore/i18n.py +290 -0
- extractscore/io.py +79 -0
- extractscore/metrics.py +119 -0
- extractscore/py.typed +0 -0
- extractscore/render.py +144 -0
- extractscore/sample/README.md +29 -0
- extractscore/sample/docs/inv-001.txt +10 -0
- extractscore/sample/docs/inv-002.txt +9 -0
- extractscore/sample/docs/inv-003.txt +9 -0
- extractscore/sample/docs/rcp-001.txt +8 -0
- extractscore/sample/docs/rcp-002.txt +9 -0
- extractscore/sample/docs/rcp-003.txt +8 -0
- extractscore/sample/fields.toml +27 -0
- extractscore/sample/gold.jsonl +6 -0
- extractscore/sample/pred_a.jsonl +6 -0
- extractscore/sample/pred_b.jsonl +6 -0
- extractscore/score.py +334 -0
- extractscore-0.1.0.dist-info/METADATA +261 -0
- extractscore-0.1.0.dist-info/RECORD +31 -0
- extractscore-0.1.0.dist-info/WHEEL +4 -0
- extractscore-0.1.0.dist-info/entry_points.txt +2 -0
- extractscore-0.1.0.dist-info/licenses/LICENSE +21 -0
extractscore/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""extractscore - field-level precision/recall/F1 for LLM-extracted JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
from .config import Config, FieldRule
|
|
8
|
+
from .diff import Diff, FieldDelta, Flip, diff
|
|
9
|
+
from .errors import ConfigError, ExtractScoreError, InputError
|
|
10
|
+
from .gate import GateFailure, check_gates
|
|
11
|
+
from .io import ParseFailure, load_jsonl
|
|
12
|
+
from .metrics import Counts, FieldStats
|
|
13
|
+
from .score import DocResult, Report, compare, evaluate, evaluate_files
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Config",
|
|
17
|
+
"ConfigError",
|
|
18
|
+
"Counts",
|
|
19
|
+
"Diff",
|
|
20
|
+
"DocResult",
|
|
21
|
+
"ExtractScoreError",
|
|
22
|
+
"FieldDelta",
|
|
23
|
+
"FieldRule",
|
|
24
|
+
"FieldStats",
|
|
25
|
+
"Flip",
|
|
26
|
+
"GateFailure",
|
|
27
|
+
"InputError",
|
|
28
|
+
"ParseFailure",
|
|
29
|
+
"Report",
|
|
30
|
+
"__version__",
|
|
31
|
+
"check_gates",
|
|
32
|
+
"compare",
|
|
33
|
+
"diff",
|
|
34
|
+
"evaluate",
|
|
35
|
+
"evaluate_files",
|
|
36
|
+
"load_jsonl",
|
|
37
|
+
]
|
extractscore/__main__.py
ADDED
extractscore/align.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Pairing items of a list: pure-Python Hungarian algorithm, no dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Callable, Sequence
|
|
7
|
+
|
|
8
|
+
INF = float("inf")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def hungarian(cost: Sequence[Sequence[float]]) -> list[int]:
|
|
12
|
+
"""Minimum-cost assignment on a square matrix; returns ``col`` for every ``row``.
|
|
13
|
+
|
|
14
|
+
O(n^3) shortest-augmenting-path version. Fine for the few hundred items a
|
|
15
|
+
document list usually has (200 items take a few tens of milliseconds).
|
|
16
|
+
"""
|
|
17
|
+
n = len(cost)
|
|
18
|
+
u = [0.0] * (n + 1)
|
|
19
|
+
v = [0.0] * (n + 1)
|
|
20
|
+
owner = [0] * (n + 1)
|
|
21
|
+
way = [0] * (n + 1)
|
|
22
|
+
for i in range(1, n + 1):
|
|
23
|
+
owner[0] = i
|
|
24
|
+
j0 = 0
|
|
25
|
+
minv = [INF] * (n + 1)
|
|
26
|
+
used = [False] * (n + 1)
|
|
27
|
+
while True:
|
|
28
|
+
used[j0] = True
|
|
29
|
+
i0, delta, j1 = owner[j0], INF, 0
|
|
30
|
+
row = cost[i0 - 1]
|
|
31
|
+
for j in range(1, n + 1):
|
|
32
|
+
if not used[j]:
|
|
33
|
+
current = row[j - 1] - u[i0] - v[j]
|
|
34
|
+
if current < minv[j]:
|
|
35
|
+
minv[j], way[j] = current, j0
|
|
36
|
+
if minv[j] < delta:
|
|
37
|
+
delta, j1 = minv[j], j
|
|
38
|
+
for j in range(n + 1):
|
|
39
|
+
if used[j]:
|
|
40
|
+
u[owner[j]] += delta
|
|
41
|
+
v[j] -= delta
|
|
42
|
+
else:
|
|
43
|
+
minv[j] -= delta
|
|
44
|
+
j0 = j1
|
|
45
|
+
if owner[j0] == 0:
|
|
46
|
+
break
|
|
47
|
+
while j0:
|
|
48
|
+
j1 = way[j0]
|
|
49
|
+
owner[j0] = owner[j1]
|
|
50
|
+
j0 = j1
|
|
51
|
+
result = [0] * n
|
|
52
|
+
for j in range(1, n + 1):
|
|
53
|
+
result[owner[j] - 1] = j - 1
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def match_items(
|
|
58
|
+
gold: Sequence[dict],
|
|
59
|
+
pred: Sequence[dict],
|
|
60
|
+
similarity: Callable[[dict, dict], float],
|
|
61
|
+
threshold: float,
|
|
62
|
+
) -> tuple[list[tuple[int, int]], list[int], list[int]]:
|
|
63
|
+
"""Pair gold items with predicted items regardless of order.
|
|
64
|
+
|
|
65
|
+
Identical items are paired first (cheap, and optimal). The rest are paired by
|
|
66
|
+
maximising total similarity. A pair whose similarity is below ``threshold``
|
|
67
|
+
(or zero) is dropped, so both items stay unmatched - a gold item with nothing
|
|
68
|
+
like it counts as missing and the stray predicted item as hallucinated, instead
|
|
69
|
+
of a misleading "wrong value".
|
|
70
|
+
|
|
71
|
+
Returns ``(pairs, unmatched_gold_indices, unmatched_pred_indices)``.
|
|
72
|
+
"""
|
|
73
|
+
pairs: list[tuple[int, int]] = []
|
|
74
|
+
pool: dict[str, list[int]] = {}
|
|
75
|
+
for j, item in enumerate(pred):
|
|
76
|
+
pool.setdefault(_key(item), []).append(j)
|
|
77
|
+
rest_gold: list[int] = []
|
|
78
|
+
taken: set[int] = set()
|
|
79
|
+
for i, item in enumerate(gold):
|
|
80
|
+
candidates = pool.get(_key(item))
|
|
81
|
+
if candidates:
|
|
82
|
+
j = candidates.pop(0)
|
|
83
|
+
pairs.append((i, j))
|
|
84
|
+
taken.add(j)
|
|
85
|
+
else:
|
|
86
|
+
rest_gold.append(i)
|
|
87
|
+
rest_pred = [j for j in range(len(pred)) if j not in taken]
|
|
88
|
+
|
|
89
|
+
if rest_gold and rest_pred:
|
|
90
|
+
size = max(len(rest_gold), len(rest_pred))
|
|
91
|
+
sims = [[0.0] * size for _ in range(size)]
|
|
92
|
+
for a, i in enumerate(rest_gold):
|
|
93
|
+
for b, j in enumerate(rest_pred):
|
|
94
|
+
sims[a][b] = similarity(gold[i], pred[j])
|
|
95
|
+
assignment = hungarian([[1.0 - s for s in row] for row in sims])
|
|
96
|
+
matched_gold: set[int] = set()
|
|
97
|
+
matched_pred: set[int] = set()
|
|
98
|
+
for a, b in enumerate(assignment):
|
|
99
|
+
if a < len(rest_gold) and b < len(rest_pred):
|
|
100
|
+
s = sims[a][b]
|
|
101
|
+
if s > 0.0 and s >= threshold:
|
|
102
|
+
pairs.append((rest_gold[a], rest_pred[b]))
|
|
103
|
+
matched_gold.add(rest_gold[a])
|
|
104
|
+
matched_pred.add(rest_pred[b])
|
|
105
|
+
rest_gold = [i for i in rest_gold if i not in matched_gold]
|
|
106
|
+
rest_pred = [j for j in rest_pred if j not in matched_pred]
|
|
107
|
+
return sorted(pairs), rest_gold, rest_pred
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _key(item: Any) -> str:
|
|
111
|
+
return json.dumps(item, sort_keys=True, ensure_ascii=False, default=str)
|
extractscore/cli.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Command line: ``extractscore score | diff | demo``.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 ok, 1 gate failed (``--min-f1``, ``--field-min``, ``--fail-on-regression``),
|
|
4
|
+
2 usage or input error.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
import typing
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Sequence
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .config import Config
|
|
20
|
+
from .diff import diff as diff_reports
|
|
21
|
+
from .errors import ExtractScoreError, InputError
|
|
22
|
+
from .gate import check_gates
|
|
23
|
+
from .i18n import LANGS, ref, resolve_lang, t
|
|
24
|
+
from .render import diff_markdown, diff_text, report_markdown, report_text
|
|
25
|
+
from .score import evaluate_files
|
|
26
|
+
|
|
27
|
+
SAMPLE_DIR = Path(__file__).resolve().parent / "sample"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _early_lang(argv: Sequence[str]) -> str | None:
|
|
31
|
+
"""Find ``--lang`` before the parser exists, so the help text itself can be translated."""
|
|
32
|
+
for i, arg in enumerate(argv):
|
|
33
|
+
if arg == "--lang" and i + 1 < len(argv):
|
|
34
|
+
return argv[i + 1]
|
|
35
|
+
if arg.startswith("--lang="):
|
|
36
|
+
return arg.split("=", 1)[1]
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_KIND_WORDS = {"int": "int", "float": "float"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _translate_argparse(message: str, lang: str) -> str:
|
|
44
|
+
"""Translate argparse's own English messages (it has no translation hook)."""
|
|
45
|
+
if lang == "en":
|
|
46
|
+
return message
|
|
47
|
+
patterns = (
|
|
48
|
+
(r"the following arguments are required: (?P<names>.+)", "ap_required"),
|
|
49
|
+
(r"unrecognized arguments: (?P<names>.+)", "ap_unrecognized"),
|
|
50
|
+
(r"invalid choice: (?P<value>.+?) \(choose from (?P<choices>[^)]+)\)(?:, maybe you meant .+)?", "ap_invalid_choice"),
|
|
51
|
+
(r"invalid (?P<kind>\w+) value: (?P<value>.+)", "ap_invalid_value"),
|
|
52
|
+
(r"expected one argument", "ap_expected_one"),
|
|
53
|
+
)
|
|
54
|
+
named = re.fullmatch(r"argument (?P<name>[^:]+): (?P<rest>.+)", message, re.S)
|
|
55
|
+
if named:
|
|
56
|
+
return t(lang, "ap_argument", name=named["name"], message=_translate_argparse(named["rest"], lang))
|
|
57
|
+
for pattern, key in patterns:
|
|
58
|
+
found = re.fullmatch(pattern, message, re.S)
|
|
59
|
+
if found:
|
|
60
|
+
return t(lang, key, **found.groupdict())
|
|
61
|
+
return message
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _make_parser_class(lang: str) -> type[argparse.ArgumentParser]:
|
|
65
|
+
"""An ArgumentParser whose usage prefix, group titles and usage errors follow ``lang``."""
|
|
66
|
+
|
|
67
|
+
class Formatter(argparse.HelpFormatter):
|
|
68
|
+
def add_usage(self, usage, actions, groups, prefix=None): # type: ignore[no-untyped-def]
|
|
69
|
+
super().add_usage(usage, actions, groups, t(lang, "usage_prefix") if prefix is None else prefix)
|
|
70
|
+
|
|
71
|
+
class Parser(argparse.ArgumentParser):
|
|
72
|
+
def __init__(self, *args, **kwargs): # type: ignore[no-untyped-def]
|
|
73
|
+
kwargs.setdefault("formatter_class", Formatter)
|
|
74
|
+
kwargs["add_help"] = False
|
|
75
|
+
super().__init__(*args, **kwargs)
|
|
76
|
+
self._positionals.title = t(lang, "title_positional")
|
|
77
|
+
self._optionals.title = t(lang, "title_options")
|
|
78
|
+
self.add_argument("-h", "--help", action="help", default=argparse.SUPPRESS, help=t(lang, "help_help"))
|
|
79
|
+
|
|
80
|
+
def error(self, message: str) -> typing.NoReturn:
|
|
81
|
+
self.print_usage(sys.stderr)
|
|
82
|
+
self.exit(2, f"{self.prog}: {t(lang, 'ap_error')}: {_translate_argparse(message, lang)}\n")
|
|
83
|
+
|
|
84
|
+
return Parser
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_parser(lang: str) -> argparse.ArgumentParser:
|
|
88
|
+
"""Build the argparse parser with all help text in ``lang``."""
|
|
89
|
+
parser_class = _make_parser_class(lang)
|
|
90
|
+
lang_parent = argparse.ArgumentParser(add_help=False)
|
|
91
|
+
lang_parent.add_argument("--lang", choices=LANGS, default=argparse.SUPPRESS, help=t(lang, "help_lang"))
|
|
92
|
+
|
|
93
|
+
parser = parser_class(prog="extractscore", description=t(lang, "help_description"), parents=[lang_parent])
|
|
94
|
+
parser.add_argument("--version", action="version", version=f"extractscore {__version__}", help=t(lang, "help_version"))
|
|
95
|
+
sub = parser.add_subparsers(dest="command", required=True, metavar="{score,diff,demo}", title=t(lang, "title_commands"), parser_class=parser_class)
|
|
96
|
+
|
|
97
|
+
score = sub.add_parser("score", help=t(lang, "help_score"), description=t(lang, "help_score"), parents=[lang_parent])
|
|
98
|
+
score.add_argument("gold", help=t(lang, "help_gold"))
|
|
99
|
+
score.add_argument("pred", help=t(lang, "help_pred"))
|
|
100
|
+
_common(score, lang)
|
|
101
|
+
score.add_argument("--min-f1", type=float, metavar="X", help=t(lang, "help_min_f1"))
|
|
102
|
+
score.add_argument("--field-min", action="append", default=[], metavar="PATH=X", help=t(lang, "help_field_min"))
|
|
103
|
+
score.add_argument("--top", type=int, default=5, metavar="N", help=t(lang, "help_top"))
|
|
104
|
+
|
|
105
|
+
diff = sub.add_parser("diff", help=t(lang, "help_diff"), description=t(lang, "help_diff"), parents=[lang_parent])
|
|
106
|
+
diff.add_argument("gold", help=t(lang, "help_gold"))
|
|
107
|
+
diff.add_argument("pred_a", metavar="A", help=t(lang, "help_pred_a"))
|
|
108
|
+
diff.add_argument("pred_b", metavar="B", help=t(lang, "help_pred_b"))
|
|
109
|
+
_common(diff, lang)
|
|
110
|
+
diff.add_argument("--fail-on-regression", action="store_true", help=t(lang, "help_fail_regress"))
|
|
111
|
+
diff.add_argument("--tolerance", type=float, default=0.0, metavar="X", help=t(lang, "help_tolerance"))
|
|
112
|
+
|
|
113
|
+
demo = sub.add_parser("demo", help=t(lang, "help_demo"), description=t(lang, "help_demo"), parents=[lang_parent])
|
|
114
|
+
demo.add_argument("--export", metavar="DIR", help=t(lang, "help_export"))
|
|
115
|
+
return parser
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _common(p: argparse.ArgumentParser, lang: str) -> None:
|
|
119
|
+
p.add_argument("--config", metavar="FILE", help=t(lang, "help_config"))
|
|
120
|
+
p.add_argument("--id-key", default="id", metavar="KEY", help=t(lang, "help_id_key"))
|
|
121
|
+
p.add_argument("--format", choices=("table", "json", "md"), default="table", help=t(lang, "help_format"))
|
|
122
|
+
p.add_argument("--out", metavar="FILE", help=t(lang, "help_out"))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
126
|
+
"""Entry point; returns the process exit code."""
|
|
127
|
+
args_list = list(sys.argv[1:] if argv is None else argv)
|
|
128
|
+
lang = resolve_lang(_early_lang(args_list))
|
|
129
|
+
parser = build_parser(lang)
|
|
130
|
+
args = parser.parse_args(args_list)
|
|
131
|
+
lang = resolve_lang(getattr(args, "lang", None))
|
|
132
|
+
try:
|
|
133
|
+
if args.command == "score":
|
|
134
|
+
return _cmd_score(args, lang)
|
|
135
|
+
if args.command == "diff":
|
|
136
|
+
return _cmd_diff(args, lang)
|
|
137
|
+
return _cmd_demo(args, lang)
|
|
138
|
+
except ExtractScoreError as exc:
|
|
139
|
+
print(f"{t(lang, 'error_prefix')}: {exc.localized(lang)}", file=sys.stderr)
|
|
140
|
+
return 2
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ------------------------------------------------------------------ commands
|
|
144
|
+
def _load_config(path: str | None) -> Config:
|
|
145
|
+
return Config.from_file(path) if path else Config()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _parse_field_min(items: Sequence[str]) -> dict[str, float]:
|
|
149
|
+
result: dict[str, float] = {}
|
|
150
|
+
for item in items:
|
|
151
|
+
path, sep, value = item.rpartition("=")
|
|
152
|
+
try:
|
|
153
|
+
number = float(value)
|
|
154
|
+
except ValueError:
|
|
155
|
+
number = -1.0
|
|
156
|
+
if not sep or not path or not 0.0 <= number <= 1.0:
|
|
157
|
+
raise InputError("err_bad_option", detail=ref("d_field_min_format", item=item))
|
|
158
|
+
result[path] = number
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _emit(text: str, out: str | None, lang: str) -> None:
|
|
163
|
+
"""Print to stdout, or write to ``--out`` and say so."""
|
|
164
|
+
if out is None:
|
|
165
|
+
sys.stdout.write(text)
|
|
166
|
+
return
|
|
167
|
+
try:
|
|
168
|
+
Path(out).write_text(text, encoding="utf-8")
|
|
169
|
+
except OSError as exc:
|
|
170
|
+
raise InputError("err_output_file", path=out, detail=str(exc)) from exc
|
|
171
|
+
print(t(lang, "wrote", path=out))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _cmd_score(args: argparse.Namespace, lang: str) -> int:
|
|
175
|
+
if args.min_f1 is not None and not 0.0 <= args.min_f1 <= 1.0:
|
|
176
|
+
raise InputError("err_bad_option", detail=ref("d_min_f1_range"))
|
|
177
|
+
if args.top < 0:
|
|
178
|
+
raise InputError("err_bad_option", detail=ref("d_top_negative"))
|
|
179
|
+
field_min = _parse_field_min(args.field_min)
|
|
180
|
+
report = evaluate_files(args.gold, args.pred, _load_config(args.config), id_key=args.id_key)
|
|
181
|
+
gated = args.min_f1 is not None or bool(field_min)
|
|
182
|
+
failures = check_gates(report, min_f1=args.min_f1, field_min=field_min)
|
|
183
|
+
|
|
184
|
+
if args.format == "json":
|
|
185
|
+
data = report.to_dict(args.top)
|
|
186
|
+
if gated:
|
|
187
|
+
data["gate"] = {
|
|
188
|
+
"passed": not failures,
|
|
189
|
+
"failures": [{"field": f.path, "f1": f.f1, "minimum": f.minimum} for f in failures],
|
|
190
|
+
}
|
|
191
|
+
text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
192
|
+
elif args.format == "md":
|
|
193
|
+
text = report_markdown(report, lang, args.top, failures, gated)
|
|
194
|
+
else:
|
|
195
|
+
text = report_text(report, lang, args.top, failures, gated)
|
|
196
|
+
_emit(text, args.out, lang)
|
|
197
|
+
if args.out and gated: # keep the verdict visible in the CI log even when the report went to a file
|
|
198
|
+
for line in _gate_summary(failures, lang):
|
|
199
|
+
print(line)
|
|
200
|
+
return 1 if failures else 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _gate_summary(failures, lang: str) -> list[str]:
|
|
204
|
+
lines = [t(lang, "gate_fail_line", path=f.path, f1=f"{f.f1:.3f}", minimum=f"{f.minimum:.3f}") for f in failures]
|
|
205
|
+
lines.append(t(lang, "gate_fail", n=len(failures)) if failures else t(lang, "gate_pass"))
|
|
206
|
+
return lines
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _cmd_diff(args: argparse.Namespace, lang: str) -> int:
|
|
210
|
+
config = _load_config(args.config)
|
|
211
|
+
report_a = evaluate_files(args.gold, args.pred_a, config, id_key=args.id_key)
|
|
212
|
+
report_b = evaluate_files(args.gold, args.pred_b, config, id_key=args.id_key)
|
|
213
|
+
result = diff_reports(report_a, report_b, tolerance=args.tolerance)
|
|
214
|
+
if args.format == "json":
|
|
215
|
+
text = json.dumps(result.to_dict(), indent=2, ensure_ascii=False) + "\n"
|
|
216
|
+
elif args.format == "md":
|
|
217
|
+
text = diff_markdown(result, lang)
|
|
218
|
+
else:
|
|
219
|
+
text = diff_text(result, lang)
|
|
220
|
+
_emit(text, args.out, lang)
|
|
221
|
+
return 1 if args.fail_on_regression and result.regressions else 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _cmd_demo(args: argparse.Namespace, lang: str) -> int:
|
|
225
|
+
if args.export:
|
|
226
|
+
return _export_demo(Path(args.export), lang)
|
|
227
|
+
config = Config.from_file(SAMPLE_DIR / "fields.toml")
|
|
228
|
+
gold = SAMPLE_DIR / "gold.jsonl"
|
|
229
|
+
report_a = evaluate_files(gold, SAMPLE_DIR / "pred_a.jsonl", config)
|
|
230
|
+
report_b = evaluate_files(gold, SAMPLE_DIR / "pred_b.jsonl", config)
|
|
231
|
+
print(t(lang, "demo_banner"))
|
|
232
|
+
print()
|
|
233
|
+
print(f"=== {t(lang, 'demo_run_a')}")
|
|
234
|
+
sys.stdout.write(report_text(report_a, lang))
|
|
235
|
+
print()
|
|
236
|
+
print(f"=== {t(lang, 'demo_run_b')}")
|
|
237
|
+
sys.stdout.write(diff_text(diff_reports(report_a, report_b), lang))
|
|
238
|
+
print()
|
|
239
|
+
print(t(lang, "demo_next"))
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _export_demo(target: Path, lang: str) -> int:
|
|
244
|
+
if target.exists() and (not target.is_dir() or any(target.iterdir())):
|
|
245
|
+
raise InputError("err_export_exists", path=str(target))
|
|
246
|
+
shutil.copytree(SAMPLE_DIR, target, dirs_exist_ok=True, ignore=shutil.ignore_patterns("__pycache__"))
|
|
247
|
+
print(t(lang, "demo_banner"))
|
|
248
|
+
print(t(lang, "demo_exported", path=target))
|
|
249
|
+
print(f" extractscore score {target}/gold.jsonl {target}/pred_a.jsonl --config {target}/fields.toml --min-f1 0.95")
|
|
250
|
+
print(f" extractscore diff {target}/gold.jsonl {target}/pred_a.jsonl {target}/pred_b.jsonl --config {target}/fields.toml --fail-on-regression")
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
if __name__ == "__main__": # pragma: no cover
|
|
255
|
+
sys.exit(main())
|
extractscore/compare.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Typed value comparators.
|
|
2
|
+
|
|
3
|
+
Each comparator answers one question: is the predicted value the same as the gold
|
|
4
|
+
value, once format noise is removed? The answer is one of three outcomes:
|
|
5
|
+
|
|
6
|
+
* ``"match"`` - same value
|
|
7
|
+
* ``"wrong"`` - a different value
|
|
8
|
+
* ``"type_error"`` - the predicted value cannot be read as the field's type
|
|
9
|
+
(``"N/A"`` in a number field, an object in a date field...)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
import re
|
|
16
|
+
from datetime import date, datetime
|
|
17
|
+
from typing import Any, Sequence
|
|
18
|
+
|
|
19
|
+
from .config import FieldRule
|
|
20
|
+
|
|
21
|
+
MATCH = "match"
|
|
22
|
+
WRONG = "wrong"
|
|
23
|
+
TYPE_ERROR = "type_error"
|
|
24
|
+
|
|
25
|
+
_CURRENCY = re.compile(r"usd|krw|eur|gbp|jpy|[$€£₩¥원\s,]", re.IGNORECASE)
|
|
26
|
+
_TRUE = {"true", "yes", "y", "1"}
|
|
27
|
+
_FALSE = {"false", "no", "n", "0"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_empty(value: Any, empty_as_null: bool = True) -> bool:
|
|
31
|
+
"""True for ``None``; with ``empty_as_null`` also ``""``, ``[]`` and ``{}``."""
|
|
32
|
+
if value is None:
|
|
33
|
+
return True
|
|
34
|
+
if not empty_as_null:
|
|
35
|
+
return False
|
|
36
|
+
if isinstance(value, str):
|
|
37
|
+
return not value.strip()
|
|
38
|
+
if isinstance(value, (list, dict)):
|
|
39
|
+
return not value
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def to_number(value: Any) -> float | None:
|
|
44
|
+
"""Read a number from an int/float or a string such as ``"$1,250.00"`` or ``"12,000원"``."""
|
|
45
|
+
if isinstance(value, bool):
|
|
46
|
+
return None
|
|
47
|
+
if isinstance(value, (int, float)):
|
|
48
|
+
number = float(value)
|
|
49
|
+
elif isinstance(value, str):
|
|
50
|
+
cleaned = _CURRENCY.sub("", value)
|
|
51
|
+
try:
|
|
52
|
+
number = float(cleaned)
|
|
53
|
+
except ValueError:
|
|
54
|
+
return None
|
|
55
|
+
else:
|
|
56
|
+
return None
|
|
57
|
+
return number if math.isfinite(number) else None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def to_date(value: Any, formats: Sequence[str]) -> date | None:
|
|
61
|
+
"""Parse a date string with the first format that fits (order matters for ``11/03/2026``)."""
|
|
62
|
+
if not isinstance(value, str):
|
|
63
|
+
return None
|
|
64
|
+
text = value.strip()
|
|
65
|
+
for fmt in formats:
|
|
66
|
+
try:
|
|
67
|
+
return datetime.strptime(text, fmt).date()
|
|
68
|
+
except ValueError:
|
|
69
|
+
continue
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def to_bool(value: Any) -> bool | None:
|
|
74
|
+
"""Read a bool from a JSON bool or common strings (true/false/yes/no/1/0)."""
|
|
75
|
+
if isinstance(value, bool):
|
|
76
|
+
return value
|
|
77
|
+
if isinstance(value, (int, float)) and value in (0, 1):
|
|
78
|
+
return bool(value)
|
|
79
|
+
if isinstance(value, str):
|
|
80
|
+
lowered = value.strip().casefold()
|
|
81
|
+
if lowered in _TRUE:
|
|
82
|
+
return True
|
|
83
|
+
if lowered in _FALSE:
|
|
84
|
+
return False
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def normalise_text(value: Any, casefold: bool = True) -> str:
|
|
89
|
+
"""Collapse whitespace and (optionally) ignore case."""
|
|
90
|
+
text = re.sub(r"\s+", " ", str(value)).strip()
|
|
91
|
+
return text.casefold() if casefold else text
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def levenshtein(a: str, b: str) -> int:
|
|
95
|
+
"""Edit distance between two strings."""
|
|
96
|
+
if a == b:
|
|
97
|
+
return 0
|
|
98
|
+
if not a:
|
|
99
|
+
return len(b)
|
|
100
|
+
if not b:
|
|
101
|
+
return len(a)
|
|
102
|
+
previous = list(range(len(b) + 1))
|
|
103
|
+
for i, char_a in enumerate(a, 1):
|
|
104
|
+
current = [i]
|
|
105
|
+
for j, char_b in enumerate(b, 1):
|
|
106
|
+
current.append(min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (char_a != char_b)))
|
|
107
|
+
previous = current
|
|
108
|
+
return previous[-1]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def text_similarity(a: str, b: str) -> float:
|
|
112
|
+
"""1.0 for identical strings, falling with edit distance (normalised by the longer one)."""
|
|
113
|
+
longest = max(len(a), len(b))
|
|
114
|
+
return 1.0 if longest == 0 else 1.0 - levenshtein(a, b) / longest
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_container(value: Any) -> bool:
|
|
118
|
+
return isinstance(value, (dict, list))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def compare_values(rule: FieldRule, gold: Any, pred: Any, date_formats: Sequence[str]) -> str:
|
|
122
|
+
"""Compare two non-empty scalar values under ``rule`` and return the outcome."""
|
|
123
|
+
kind = rule.type or "text"
|
|
124
|
+
if _is_container(pred):
|
|
125
|
+
return TYPE_ERROR
|
|
126
|
+
|
|
127
|
+
if kind == "number":
|
|
128
|
+
gold_n = to_number(gold)
|
|
129
|
+
if gold_n is None: # unreadable gold: fall back to plain text equality
|
|
130
|
+
return MATCH if normalise_text(gold) == normalise_text(pred) else WRONG
|
|
131
|
+
pred_n = to_number(pred)
|
|
132
|
+
if pred_n is None:
|
|
133
|
+
return TYPE_ERROR
|
|
134
|
+
return MATCH if math.isclose(gold_n, pred_n, rel_tol=1e-9, abs_tol=max(rule.tol, 1e-12)) else WRONG
|
|
135
|
+
|
|
136
|
+
if kind == "date":
|
|
137
|
+
gold_d = to_date(gold, date_formats)
|
|
138
|
+
if gold_d is None:
|
|
139
|
+
return MATCH if normalise_text(gold) == normalise_text(pred) else WRONG
|
|
140
|
+
pred_d = to_date(pred, date_formats)
|
|
141
|
+
if pred_d is None:
|
|
142
|
+
return TYPE_ERROR
|
|
143
|
+
return MATCH if gold_d == pred_d else WRONG
|
|
144
|
+
|
|
145
|
+
if kind == "bool":
|
|
146
|
+
gold_b = to_bool(gold)
|
|
147
|
+
if gold_b is None:
|
|
148
|
+
return MATCH if normalise_text(gold) == normalise_text(pred) else WRONG
|
|
149
|
+
pred_b = to_bool(pred)
|
|
150
|
+
if pred_b is None:
|
|
151
|
+
return TYPE_ERROR
|
|
152
|
+
return MATCH if gold_b == pred_b else WRONG
|
|
153
|
+
|
|
154
|
+
if kind == "id": # exact match; only surrounding whitespace is ignored
|
|
155
|
+
return MATCH if str(gold).strip() == str(pred).strip() else WRONG
|
|
156
|
+
|
|
157
|
+
if kind == "enum":
|
|
158
|
+
return MATCH if normalise_text(gold) == normalise_text(pred) else WRONG
|
|
159
|
+
|
|
160
|
+
# text
|
|
161
|
+
casefold = True if rule.casefold is None else rule.casefold
|
|
162
|
+
a, b = normalise_text(gold, casefold), normalise_text(pred, casefold)
|
|
163
|
+
if a == b:
|
|
164
|
+
return MATCH
|
|
165
|
+
if rule.fuzzy is not None and text_similarity(a, b) >= rule.fuzzy:
|
|
166
|
+
return MATCH
|
|
167
|
+
return WRONG
|