fieldbench 0.2.1__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.
- fieldbench/__init__.py +23 -0
- fieldbench/aggregate.py +120 -0
- fieldbench/cli.py +134 -0
- fieldbench/corpus.py +167 -0
- fieldbench/run.py +209 -0
- fieldbench/scoring.py +333 -0
- fieldbench-0.2.1.dist-info/METADATA +107 -0
- fieldbench-0.2.1.dist-info/RECORD +12 -0
- fieldbench-0.2.1.dist-info/WHEEL +5 -0
- fieldbench-0.2.1.dist-info/entry_points.txt +2 -0
- fieldbench-0.2.1.dist-info/licenses/LICENSE +21 -0
- fieldbench-0.2.1.dist-info/top_level.txt +1 -0
fieldbench/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""FieldBench — a cross-domain, field-level benchmark for schema-driven document extraction."""
|
|
2
|
+
|
|
3
|
+
from .aggregate import DocResult, Report, build_report
|
|
4
|
+
from .corpus import list_documents, score_corpus
|
|
5
|
+
from .run import LLMRunner, Runner, run_corpus
|
|
6
|
+
from .scoring import SCORER_VERSION, FieldResult, compare_field
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.1"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"compare_field",
|
|
12
|
+
"FieldResult",
|
|
13
|
+
"SCORER_VERSION",
|
|
14
|
+
"score_corpus",
|
|
15
|
+
"list_documents",
|
|
16
|
+
"build_report",
|
|
17
|
+
"DocResult",
|
|
18
|
+
"Report",
|
|
19
|
+
"run_corpus",
|
|
20
|
+
"Runner",
|
|
21
|
+
"LLMRunner",
|
|
22
|
+
"__version__",
|
|
23
|
+
]
|
fieldbench/aggregate.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Aggregate per-field results into a FieldBench report.
|
|
2
|
+
|
|
3
|
+
Reports micro/macro field accuracy, always stratified real vs synthetic, with a
|
|
4
|
+
four-way confusion breakdown (correct-absence / hallucination / miss /
|
|
5
|
+
wrong-value) and the all-null degenerate floor — so every score is read against
|
|
6
|
+
what an empty extractor scores for free.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import Counter, defaultdict
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from .scoring import SCORER_VERSION, FieldResult, is_empty
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class DocResult:
|
|
19
|
+
stem: str
|
|
20
|
+
category: str
|
|
21
|
+
source: str # "real" | "synthetic" | "unknown"
|
|
22
|
+
fields: list[FieldResult]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Stratum:
|
|
27
|
+
docs: int = 0
|
|
28
|
+
fields: int = 0
|
|
29
|
+
passed: int = 0
|
|
30
|
+
weighted: float = 0.0
|
|
31
|
+
four_way: Counter = field(default_factory=Counter)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def accuracy(self) -> float:
|
|
35
|
+
return self.passed / self.fields if self.fields else 0.0
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def partial(self) -> float:
|
|
39
|
+
return self.weighted / self.fields if self.fields else 0.0
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict:
|
|
42
|
+
n = self.fields or 1
|
|
43
|
+
return {
|
|
44
|
+
"docs": self.docs,
|
|
45
|
+
"fields": self.fields,
|
|
46
|
+
"passed": self.passed,
|
|
47
|
+
"accuracy": round(self.accuracy, 4),
|
|
48
|
+
"partial_credit": round(self.partial, 4),
|
|
49
|
+
"four_way": {
|
|
50
|
+
bucket: {"count": c, "rate": round(c / n, 4)}
|
|
51
|
+
for bucket, c in sorted(self.four_way.items())
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Report:
|
|
58
|
+
scorer_version: str = SCORER_VERSION
|
|
59
|
+
mode: str = "unspecified" # which representation the predictions came from
|
|
60
|
+
overall: Stratum = field(default_factory=Stratum)
|
|
61
|
+
by_source: dict[str, Stratum] = field(default_factory=dict)
|
|
62
|
+
by_category: dict[str, Stratum] = field(default_factory=dict)
|
|
63
|
+
four_way: Counter = field(default_factory=Counter)
|
|
64
|
+
all_null_floor: float = 0.0
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> dict:
|
|
67
|
+
total = sum(self.four_way.values()) or 1
|
|
68
|
+
return {
|
|
69
|
+
"scorer_version": self.scorer_version,
|
|
70
|
+
"mode": self.mode,
|
|
71
|
+
"overall": self.overall.to_dict(),
|
|
72
|
+
"by_source": {k: v.to_dict() for k, v in sorted(self.by_source.items())},
|
|
73
|
+
"by_category": {k: v.to_dict() for k, v in sorted(self.by_category.items())},
|
|
74
|
+
"four_way": {
|
|
75
|
+
bucket: {"count": n, "rate": round(n / total, 4)}
|
|
76
|
+
for bucket, n in sorted(self.four_way.items())
|
|
77
|
+
},
|
|
78
|
+
"all_null_floor": round(self.all_null_floor, 4),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _add(strata: dict[str, Stratum], key: str, r: FieldResult) -> None:
|
|
83
|
+
s = strata.setdefault(key, Stratum())
|
|
84
|
+
s.fields += 1
|
|
85
|
+
s.passed += int(r.passed)
|
|
86
|
+
s.weighted += r.weighted_score
|
|
87
|
+
s.four_way[r.bucket] += 1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_report(docs: list[DocResult], mode: str = "unspecified") -> Report:
|
|
91
|
+
rep = Report(mode=mode)
|
|
92
|
+
empty_expected = 0
|
|
93
|
+
total = 0
|
|
94
|
+
doc_counts_source: dict[str, int] = defaultdict(int)
|
|
95
|
+
doc_counts_cat: dict[str, int] = defaultdict(int)
|
|
96
|
+
|
|
97
|
+
for doc in docs:
|
|
98
|
+
doc_counts_source[doc.source] += 1
|
|
99
|
+
doc_counts_cat[doc.category] += 1
|
|
100
|
+
for r in doc.fields:
|
|
101
|
+
total += 1
|
|
102
|
+
rep.overall.fields += 1
|
|
103
|
+
rep.overall.passed += int(r.passed)
|
|
104
|
+
rep.overall.weighted += r.weighted_score
|
|
105
|
+
rep.four_way[r.bucket] += 1
|
|
106
|
+
_add(rep.by_source, doc.source, r)
|
|
107
|
+
_add(rep.by_category, doc.category, r)
|
|
108
|
+
if is_empty(r.expected):
|
|
109
|
+
empty_expected += 1
|
|
110
|
+
|
|
111
|
+
rep.overall.docs = len(docs)
|
|
112
|
+
for src, n in doc_counts_source.items():
|
|
113
|
+
rep.by_source.setdefault(src, Stratum()).docs = n
|
|
114
|
+
for cat, n in doc_counts_cat.items():
|
|
115
|
+
rep.by_category.setdefault(cat, Stratum()).docs = n
|
|
116
|
+
|
|
117
|
+
# All-null floor: an empty extractor scores exactly the empty-GT fields
|
|
118
|
+
# (they land in `correct_absence`); everything else becomes a miss.
|
|
119
|
+
rep.all_null_floor = empty_expected / total if total else 0.0
|
|
120
|
+
return rep
|
fieldbench/cli.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""FieldBench CLI: score prediction files against the corpus."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .aggregate import build_report
|
|
12
|
+
from .corpus import score_corpus
|
|
13
|
+
from .run import run_corpus
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_runner(ref: str):
|
|
17
|
+
"""Load a runner from `module:attr`. attr is a Runner instance or a
|
|
18
|
+
zero-arg factory that returns one."""
|
|
19
|
+
if ":" not in ref:
|
|
20
|
+
raise ValueError(f"--runner must be 'module:attr', got {ref!r}")
|
|
21
|
+
mod_name, attr = ref.split(":", 1)
|
|
22
|
+
obj = getattr(importlib.import_module(mod_name), attr)
|
|
23
|
+
if hasattr(obj, "extract"):
|
|
24
|
+
return obj
|
|
25
|
+
return obj() # factory
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _print_table(report_dict: dict) -> None:
|
|
29
|
+
o = report_dict["overall"]
|
|
30
|
+
print(f"\nFieldBench (scorer {report_dict['scorer_version']})")
|
|
31
|
+
print("=" * 52)
|
|
32
|
+
print(f"Overall: {o['accuracy']:.1%} ({o['passed']}/{o['fields']} fields, {o['docs']} docs)")
|
|
33
|
+
print(f"All-null floor: {report_dict['all_null_floor']:.1%} <- a `{{}}`-emitting baseline scores this free")
|
|
34
|
+
|
|
35
|
+
print("\nBy source:")
|
|
36
|
+
for src, s in report_dict["by_source"].items():
|
|
37
|
+
print(f" {src:<12} {s['accuracy']:.1%} ({s['fields']} fields, {s['docs']} docs)")
|
|
38
|
+
|
|
39
|
+
print("\nFour-way outcomes:")
|
|
40
|
+
for bucket, v in report_dict["four_way"].items():
|
|
41
|
+
print(f" {bucket:<16} {v['count']:>6} ({v['rate']:.1%})")
|
|
42
|
+
|
|
43
|
+
print("\nBy category:")
|
|
44
|
+
for cat, s in sorted(report_dict["by_category"].items(), key=lambda kv: kv[1]["accuracy"]):
|
|
45
|
+
print(f" {cat:<24} {s['accuracy']:.1%} ({s['fields']} fields)")
|
|
46
|
+
print()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main(argv: list[str] | None = None) -> int:
|
|
50
|
+
parser = argparse.ArgumentParser(prog="fieldbench")
|
|
51
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
52
|
+
|
|
53
|
+
score = sub.add_parser("score", help="Score prediction files against the corpus")
|
|
54
|
+
score.add_argument("--corpus", required=True, type=Path, help="Path to the corpus root")
|
|
55
|
+
score.add_argument("--results", required=True, type=Path, help="Directory of <stem>.json predictions")
|
|
56
|
+
score.add_argument("--category", default=None, help="Score a single category only")
|
|
57
|
+
score.add_argument(
|
|
58
|
+
"--mode", default="unspecified", help="Label for which representation the predictions came from (markdown/source/...)"
|
|
59
|
+
)
|
|
60
|
+
score.add_argument("--fuzzy-threshold", type=float, default=0.0, help="Off (0.0) for the official metric")
|
|
61
|
+
score.add_argument("--json", action="store_true", help="Emit the full report as JSON")
|
|
62
|
+
score.add_argument(
|
|
63
|
+
"--allow-missing",
|
|
64
|
+
action="store_true",
|
|
65
|
+
help="Do not fail when prediction files are missing (they are scored as all-null)",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
run = sub.add_parser("run", help="Run an extractor over the corpus, writing per-doc predictions")
|
|
69
|
+
run.add_argument("--corpus", required=True, type=Path, help="Path to the corpus root")
|
|
70
|
+
run.add_argument("--out", required=True, type=Path, help="Output dir for <stem>.json predictions")
|
|
71
|
+
run.add_argument("--runner", required=True, help="Runner as 'module:attr' (instance or zero-arg factory)")
|
|
72
|
+
run.add_argument("--mode", default="markdown", help="Which representation to feed (markdown/text/source)")
|
|
73
|
+
run.add_argument("--category", default=None, help="Run a single category only")
|
|
74
|
+
run.add_argument("--limit", type=int, default=None, help="Stop after N docs (smoke testing)")
|
|
75
|
+
run.add_argument("--no-resume", action="store_true", help="Re-run docs even if a prediction file exists")
|
|
76
|
+
|
|
77
|
+
args = parser.parse_args(argv)
|
|
78
|
+
|
|
79
|
+
if args.command == "run":
|
|
80
|
+
if not args.corpus.is_dir():
|
|
81
|
+
print(f"error: corpus not found: {args.corpus}", file=sys.stderr)
|
|
82
|
+
return 2
|
|
83
|
+
try:
|
|
84
|
+
runner = _load_runner(args.runner)
|
|
85
|
+
except (ValueError, ImportError, AttributeError) as exc:
|
|
86
|
+
print(f"error: could not load runner {args.runner!r}: {exc}", file=sys.stderr)
|
|
87
|
+
return 2
|
|
88
|
+
stats = run_corpus(
|
|
89
|
+
args.corpus,
|
|
90
|
+
runner,
|
|
91
|
+
args.out,
|
|
92
|
+
mode=args.mode,
|
|
93
|
+
category=args.category,
|
|
94
|
+
resume=not args.no_resume,
|
|
95
|
+
limit=args.limit,
|
|
96
|
+
on_progress=lambda stem, status: print(f" {stem}: {status}", file=sys.stderr),
|
|
97
|
+
)
|
|
98
|
+
print(
|
|
99
|
+
f"\nwrote {stats.written}, skipped {stats.skipped} (resume), "
|
|
100
|
+
f"errors {stats.errors}, no-representation {stats.no_representation}"
|
|
101
|
+
)
|
|
102
|
+
if stats.error_stems:
|
|
103
|
+
print(f"error docs: {', '.join(stats.error_stems[:10])}"
|
|
104
|
+
f"{'…' if len(stats.error_stems) > 10 else ''}", file=sys.stderr)
|
|
105
|
+
return 1 if (stats.errors or stats.no_representation) else 0
|
|
106
|
+
|
|
107
|
+
if args.command == "score":
|
|
108
|
+
if not args.corpus.is_dir():
|
|
109
|
+
print(f"error: corpus not found: {args.corpus}", file=sys.stderr)
|
|
110
|
+
return 2
|
|
111
|
+
docs, missing = score_corpus(
|
|
112
|
+
args.corpus, args.results, category=args.category, fuzzy_threshold=args.fuzzy_threshold
|
|
113
|
+
)
|
|
114
|
+
if not docs:
|
|
115
|
+
print("error: no scorable documents found", file=sys.stderr)
|
|
116
|
+
return 2
|
|
117
|
+
report = build_report(docs, mode=args.mode).to_dict()
|
|
118
|
+
if args.json:
|
|
119
|
+
print(json.dumps(report, indent=2))
|
|
120
|
+
else:
|
|
121
|
+
_print_table(report)
|
|
122
|
+
if missing and not args.allow_missing:
|
|
123
|
+
print(
|
|
124
|
+
f"error: {missing} prediction file(s) missing — scored as all-null. "
|
|
125
|
+
f"Re-run with complete results, or pass --allow-missing to override.",
|
|
126
|
+
file=sys.stderr,
|
|
127
|
+
)
|
|
128
|
+
return 1
|
|
129
|
+
return 0
|
|
130
|
+
return 2
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
raise SystemExit(main())
|
fieldbench/corpus.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Load the FieldBench corpus and score prediction files against it.
|
|
2
|
+
|
|
3
|
+
Supports two on-disk layouts so scoring keeps working across the migration to
|
|
4
|
+
the source-first structure (see corpus-structure.md):
|
|
5
|
+
|
|
6
|
+
Flat (current):
|
|
7
|
+
<category>/documents/<stem>.md
|
|
8
|
+
<category>/expected/<stem>.expected.json
|
|
9
|
+
<category>/manifests/<stem>.json
|
|
10
|
+
|
|
11
|
+
Source-first (target):
|
|
12
|
+
<category>/documents/<stem>/meta.json
|
|
13
|
+
<category>/documents/<stem>/repr/markdown.md (+ other representations)
|
|
14
|
+
<category>/documents/<stem>/source.<ext>
|
|
15
|
+
<category>/expected/<stem>.expected.json (GT unchanged, either layout)
|
|
16
|
+
|
|
17
|
+
Predictions: a directory of `<stem>.json` files, each a flat {field: value} map
|
|
18
|
+
(the zero-dependency format any system can emit). A missing prediction file is
|
|
19
|
+
scored as all-null, never silently skipped.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import yaml
|
|
28
|
+
|
|
29
|
+
from .aggregate import DocResult
|
|
30
|
+
from .scoring import compare_field
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _read_json(path: Path) -> dict | None:
|
|
34
|
+
try:
|
|
35
|
+
return json.loads(path.read_text())
|
|
36
|
+
except (OSError, json.JSONDecodeError):
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load_manifest(cat_dir: Path, stem: str) -> dict | None:
|
|
41
|
+
"""Dual-path manifest lookup: per-doc meta.json (new) → manifests/ (flat)."""
|
|
42
|
+
meta = cat_dir / "documents" / stem / "meta.json"
|
|
43
|
+
if meta.exists():
|
|
44
|
+
return _read_json(meta)
|
|
45
|
+
return _read_json(cat_dir / "manifests" / f"{stem}.json")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _source_of(manifest: dict) -> str:
|
|
49
|
+
"""real | synthetic | unknown. Explicit `source` wins; else derive from format."""
|
|
50
|
+
src = str(manifest.get("source", "")).strip().lower()
|
|
51
|
+
if src in ("real", "synthetic"):
|
|
52
|
+
return src
|
|
53
|
+
fmt = str(manifest.get("source_format") or manifest.get("original_format", "")).lower()
|
|
54
|
+
if not fmt:
|
|
55
|
+
return "unknown"
|
|
56
|
+
return "synthetic" if "synth" in fmt else "real"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def discover_categories(root: Path) -> list[str]:
|
|
60
|
+
cats = []
|
|
61
|
+
for entry in sorted(root.iterdir()):
|
|
62
|
+
if not entry.is_dir() or entry.name.startswith((".", "_")):
|
|
63
|
+
continue
|
|
64
|
+
if (entry / "documents").is_dir() and (entry / "expected").is_dir():
|
|
65
|
+
cats.append(entry.name)
|
|
66
|
+
return cats
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _schema_mappings(root: Path, schema_ref: str | None, cache: dict) -> dict:
|
|
70
|
+
"""{field_name: mappings} from a schema YAML (`fields.<name>.mappings`).
|
|
71
|
+
|
|
72
|
+
Enum-alias folding is what lets `10K/A` match GT `10-K/A`. Best-effort:
|
|
73
|
+
a missing/unreadable schema yields no mappings, never an error.
|
|
74
|
+
"""
|
|
75
|
+
if not schema_ref:
|
|
76
|
+
return {}
|
|
77
|
+
if schema_ref in cache:
|
|
78
|
+
return cache[schema_ref]
|
|
79
|
+
out: dict = {}
|
|
80
|
+
path = root / schema_ref
|
|
81
|
+
try:
|
|
82
|
+
schema = yaml.safe_load(path.read_text())
|
|
83
|
+
for name, spec in (schema.get("fields") or {}).items():
|
|
84
|
+
if isinstance(spec, dict) and isinstance(spec.get("mappings"), dict):
|
|
85
|
+
out[name] = spec["mappings"]
|
|
86
|
+
except (OSError, yaml.YAMLError, AttributeError):
|
|
87
|
+
out = {}
|
|
88
|
+
cache[schema_ref] = out
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _iter_docs(root: Path, category: str):
|
|
93
|
+
cat = root / category
|
|
94
|
+
for expected_path in sorted((cat / "expected").glob("*.expected.json")):
|
|
95
|
+
stem = expected_path.name[: -len(".expected.json")]
|
|
96
|
+
expected = _read_json(expected_path)
|
|
97
|
+
manifest = _load_manifest(cat, stem)
|
|
98
|
+
if expected is None or manifest is None:
|
|
99
|
+
continue
|
|
100
|
+
yield stem, expected, manifest
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def representation_path(cat_dir: Path, stem: str, manifest: dict, mode: str) -> Path | None:
|
|
104
|
+
"""Resolve the file a prediction generator should read for `mode`.
|
|
105
|
+
|
|
106
|
+
New layout: manifest.representations[mode].path (or source.* for mode='source').
|
|
107
|
+
Flat layout: documents/<stem>.md for mode in {markdown, text}.
|
|
108
|
+
"""
|
|
109
|
+
doc_dir = cat_dir / "documents" / stem
|
|
110
|
+
if mode == "source":
|
|
111
|
+
artifact = manifest.get("source_artifact")
|
|
112
|
+
return (doc_dir / artifact) if artifact else None
|
|
113
|
+
reps = manifest.get("representations")
|
|
114
|
+
if isinstance(reps, dict) and mode in reps and isinstance(reps[mode], dict):
|
|
115
|
+
p = doc_dir / reps[mode]["path"]
|
|
116
|
+
return p if p.exists() else None
|
|
117
|
+
flat = cat_dir / "documents" / f"{stem}.md" # flat layout has only markdown
|
|
118
|
+
return flat if (mode in ("markdown", "text") and flat.exists()) else None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def list_documents(corpus_root: Path, mode: str = "markdown", category: str | None = None):
|
|
122
|
+
"""Yield (stem, category, representation_path) for baseline/prediction runners."""
|
|
123
|
+
categories = [category] if category else discover_categories(corpus_root)
|
|
124
|
+
for cat in categories:
|
|
125
|
+
for stem, _expected, manifest in _iter_docs(corpus_root, cat):
|
|
126
|
+
yield stem, cat, representation_path(corpus_root / cat, stem, manifest, mode)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _load_prediction(results_dir: Path, stem: str) -> dict:
|
|
130
|
+
data = _read_json(results_dir / f"{stem}.json")
|
|
131
|
+
if isinstance(data, dict) and isinstance(data.get("fields"), dict):
|
|
132
|
+
return data["fields"] # accept a {"fields": {...}} wrapper
|
|
133
|
+
return data if isinstance(data, dict) else {}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def score_corpus(
|
|
137
|
+
corpus_root: Path,
|
|
138
|
+
results_dir: Path,
|
|
139
|
+
category: str | None = None,
|
|
140
|
+
fuzzy_threshold: float = 0.0,
|
|
141
|
+
) -> tuple[list[DocResult], int]:
|
|
142
|
+
"""Score every document. Returns (doc_results, missing_prediction_count).
|
|
143
|
+
|
|
144
|
+
Assert `missing_prediction_count == 0` before publishing any number — a
|
|
145
|
+
missing file is scored as all-null, not skipped, so it can't silently
|
|
146
|
+
inflate a system's accuracy.
|
|
147
|
+
"""
|
|
148
|
+
categories = [category] if category else discover_categories(corpus_root)
|
|
149
|
+
docs: list[DocResult] = []
|
|
150
|
+
missing = 0
|
|
151
|
+
schema_cache: dict = {}
|
|
152
|
+
|
|
153
|
+
for cat in categories:
|
|
154
|
+
for stem, expected, manifest in _iter_docs(corpus_root, cat):
|
|
155
|
+
if not (results_dir / f"{stem}.json").exists():
|
|
156
|
+
missing += 1
|
|
157
|
+
prediction = _load_prediction(results_dir, stem)
|
|
158
|
+
mappings = _schema_mappings(corpus_root, manifest.get("schema"), schema_cache)
|
|
159
|
+
fields = [
|
|
160
|
+
compare_field(
|
|
161
|
+
name, exp, prediction.get(name), fuzzy_threshold=fuzzy_threshold, mappings=mappings.get(name)
|
|
162
|
+
)
|
|
163
|
+
for name, exp in expected.items()
|
|
164
|
+
]
|
|
165
|
+
docs.append(DocResult(stem, cat, _source_of(manifest), fields))
|
|
166
|
+
|
|
167
|
+
return docs, missing
|
fieldbench/run.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Drive an extractor over the corpus and write per-doc predictions.
|
|
2
|
+
|
|
3
|
+
The other half of the benchmark loop: `fieldbench run` produces the `<stem>.json`
|
|
4
|
+
prediction files that `fieldbench score` reads. Extractor-agnostic — supply any
|
|
5
|
+
Runner (turns document text + schema into a {field: value} dict). A schema-driven
|
|
6
|
+
LLM runner is provided; model-SDK wiring lives in examples/ so the core stays
|
|
7
|
+
dependency-light. Runs are resumable (existing prediction files are skipped) and
|
|
8
|
+
never invent results — a runner error writes nothing, so the doc is scored as
|
|
9
|
+
all-null (a real miss), not silently dropped.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Callable, Protocol
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
from .corpus import _iter_docs, discover_categories, representation_path
|
|
23
|
+
from .scoring import is_empty
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Runner(Protocol):
|
|
27
|
+
"""Anything that turns a document + its schema into a {field: value} dict."""
|
|
28
|
+
|
|
29
|
+
def extract(self, doc_text: str, schema: dict, stem: str) -> dict: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class RunStats:
|
|
34
|
+
written: int = 0
|
|
35
|
+
skipped: int = 0 # already present (resume)
|
|
36
|
+
errors: int = 0
|
|
37
|
+
no_representation: int = 0
|
|
38
|
+
error_stems: list = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _schema_field_names(schema: dict) -> list[str]:
|
|
42
|
+
return list((schema.get("fields") or {}).keys())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_extraction_prompt(doc_text: str, schema: dict) -> str:
|
|
46
|
+
"""Generic schema-driven extraction prompt. No domain assumptions."""
|
|
47
|
+
lines = []
|
|
48
|
+
for name, spec in (schema.get("fields") or {}).items():
|
|
49
|
+
spec = spec if isinstance(spec, dict) else {}
|
|
50
|
+
typ = spec.get("type", "string")
|
|
51
|
+
desc = spec.get("description", "")
|
|
52
|
+
opts = spec.get("options")
|
|
53
|
+
suffix = f" (one of: {', '.join(map(str, opts))})" if isinstance(opts, list) and opts else ""
|
|
54
|
+
lines.append(f"- {name} ({typ}): {desc}{suffix}")
|
|
55
|
+
fields_block = "\n".join(lines)
|
|
56
|
+
return (
|
|
57
|
+
"Extract structured data from the document below.\n"
|
|
58
|
+
"Return ONLY a JSON object with EXACTLY these fields. "
|
|
59
|
+
"Use null when a field is not present in the document — do not guess.\n\n"
|
|
60
|
+
f"Fields:\n{fields_block}\n\n"
|
|
61
|
+
f"Document:\n{doc_text}\n\nJSON:"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_json_response(text: str) -> dict:
|
|
66
|
+
"""Robustly pull a JSON object out of an LLM response."""
|
|
67
|
+
text = text.strip()
|
|
68
|
+
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text).strip()
|
|
69
|
+
try:
|
|
70
|
+
obj = json.loads(text)
|
|
71
|
+
except json.JSONDecodeError:
|
|
72
|
+
start, depth, end = text.find("{"), 0, -1
|
|
73
|
+
if start < 0:
|
|
74
|
+
return {}
|
|
75
|
+
for i in range(start, len(text)):
|
|
76
|
+
depth += text[i] == "{"
|
|
77
|
+
depth -= text[i] == "}"
|
|
78
|
+
if depth == 0:
|
|
79
|
+
end = i + 1
|
|
80
|
+
break
|
|
81
|
+
if end < 0:
|
|
82
|
+
return {}
|
|
83
|
+
try:
|
|
84
|
+
obj = json.loads(text[start:end])
|
|
85
|
+
except json.JSONDecodeError:
|
|
86
|
+
return {}
|
|
87
|
+
return obj if isinstance(obj, dict) else {}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def window_text(text: str, size: int, overlap: int = 2000) -> list[str]:
|
|
91
|
+
"""Split text into overlapping windows of `size` chars (overlap avoids
|
|
92
|
+
splitting a value across a boundary)."""
|
|
93
|
+
if len(text) <= size:
|
|
94
|
+
return [text]
|
|
95
|
+
step = max(1, size - overlap)
|
|
96
|
+
return [text[i : i + size] for i in range(0, len(text), step)]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def merge_field_values(values: list) -> object:
|
|
100
|
+
"""Merge one field's values across windows: concat+dedup lists, first
|
|
101
|
+
non-empty dict, else first non-empty scalar."""
|
|
102
|
+
non_empty = [v for v in values if not is_empty(v)]
|
|
103
|
+
if not non_empty:
|
|
104
|
+
return None
|
|
105
|
+
if any(isinstance(v, list) for v in non_empty):
|
|
106
|
+
out, seen = [], set()
|
|
107
|
+
for v in non_empty:
|
|
108
|
+
for item in v if isinstance(v, list) else [v]:
|
|
109
|
+
key = json.dumps(item, sort_keys=True, default=str)
|
|
110
|
+
if key not in seen:
|
|
111
|
+
seen.add(key)
|
|
112
|
+
out.append(item)
|
|
113
|
+
return out
|
|
114
|
+
for v in non_empty:
|
|
115
|
+
if isinstance(v, dict):
|
|
116
|
+
return v
|
|
117
|
+
return non_empty[0]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class LLMRunner:
|
|
121
|
+
"""Schema-driven runner over any `complete(prompt) -> str` callable.
|
|
122
|
+
|
|
123
|
+
Keeps model SDKs out of the core: pass a completion function (see
|
|
124
|
+
examples/). Output is filtered to the schema's declared fields.
|
|
125
|
+
|
|
126
|
+
`max_doc_chars` (None = off) makes it windowed: a document longer than the
|
|
127
|
+
budget is split into overlapping windows, each extracted, then merged
|
|
128
|
+
field-by-field — a fair long-document baseline that survives context limits.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(self, complete: Callable[[str], str], max_doc_chars: int | None = None, overlap: int = 2000):
|
|
132
|
+
self._complete = complete
|
|
133
|
+
self._max_doc_chars = max_doc_chars
|
|
134
|
+
self._overlap = overlap
|
|
135
|
+
|
|
136
|
+
def _extract_once(self, doc_text: str, schema: dict, allowed: set) -> dict:
|
|
137
|
+
parsed = parse_json_response(self._complete(build_extraction_prompt(doc_text, schema)))
|
|
138
|
+
return {k: v for k, v in parsed.items() if k in allowed} if allowed else parsed
|
|
139
|
+
|
|
140
|
+
def extract(self, doc_text: str, schema: dict, stem: str) -> dict:
|
|
141
|
+
allowed = set(_schema_field_names(schema))
|
|
142
|
+
if self._max_doc_chars and len(doc_text) > self._max_doc_chars:
|
|
143
|
+
windows = window_text(doc_text, self._max_doc_chars, self._overlap)
|
|
144
|
+
preds = [self._extract_once(w, schema, allowed) for w in windows]
|
|
145
|
+
names = allowed or {k for p in preds for k in p}
|
|
146
|
+
return {name: merge_field_values([p.get(name) for p in preds]) for name in names}
|
|
147
|
+
return self._extract_once(doc_text, schema, allowed)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _load_schema(root: Path, schema_ref: str | None, cache: dict) -> dict:
|
|
151
|
+
if not schema_ref:
|
|
152
|
+
return {}
|
|
153
|
+
if schema_ref in cache:
|
|
154
|
+
return cache[schema_ref]
|
|
155
|
+
try:
|
|
156
|
+
schema = yaml.safe_load((root / schema_ref).read_text()) or {}
|
|
157
|
+
except (OSError, yaml.YAMLError):
|
|
158
|
+
schema = {}
|
|
159
|
+
cache[schema_ref] = schema
|
|
160
|
+
return schema
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def run_corpus(
|
|
164
|
+
corpus_root: Path,
|
|
165
|
+
runner: Runner,
|
|
166
|
+
out_dir: Path,
|
|
167
|
+
mode: str = "markdown",
|
|
168
|
+
category: str | None = None,
|
|
169
|
+
resume: bool = True,
|
|
170
|
+
limit: int | None = None,
|
|
171
|
+
on_progress: Callable[[str, str], None] | None = None,
|
|
172
|
+
) -> RunStats:
|
|
173
|
+
"""Run `runner` over the corpus, writing out_dir/<stem>.json per doc."""
|
|
174
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
stats = RunStats()
|
|
176
|
+
schema_cache: dict = {}
|
|
177
|
+
categories = [category] if category else discover_categories(corpus_root)
|
|
178
|
+
done = 0
|
|
179
|
+
|
|
180
|
+
for cat in categories:
|
|
181
|
+
cat_dir = corpus_root / cat
|
|
182
|
+
for stem, _expected, manifest in _iter_docs(corpus_root, cat): # _expected is GT — NOT used at run time
|
|
183
|
+
if limit is not None and done >= limit:
|
|
184
|
+
return stats
|
|
185
|
+
out_path = out_dir / f"{stem}.json"
|
|
186
|
+
if resume and out_path.exists():
|
|
187
|
+
stats.skipped += 1
|
|
188
|
+
continue
|
|
189
|
+
repr_path = representation_path(cat_dir, stem, manifest, mode)
|
|
190
|
+
if repr_path is None or not repr_path.exists():
|
|
191
|
+
stats.no_representation += 1
|
|
192
|
+
if on_progress:
|
|
193
|
+
on_progress(stem, "no-representation")
|
|
194
|
+
continue
|
|
195
|
+
schema = _load_schema(corpus_root, manifest.get("schema"), schema_cache)
|
|
196
|
+
done += 1 # counts any attempted doc, so --limit works even when docs error
|
|
197
|
+
try:
|
|
198
|
+
prediction = runner.extract(repr_path.read_text(), schema, stem)
|
|
199
|
+
out_path.write_text(json.dumps(prediction, ensure_ascii=False, indent=2))
|
|
200
|
+
stats.written += 1
|
|
201
|
+
if on_progress:
|
|
202
|
+
on_progress(stem, "ok")
|
|
203
|
+
except Exception as exc: # noqa: BLE001 — a runner failure must not abort the whole run
|
|
204
|
+
stats.errors += 1
|
|
205
|
+
stats.error_stems.append(stem)
|
|
206
|
+
if on_progress:
|
|
207
|
+
on_progress(stem, f"error: {exc}")
|
|
208
|
+
|
|
209
|
+
return stats
|
fieldbench/scoring.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Canonical field-level comparison for the FieldBench benchmark.
|
|
2
|
+
|
|
3
|
+
This is the single source of truth for how an extracted value is compared to
|
|
4
|
+
ground truth. It ports the semantics of Koji's ``cli/test_runner.py`` scorer
|
|
5
|
+
(binary pass/fail + four-way null semantics, array F1, enum/mapping folding,
|
|
6
|
+
punctuation-tolerant scalars) and makes the four-way outcome an explicit,
|
|
7
|
+
first-class tag on every field so the aggregate can report a hallucination /
|
|
8
|
+
miss / correct-absence breakdown, not just accuracy.
|
|
9
|
+
|
|
10
|
+
Fully generic: no field names, document types, or domain knowledge.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from collections import Counter
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Literal
|
|
20
|
+
|
|
21
|
+
Bucket = Literal["correct_absence", "hallucination", "miss", "match", "wrong_value"]
|
|
22
|
+
|
|
23
|
+
SCORER_VERSION = "0.2.1"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ── Normalization helpers ─────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _levenshtein(a: str, b: str) -> int:
|
|
30
|
+
if len(a) < len(b):
|
|
31
|
+
return _levenshtein(b, a)
|
|
32
|
+
if not b:
|
|
33
|
+
return len(a)
|
|
34
|
+
prev = list(range(len(b) + 1))
|
|
35
|
+
for i, ca in enumerate(a):
|
|
36
|
+
curr = [i + 1]
|
|
37
|
+
for j, cb in enumerate(b):
|
|
38
|
+
curr.append(min(prev[j + 1] + 1, curr[j] + 1, prev[j] + (ca != cb)))
|
|
39
|
+
prev = curr
|
|
40
|
+
return prev[-1]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Common organizational designators. Folding trailing occurrences lets
|
|
44
|
+
# "Microsoft Corp" match "Microsoft Corporation" and "3M Co" match "3M Company"
|
|
45
|
+
# under the exact metric — a document prints the full legal form while an
|
|
46
|
+
# authoritative index (e.g. EDGAR) stores an abbreviation.
|
|
47
|
+
_ORG_SUFFIXES = {
|
|
48
|
+
"corp", "corporation", "co", "company", "inc", "incorporated", "ltd",
|
|
49
|
+
"limited", "llc", "llp", "lp", "plc", "pllc", "pc", "sa", "ag", "gmbh",
|
|
50
|
+
"nv", "bv", "ab", "oy", "spa",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _fold_org_suffix(normalized: str) -> str:
|
|
55
|
+
"""Strip trailing organizational designators from a punctuation-normalized
|
|
56
|
+
string (already lowercased, single-spaced). Returns the folded core."""
|
|
57
|
+
toks = normalized.split()
|
|
58
|
+
while toks and toks[-1] in _ORG_SUFFIXES:
|
|
59
|
+
toks.pop()
|
|
60
|
+
return " ".join(toks)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def string_similarity(a: str, b: str) -> float:
|
|
64
|
+
"""Levenshtein ratio in [0,1], case-insensitive after trimming."""
|
|
65
|
+
a = a.strip().lower()
|
|
66
|
+
b = b.strip().lower()
|
|
67
|
+
if a == b:
|
|
68
|
+
return 1.0
|
|
69
|
+
max_len = max(len(a), len(b))
|
|
70
|
+
if max_len == 0:
|
|
71
|
+
return 1.0
|
|
72
|
+
return 1.0 - _levenshtein(a, b) / max_len
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def normalize_date(value: Any) -> str | None:
|
|
76
|
+
"""Normalize to YYYY-MM-DD, or None if not a recognizable date."""
|
|
77
|
+
if not isinstance(value, str):
|
|
78
|
+
return None
|
|
79
|
+
s = value.strip()
|
|
80
|
+
m = re.match(r"^(\d{4})-(\d{1,2})-(\d{1,2})$", s)
|
|
81
|
+
if m:
|
|
82
|
+
return f"{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}"
|
|
83
|
+
m = re.match(r"^(\d{1,2})/(\d{1,2})/(\d{4})$", s)
|
|
84
|
+
if m:
|
|
85
|
+
return f"{m.group(3)}-{int(m.group(1)):02d}-{int(m.group(2)):02d}"
|
|
86
|
+
m = re.match(r"^(\d{1,2})[.\-](\d{1,2})[.\-](\d{4})$", s)
|
|
87
|
+
if m:
|
|
88
|
+
return f"{m.group(3)}-{int(m.group(1)):02d}-{int(m.group(2)):02d}"
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def to_number(value: Any) -> float | None:
|
|
93
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
94
|
+
return float(value)
|
|
95
|
+
if isinstance(value, str):
|
|
96
|
+
cleaned = value.replace("$", "").replace(",", "").strip()
|
|
97
|
+
try:
|
|
98
|
+
return float(cleaned)
|
|
99
|
+
except ValueError:
|
|
100
|
+
return None
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def is_empty(value: Any) -> bool:
|
|
105
|
+
"""None, empty string, empty list, empty dict → 'no value'."""
|
|
106
|
+
if value is None:
|
|
107
|
+
return True
|
|
108
|
+
if isinstance(value, str) and value.strip() == "":
|
|
109
|
+
return True
|
|
110
|
+
if isinstance(value, (list, dict)) and len(value) == 0:
|
|
111
|
+
return True
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _normalize_value(value: Any) -> Any:
|
|
116
|
+
if is_empty(value):
|
|
117
|
+
return None
|
|
118
|
+
if isinstance(value, bool):
|
|
119
|
+
return value
|
|
120
|
+
if isinstance(value, (int, float)):
|
|
121
|
+
return float(value)
|
|
122
|
+
if isinstance(value, str):
|
|
123
|
+
d = normalize_date(value)
|
|
124
|
+
if d is not None:
|
|
125
|
+
return d
|
|
126
|
+
n = to_number(value)
|
|
127
|
+
if n is not None:
|
|
128
|
+
return n
|
|
129
|
+
return value.strip().lower()
|
|
130
|
+
if isinstance(value, dict):
|
|
131
|
+
out: dict = {}
|
|
132
|
+
for k, v in value.items():
|
|
133
|
+
if isinstance(k, str) and k.startswith("__"): # provenance keys
|
|
134
|
+
continue
|
|
135
|
+
nv = _normalize_value(v)
|
|
136
|
+
if nv is not None:
|
|
137
|
+
out[k] = nv
|
|
138
|
+
return out or None
|
|
139
|
+
if isinstance(value, list):
|
|
140
|
+
norm = [_normalize_value(v) for v in value]
|
|
141
|
+
norm = [v for v in norm if v is not None]
|
|
142
|
+
if not norm:
|
|
143
|
+
return None
|
|
144
|
+
if all(isinstance(v, dict) for v in norm):
|
|
145
|
+
norm.sort(key=lambda v: json.dumps(v, sort_keys=True))
|
|
146
|
+
return norm
|
|
147
|
+
return value
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _normalize_for_set(items: list) -> list[str]:
|
|
151
|
+
keys = []
|
|
152
|
+
for item in items:
|
|
153
|
+
n = _normalize_value(item)
|
|
154
|
+
keys.append(json.dumps(n, sort_keys=True) if isinstance(n, dict) else json.dumps(n))
|
|
155
|
+
return keys
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _dict_key_overlap(a: dict, b: dict) -> float:
|
|
159
|
+
na = _normalize_value(a)
|
|
160
|
+
nb = _normalize_value(b)
|
|
161
|
+
if not isinstance(na, dict) or not isinstance(nb, dict):
|
|
162
|
+
return 1.0 if json.dumps(na, sort_keys=True, default=str) == json.dumps(nb, sort_keys=True, default=str) else 0.0
|
|
163
|
+
all_keys = set(na) | set(nb)
|
|
164
|
+
if not all_keys:
|
|
165
|
+
return 1.0
|
|
166
|
+
matches = sum(
|
|
167
|
+
1
|
|
168
|
+
for k in all_keys
|
|
169
|
+
if json.dumps(na.get(k), sort_keys=True, default=str) == json.dumps(nb.get(k), sort_keys=True, default=str)
|
|
170
|
+
)
|
|
171
|
+
return matches / len(all_keys)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _array_of_dicts_similarity(expected: list, actual: list) -> float:
|
|
175
|
+
if not expected:
|
|
176
|
+
return 1.0 if not actual else 0.0
|
|
177
|
+
remaining = list(range(len(actual)))
|
|
178
|
+
total = 0.0
|
|
179
|
+
for exp_item in expected:
|
|
180
|
+
best, best_idx = 0.0, -1
|
|
181
|
+
for j in remaining:
|
|
182
|
+
sim = _dict_key_overlap(exp_item, actual[j])
|
|
183
|
+
if sim > best:
|
|
184
|
+
best, best_idx = sim, j
|
|
185
|
+
total += best
|
|
186
|
+
if best_idx >= 0:
|
|
187
|
+
remaining.remove(best_idx)
|
|
188
|
+
return total / len(expected)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def array_f1(expected: list, actual: list) -> float:
|
|
192
|
+
"""Element-wise F1 in [0,1] (quality-weighted precision/recall)."""
|
|
193
|
+
if not expected and not actual:
|
|
194
|
+
return 1.0
|
|
195
|
+
if not expected or not actual:
|
|
196
|
+
return 0.0
|
|
197
|
+
if all(isinstance(v, dict) for v in expected) and all(isinstance(v, dict) for v in actual):
|
|
198
|
+
recall = _array_of_dicts_similarity(expected, actual)
|
|
199
|
+
precision = _array_of_dicts_similarity(actual, expected)
|
|
200
|
+
else:
|
|
201
|
+
exp_c = Counter(_normalize_for_set(expected))
|
|
202
|
+
act_c = Counter(_normalize_for_set(actual))
|
|
203
|
+
matched = sum((exp_c & act_c).values())
|
|
204
|
+
recall = matched / len(expected)
|
|
205
|
+
precision = matched / len(actual)
|
|
206
|
+
if precision + recall == 0:
|
|
207
|
+
return 0.0
|
|
208
|
+
return 2 * precision * recall / (precision + recall)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def resolve_mapping(value: str, mappings: dict) -> str:
|
|
212
|
+
"""Fold a value to its canonical form via schema-declared enum aliases."""
|
|
213
|
+
|
|
214
|
+
def fold(s: object) -> str:
|
|
215
|
+
return re.sub(r"\s+", " ", str(s).strip().lower())
|
|
216
|
+
|
|
217
|
+
v = fold(value)
|
|
218
|
+
for canonical, aliases in mappings.items():
|
|
219
|
+
if v == fold(canonical):
|
|
220
|
+
return canonical
|
|
221
|
+
if isinstance(aliases, list) and any(v == fold(a) for a in aliases):
|
|
222
|
+
return canonical
|
|
223
|
+
return value
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ── Result ────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass
|
|
230
|
+
class FieldResult:
|
|
231
|
+
field_name: str
|
|
232
|
+
passed: bool
|
|
233
|
+
bucket: Bucket
|
|
234
|
+
expected: Any = None
|
|
235
|
+
actual: Any = None
|
|
236
|
+
detail: str = ""
|
|
237
|
+
score: float | None = None # explicit partial credit; None → derive from `passed`
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def weighted_score(self) -> float:
|
|
241
|
+
if self.score is not None:
|
|
242
|
+
return self.score
|
|
243
|
+
return 1.0 if self.passed else 0.0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ── Core comparison ───────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def compare_field(
|
|
250
|
+
field_name: str,
|
|
251
|
+
expected: Any,
|
|
252
|
+
actual: Any,
|
|
253
|
+
fuzzy_threshold: float = 0.0,
|
|
254
|
+
mappings: dict | None = None,
|
|
255
|
+
) -> FieldResult:
|
|
256
|
+
"""Compare one expected value against one extracted value.
|
|
257
|
+
|
|
258
|
+
Four-way null semantics run first, in order:
|
|
259
|
+
1. both empty → correct_absence (PASS)
|
|
260
|
+
2. expected empty only → hallucination (FAIL)
|
|
261
|
+
3. actual empty only → miss (FAIL)
|
|
262
|
+
4. both present → type-aware compare → match / wrong_value
|
|
263
|
+
"""
|
|
264
|
+
exp_empty, act_empty = is_empty(expected), is_empty(actual)
|
|
265
|
+
|
|
266
|
+
if exp_empty and act_empty:
|
|
267
|
+
return FieldResult(field_name, True, "correct_absence", expected, actual, "correctly absent")
|
|
268
|
+
if exp_empty and not act_empty:
|
|
269
|
+
return FieldResult(field_name, False, "hallucination", expected, actual, f"hallucinated: expected null, got {actual!r}")
|
|
270
|
+
if act_empty:
|
|
271
|
+
return FieldResult(field_name, False, "miss", expected, actual, "missing from actual")
|
|
272
|
+
|
|
273
|
+
def matched(detail: str = "", score: float | None = None) -> FieldResult:
|
|
274
|
+
return FieldResult(field_name, True, "match", expected, actual, detail, score)
|
|
275
|
+
|
|
276
|
+
def wrong(detail: str, score: float | None = None) -> FieldResult:
|
|
277
|
+
return FieldResult(field_name, False, "wrong_value", expected, actual, detail, score)
|
|
278
|
+
|
|
279
|
+
# Date
|
|
280
|
+
ed, ad = normalize_date(expected), normalize_date(actual)
|
|
281
|
+
if ed is not None and ad is not None:
|
|
282
|
+
return matched() if ed == ad else wrong(f"expected {ed}, got {ad}")
|
|
283
|
+
|
|
284
|
+
# Number (absolute tolerance 0.01 — cent-exact by design)
|
|
285
|
+
en, an = to_number(expected), to_number(actual)
|
|
286
|
+
if en is not None and an is not None:
|
|
287
|
+
return matched() if round(abs(en - an), 10) <= 0.01 else wrong(f"expected {expected}, got {actual}")
|
|
288
|
+
|
|
289
|
+
# Array (element-wise F1; strict pass = exact-set, else fuzzy_threshold)
|
|
290
|
+
if isinstance(expected, list) and isinstance(actual, list):
|
|
291
|
+
f1 = array_f1(expected, actual)
|
|
292
|
+
exp_keys = sorted(_normalize_for_set(expected))
|
|
293
|
+
act_keys = sorted(_normalize_for_set(actual))
|
|
294
|
+
if exp_keys == act_keys:
|
|
295
|
+
return matched(score=1.0)
|
|
296
|
+
if fuzzy_threshold > 0 and expected:
|
|
297
|
+
matched_n = sum(1 for k in exp_keys if k in act_keys)
|
|
298
|
+
ratio = matched_n / len(exp_keys)
|
|
299
|
+
if ratio >= fuzzy_threshold:
|
|
300
|
+
return matched(f"fuzzy array match ({matched_n}/{len(exp_keys)})", score=f1)
|
|
301
|
+
if (
|
|
302
|
+
len(expected) == len(actual)
|
|
303
|
+
and all(isinstance(v, dict) for v in expected)
|
|
304
|
+
and all(isinstance(v, dict) for v in actual)
|
|
305
|
+
and _array_of_dicts_similarity(expected, actual) >= fuzzy_threshold
|
|
306
|
+
):
|
|
307
|
+
return matched("fuzzy structural match", score=f1)
|
|
308
|
+
return wrong(f"array items differ ({f1:.0%} element F1)", score=f1)
|
|
309
|
+
|
|
310
|
+
# Scalar / string
|
|
311
|
+
if isinstance(expected, str) and isinstance(actual, str):
|
|
312
|
+
e, a = expected, actual
|
|
313
|
+
if mappings:
|
|
314
|
+
e, a = resolve_mapping(e, mappings), resolve_mapping(a, mappings)
|
|
315
|
+
if e.strip().lower() == a.strip().lower():
|
|
316
|
+
return matched()
|
|
317
|
+
ep = re.sub(r"[^a-z0-9]+", " ", e.lower()).strip()
|
|
318
|
+
ea = re.sub(r"[^a-z0-9]+", " ", a.lower()).strip()
|
|
319
|
+
if ep and ep == ea:
|
|
320
|
+
return matched() # punctuation-only difference
|
|
321
|
+
ef, af = _fold_org_suffix(ep), _fold_org_suffix(ea)
|
|
322
|
+
if ef and ef == af:
|
|
323
|
+
# equal once trailing org designators (Corp/Corporation/Co/…) are folded;
|
|
324
|
+
# ef non-empty guards against two bare designators folding to nothing
|
|
325
|
+
return matched("organizational-suffix-equivalent")
|
|
326
|
+
if fuzzy_threshold > 0 and string_similarity(e, a) >= fuzzy_threshold:
|
|
327
|
+
return matched(f"fuzzy match ({string_similarity(e, a):.0%})")
|
|
328
|
+
return wrong(f"expected {expected!r}, got {actual!r}")
|
|
329
|
+
|
|
330
|
+
# Type mismatch or other → strict equality fallback
|
|
331
|
+
if _normalize_value(expected) == _normalize_value(actual):
|
|
332
|
+
return matched()
|
|
333
|
+
return wrong(f"expected {expected!r}, got {actual!r}")
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fieldbench
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: A cross-domain, field-level benchmark for schema-driven document extraction
|
|
5
|
+
Author: Frank Thomas
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/fieldbench/fieldbench
|
|
8
|
+
Project-URL: Corpus, https://github.com/fieldbench/corpus
|
|
9
|
+
Keywords: benchmark,document-extraction,information-extraction,evaluation,nlp
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: pyyaml>=6
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
22
|
+
Provides-Extra: openai
|
|
23
|
+
Requires-Dist: openai>=1.0; extra == "openai"
|
|
24
|
+
Provides-Extra: anthropic
|
|
25
|
+
Requires-Dist: anthropic>=0.30; extra == "anthropic"
|
|
26
|
+
Provides-Extra: llamaindex
|
|
27
|
+
Requires-Dist: llama-index-core>=0.11; extra == "llamaindex"
|
|
28
|
+
Requires-Dist: llama-index-llms-openai>=0.2; extra == "llamaindex"
|
|
29
|
+
Provides-Extra: langchain
|
|
30
|
+
Requires-Dist: langchain-openai>=0.2; extra == "langchain"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# FieldBench
|
|
34
|
+
|
|
35
|
+
A cross-domain, field-level benchmark for **schema-driven document extraction** — and the canonical scorer that goes with it.
|
|
36
|
+
|
|
37
|
+
Every document-extraction vendor claims 95%+ accuracy; almost none publish how they measure it. FieldBench makes extraction accuracy **falsifiable and comparable**: a shared corpus, a shared type-aware scorer, and a leaderboard anyone can submit to.
|
|
38
|
+
|
|
39
|
+
> **Status: alpha (v0.1).** The scorer is the first piece to land. The corpus lives at [`fieldbench/corpus`](https://github.com/fieldbench/corpus). Leaderboard and HuggingFace packaging are in progress.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install fieldbench # once published; for now: pip install -e .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Run a baseline
|
|
48
|
+
|
|
49
|
+
`fieldbench run` drives any extractor over the corpus and writes the prediction files for you. It's extractor-agnostic — the whole integration surface is a `complete(prompt) -> str` callable (see `examples/openai_runner.py`):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install "fieldbench[openai]"
|
|
53
|
+
OPENAI_API_KEY=... FIELDBENCH_MODEL=gpt-4o-mini \
|
|
54
|
+
fieldbench run --corpus ./corpus --out ./preds/gpt-4o-mini \
|
|
55
|
+
--runner examples.openai_runner:make_runner
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Runs are **resumable** (existing predictions are skipped) and **never invent results** — a runner error writes nothing, so that document is scored as a real miss, not dropped.
|
|
59
|
+
|
|
60
|
+
## Score your system
|
|
61
|
+
|
|
62
|
+
Score prediction files — a flat `{field: value}` JSON named `<doc_id>.json` per document (produced by `fieldbench run` or by your own pipeline):
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
fieldbench score --corpus /path/to/corpus --results /path/to/predictions/
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
You get overall accuracy, the **all-null floor** (what an empty extractor scores for free), a **real-vs-synthetic** split, a **four-way outcome breakdown**, and a per-category table. `--json` emits the full report; `--category <name>` scopes to one category.
|
|
69
|
+
|
|
70
|
+
## What makes the scoring type-aware
|
|
71
|
+
|
|
72
|
+
`compare_field` runs **four-way null semantics** first — the thing plain accuracy hides:
|
|
73
|
+
|
|
74
|
+
| Outcome | Meaning |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `correct_absence` | field absent in GT, system correctly returned null |
|
|
77
|
+
| `hallucination` | field absent in GT, system invented a value |
|
|
78
|
+
| `miss` | field present in GT, system returned null |
|
|
79
|
+
| `match` / `wrong_value` | both present → type-aware comparison |
|
|
80
|
+
|
|
81
|
+
For present-vs-present it is tolerant where it should be and strict where it must be: numeric with cent-exact tolerance, date normalization, order-independent array F1 (partial credit), punctuation-insensitive strings, and schema enum-alias folding — but never masking a genuine content difference.
|
|
82
|
+
|
|
83
|
+
## How to cite
|
|
84
|
+
|
|
85
|
+
```bibtex
|
|
86
|
+
@misc{fieldbench,
|
|
87
|
+
title = {FieldBench: A Cross-Domain Benchmark for Schema-Driven Document Extraction},
|
|
88
|
+
author = {Thomas, Frank},
|
|
89
|
+
year = {2026},
|
|
90
|
+
note = {https://github.com/fieldbench}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
<!-- Replaced with the Zenodo DOI on release, and the paper citation once published. -->
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pip install -e ".[dev]"
|
|
99
|
+
pytest # golden scorer tests
|
|
100
|
+
ruff check .
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The golden tests in `tests/` are the anti-drift contract: they pin the canonical scoring semantics that any conforming implementation (including Koji's) must match.
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
Code: MIT. The corpus is licensed per-source — see [`fieldbench/corpus`](https://github.com/fieldbench/corpus).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
fieldbench/__init__.py,sha256=Uecv5-tI8eUfS0sCqm588502nAYrgLRWTHW9atp1p4w,575
|
|
2
|
+
fieldbench/aggregate.py,sha256=M4LymQ_Smw3QYPw4IUdFz-XPcot8U-Ai9yPXQy18rsk,4039
|
|
3
|
+
fieldbench/cli.py,sha256=TF0VZkx8oFSCamK692mTQyBVCbt9iFKMZKaFtOFfFew,5724
|
|
4
|
+
fieldbench/corpus.py,sha256=moljxHWUFPTTHpme8eEWH1hUz70iDqwYHvm3rUn-_EI,6341
|
|
5
|
+
fieldbench/run.py,sha256=IMacKpXZ2Rc-rrHhfCVqJMiWfJBRoa8oOFC33n6gG_M,8022
|
|
6
|
+
fieldbench/scoring.py,sha256=04EI2lBHYNa25lWiZZ6HTBfjoGYN4sfgXUoLZBrR_Nk,12491
|
|
7
|
+
fieldbench-0.2.1.dist-info/licenses/LICENSE,sha256=AVla6qeNWJpcwjF6XMS3ONSL978yto6oWyt6H6FrAUc,1069
|
|
8
|
+
fieldbench-0.2.1.dist-info/METADATA,sha256=zdgm7jXN5MFo0bEZ4dwI7P5nu0A2Y9mBpVKMcCfPyLg,4547
|
|
9
|
+
fieldbench-0.2.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
10
|
+
fieldbench-0.2.1.dist-info/entry_points.txt,sha256=qqwfIOAprmTIt1iJEO2VB6er7ov0CI537c0kGvzWWus,51
|
|
11
|
+
fieldbench-0.2.1.dist-info/top_level.txt,sha256=cMN0klfFO_qUf3aHFLI5KfZhHI3W2AUC5EaNJEywing,11
|
|
12
|
+
fieldbench-0.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Frank Thomas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fieldbench
|