extractscore 0.1.0__tar.gz

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.
Files changed (57) hide show
  1. extractscore-0.1.0/.github/ISSUE_TEMPLATE/bug.yml +22 -0
  2. extractscore-0.1.0/.github/ISSUE_TEMPLATE/question.yml +11 -0
  3. extractscore-0.1.0/.github/workflows/ci.yml +29 -0
  4. extractscore-0.1.0/.gitignore +38 -0
  5. extractscore-0.1.0/CHANGELOG.md +24 -0
  6. extractscore-0.1.0/LICENSE +21 -0
  7. extractscore-0.1.0/PKG-INFO +261 -0
  8. extractscore-0.1.0/README.ko.md +239 -0
  9. extractscore-0.1.0/README.md +239 -0
  10. extractscore-0.1.0/SECURITY.md +7 -0
  11. extractscore-0.1.0/docs/images/01-score.ko.png +0 -0
  12. extractscore-0.1.0/docs/images/01-score.png +0 -0
  13. extractscore-0.1.0/docs/images/02-gate.ko.png +0 -0
  14. extractscore-0.1.0/docs/images/02-gate.png +0 -0
  15. extractscore-0.1.0/docs/images/03-diff.ko.png +0 -0
  16. extractscore-0.1.0/docs/images/03-diff.png +0 -0
  17. extractscore-0.1.0/examples/ci.yml +24 -0
  18. extractscore-0.1.0/examples/compare_runs.py +16 -0
  19. extractscore-0.1.0/examples/quickstart.py +13 -0
  20. extractscore-0.1.0/pyproject.toml +43 -0
  21. extractscore-0.1.0/src/extractscore/__init__.py +37 -0
  22. extractscore-0.1.0/src/extractscore/__main__.py +5 -0
  23. extractscore-0.1.0/src/extractscore/align.py +111 -0
  24. extractscore-0.1.0/src/extractscore/cli.py +255 -0
  25. extractscore-0.1.0/src/extractscore/compare.py +167 -0
  26. extractscore-0.1.0/src/extractscore/config.py +176 -0
  27. extractscore-0.1.0/src/extractscore/diff.py +145 -0
  28. extractscore-0.1.0/src/extractscore/errors.py +32 -0
  29. extractscore-0.1.0/src/extractscore/gate.py +43 -0
  30. extractscore-0.1.0/src/extractscore/i18n.py +290 -0
  31. extractscore-0.1.0/src/extractscore/io.py +79 -0
  32. extractscore-0.1.0/src/extractscore/metrics.py +119 -0
  33. extractscore-0.1.0/src/extractscore/py.typed +0 -0
  34. extractscore-0.1.0/src/extractscore/render.py +144 -0
  35. extractscore-0.1.0/src/extractscore/sample/README.md +29 -0
  36. extractscore-0.1.0/src/extractscore/sample/docs/inv-001.txt +10 -0
  37. extractscore-0.1.0/src/extractscore/sample/docs/inv-002.txt +9 -0
  38. extractscore-0.1.0/src/extractscore/sample/docs/inv-003.txt +9 -0
  39. extractscore-0.1.0/src/extractscore/sample/docs/rcp-001.txt +8 -0
  40. extractscore-0.1.0/src/extractscore/sample/docs/rcp-002.txt +9 -0
  41. extractscore-0.1.0/src/extractscore/sample/docs/rcp-003.txt +8 -0
  42. extractscore-0.1.0/src/extractscore/sample/fields.toml +27 -0
  43. extractscore-0.1.0/src/extractscore/sample/gold.jsonl +6 -0
  44. extractscore-0.1.0/src/extractscore/sample/pred_a.jsonl +6 -0
  45. extractscore-0.1.0/src/extractscore/sample/pred_b.jsonl +6 -0
  46. extractscore-0.1.0/src/extractscore/score.py +334 -0
  47. extractscore-0.1.0/tests/conftest.py +52 -0
  48. extractscore-0.1.0/tests/test_align.py +82 -0
  49. extractscore-0.1.0/tests/test_cli.py +101 -0
  50. extractscore-0.1.0/tests/test_compare.py +118 -0
  51. extractscore-0.1.0/tests/test_diff.py +63 -0
  52. extractscore-0.1.0/tests/test_i18n.py +146 -0
  53. extractscore-0.1.0/tests/test_metrics.py +49 -0
  54. extractscore-0.1.0/tests/test_packaging.py +38 -0
  55. extractscore-0.1.0/tests/test_robustness.py +87 -0
  56. extractscore-0.1.0/tests/test_score.py +81 -0
  57. extractscore-0.1.0/uv.lock +79 -0
@@ -0,0 +1,22 @@
1
+ name: 버그 / Bug
2
+ description: README대로 했는데 다르게 동작해요 / Something does not work as the README says
3
+ labels: [bug]
4
+ body:
5
+ - type: textarea
6
+ id: what
7
+ attributes:
8
+ label: 무엇이 일어났나요? / What happened?
9
+ description: 실행한 명령과 나온 메시지를 그대로 붙여 주세요. / Paste the command and the output.
10
+ validations:
11
+ required: true
12
+ - type: textarea
13
+ id: expected
14
+ attributes:
15
+ label: 기대한 결과 / Expected
16
+ - type: input
17
+ id: env
18
+ attributes:
19
+ label: 환경 / Environment
20
+ placeholder: "macOS 15 (M3) · Ubuntu 24.04 · Windows 11 / uv 0.x / mock or real mode"
21
+ validations:
22
+ required: true
@@ -0,0 +1,11 @@
1
+ name: 질문 / Question
2
+ description: 이해가 안 되는 부분이나 더 알고 싶은 점 / Something unclear or worth explaining
3
+ labels: [question]
4
+ body:
5
+ - type: textarea
6
+ id: question
7
+ attributes:
8
+ label: 질문 / Question
9
+ description: README의 어느 부분(절 제목)에 대한 질문인지 알려 주세요. / Which README section is this about?
10
+ validations:
11
+ required: true
@@ -0,0 +1,29 @@
1
+ # Tests need no keys and no network: scoring is local and deterministic.
2
+ name: ci
3
+ on:
4
+ push:
5
+ pull_request:
6
+ schedule:
7
+ - cron: "17 3 * * 1" # every Monday - catch breakage from new Python releases
8
+ workflow_dispatch:
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ python-version: ["3.11", "3.13"]
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: astral-sh/setup-uv@v10.3.3
19
+ - run: uv python install ${{ matrix.python-version }}
20
+ - run: uv sync --locked --python ${{ matrix.python-version }}
21
+ - run: uv run --python ${{ matrix.python-version }} pytest -q
22
+ - run: uv run --python ${{ matrix.python-version }} extractscore demo --lang en
23
+ build:
24
+ runs-on: ubuntu-latest
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ - uses: astral-sh/setup-uv@v10.3.3
28
+ - run: uv build
29
+ - run: uv run --isolated --no-project --with ./dist/extractscore-0.1.0-py3-none-any.whl extractscore demo --lang en
@@ -0,0 +1,38 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .mypy_cache/
8
+ *.egg-info/
9
+
10
+ # Node
11
+ node_modules/
12
+ dist/
13
+ .next/
14
+
15
+ # 실행 산출물·모델·데이터 캐시
16
+ runs/
17
+ outputs/
18
+ .cache/
19
+ *.safetensors
20
+ *.gguf
21
+ *.bin
22
+
23
+ # 환경·비밀
24
+ .env
25
+ .env.*
26
+ !.env.example
27
+
28
+ # OS·에디터
29
+ .DS_Store
30
+ .vscode/
31
+ .idea/
32
+
33
+ # Flutter·Dart
34
+ .dart_tool/
35
+ build/
36
+ .flutter-plugins
37
+ .flutter-plugins-dependencies
38
+ coverage/
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.1.0] - 2026-11-14
9
+
10
+ ### Added
11
+ - Field-level scoring of LLM-extracted JSON against a gold JSONL: per-field TP/FP/FN, precision, recall, F1, micro and macro aggregates.
12
+ - Error categories: `wrong_value`, `type_error`, `missing`, `hallucinated`, `parse_failure`.
13
+ - Typed comparators (`number`, `date`, `text`, `id`, `enum`, `bool`) with conservative type inference; ids and amounts are exact by default, fuzzy text matching only where configured.
14
+ - Order-independent pairing of object lists (Hungarian algorithm, pure Python), multiset comparison of scalar lists, nested lists.
15
+ - `diff` between two runs: per-field change, improved / regressed / same, and the documents whose fields flipped.
16
+ - CLI `extractscore score | diff | demo` with table, JSON and Markdown output, CI gates (`--min-f1`, `--field-min`, `--fail-on-regression`) and exit codes 0 / 1 / 2.
17
+ - English and Korean output (`--lang`, `EXTRACTSCORE_LANG`, `LC_ALL`/`LC_MESSAGES`/`LANG`).
18
+ - TOML or JSON field-rule configuration; `ignore = true` fields are reported as skipped.
19
+ - Python API: `evaluate`, `evaluate_files`, `compare`, `diff`, `check_gates`, `Config`, `FieldRule`, `load_jsonl`.
20
+ - Bundled practice mock (hand-written predictions, no model is called): `extractscore demo`, `demo --export DIR`.
21
+ - Zero runtime dependencies; Python 3.11 or newer.
22
+
23
+ [Unreleased]: https://github.com/LeeGyeongPil/extractscore/compare/v0.1.0...HEAD
24
+ [0.1.0]: https://github.com/LeeGyeongPil/extractscore/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LeeGyeongPil
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,261 @@
1
+ Metadata-Version: 2.5
2
+ Name: extractscore
3
+ Version: 0.1.0
4
+ Summary: Field-level precision/recall/F1 for LLM-extracted JSON, with before/after run comparison and CI gates.
5
+ Project-URL: Homepage, https://github.com/LeeGyeongPil/extractscore
6
+ Project-URL: Issues, https://github.com/LeeGyeongPil/extractscore/issues
7
+ Project-URL: Changelog, https://github.com/LeeGyeongPil/extractscore/blob/main/CHANGELOG.md
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: evaluation,information-extraction,json,llm,precision-recall,structured-output
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Testing
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+
23
+ # extractscore
24
+
25
+ **English** | [한국어](https://github.com/LeeGyeongPil/extractscore/blob/v0.1.0/README.ko.md)
26
+
27
+ Field-level precision, recall and F1 for JSON that an LLM extracted from documents (invoices, receipts, contracts), plus a before/after comparison of two runs on the same gold set. It is a Python library with a matching CLI, has zero runtime dependencies, and needs Python 3.11 or newer.
28
+
29
+ ![Per-field score table for the practice mock](https://raw.githubusercontent.com/LeeGyeongPil/extractscore/v0.1.0/docs/images/01-score.png)
30
+
31
+ *Per-field TP/FP/FN, precision, recall, F1 and error categories, scored against a gold JSONL.*
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ uv add git+https://github.com/LeeGyeongPil/extractscore # or: pip install git+https://github.com/LeeGyeongPil/extractscore
37
+ ```
38
+
39
+ To try it from a clone instead: `git clone https://github.com/LeeGyeongPil/extractscore && cd extractscore && uv run extractscore demo`. If you do not have uv: `curl -LsSf https://astral.sh/uv/install.sh | sh`.
40
+
41
+ Python 3.11+ (it reads TOML with the standard library). No runtime dependencies.
42
+
43
+ ## Quick start
44
+
45
+ The package ships a **practice mock**: six made-up invoices and receipts, the gold answers, and two sets of predictions that were **written by hand** to imitate typical LLM mistakes. No model is called, so it works offline. Your own data works the same way, with your gold file and your model's output.
46
+
47
+ ```bash
48
+ uv run extractscore demo # inside a uv project or a clone; with pip: extractscore demo
49
+ ```
50
+
51
+ That prints the score of run A, then the A to B comparison. To score one run yourself:
52
+
53
+ ```bash
54
+ uv run extractscore demo --export my-eval # copies gold, predictions, rules and source texts
55
+ uv run extractscore score my-eval/gold.jsonl my-eval/pred_a.jsonl --config my-eval/fields.toml
56
+ ```
57
+
58
+ ```text
59
+ Field Type TP FP FN Precision Recall F1 Errors
60
+ ------------------ ------ -- -- -- --------- ------ ----- --------------
61
+ date date 6 0 0 1.000 1.000 1.000 -
62
+ discount number 0 1 0 0.000 n/a 0.000 hallucinated=1
63
+ invoice_no id 2 1 1 0.667 0.667 0.667 wrong_value=1
64
+ line_items[].desc text 16 0 0 1.000 1.000 1.000 -
65
+ line_items[].price number 16 0 0 1.000 1.000 1.000 -
66
+ line_items[].qty number 13 3 3 0.812 0.812 0.812 wrong_value=3
67
+ payment_method enum 3 0 0 1.000 1.000 1.000 -
68
+ receipt_no id 3 0 0 1.000 1.000 1.000 -
69
+ total number 5 1 1 0.833 0.833 0.833 type_error=1
70
+ vendor.name text 6 0 0 1.000 1.000 1.000 -
71
+ vendor.tax_id id 1 0 2 1.000 0.333 0.500 missing=2
72
+ Micro (all fields) 71 6 7 0.922 0.910 0.916
73
+
74
+ Macro F1 (mean of field F1): 0.801
75
+ Errors overall: hallucinated=1, missing=2, type_error=1, wrong_value=4
76
+ Skipped - not scored, and not counted as passing: notes
77
+ ```
78
+
79
+ (The command also prints a header, the most-wrong documents and a short error-category legend; the table above is the core of it. For Korean output add `--lang ko`.)
80
+
81
+ Read it like this: `total` has 5 correct values (TP), 1 wrong value (one FP and one FN), so precision and recall are both 5/6. `vendor.tax_id` was never wrong when present, but the model left it out twice, so precision is 1.0 and recall 0.33.
82
+
83
+ ### Your own files
84
+
85
+ Gold file, one JSON object per line, with an `id` and the correct fields:
86
+
87
+ ```json
88
+ {"id": "inv-001", "invoice_no": "INV-0042", "total": 1250.0, "line_items": [{"desc": "Widget A", "qty": 2, "price": 300.0}]}
89
+ ```
90
+
91
+ The prediction file has the same shape, one line per document, using the same `id`. A line that is not valid JSON (for example a truncated response) is scored as a parse failure for that document, it does not stop the run.
92
+
93
+ ### Compare two runs and gate CI
94
+
95
+ ```bash
96
+ extractscore diff gold.jsonl pred_before.jsonl pred_after.jsonl --config fields.toml --fail-on-regression
97
+ extractscore score gold.jsonl pred.jsonl --config fields.toml --min-f1 0.95
98
+ ```
99
+
100
+ Exit code 0 means pass, 1 means a gate failed, 2 means the input or config is invalid. A ready GitHub Actions workflow is in [`examples/ci.yml`](https://github.com/LeeGyeongPil/extractscore/blob/v0.1.0/examples/ci.yml). It calls `uvx extractscore`, which works once the package is on PyPI; until then use `uvx --from git+https://github.com/LeeGyeongPil/extractscore extractscore ...`.
101
+
102
+ ![A gate failing on five fields](https://raw.githubusercontent.com/LeeGyeongPil/extractscore/v0.1.0/docs/images/02-gate.png)
103
+
104
+ *`--min-f1 0.95` lists every field below the bar and exits with code 1.*
105
+
106
+ ![A to B comparison](https://raw.githubusercontent.com/LeeGyeongPil/extractscore/v0.1.0/docs/images/03-diff.png)
107
+
108
+ *`diff` shows each field's F1 before and after, and which documents flipped between correct and wrong.*
109
+
110
+ Run B in the mock fixes `line_items[].qty` and `vendor.tax_id` but makes `date` and `invoice_no` worse. Most of the 7 regressed fields come from one cut-off JSON line (`rcp-003`): one broken response lowers every field of that document. That is why the comparison lists the flipped documents, not only the totals.
111
+
112
+ ## Core concepts
113
+
114
+ ```mermaid
115
+ flowchart LR
116
+ G[gold.jsonl] --> W[walk fields]
117
+ P[pred.jsonl] --> W
118
+ C[fields.toml] --> W
119
+ W --> S[TP / FP / FN per field]
120
+ S --> R[precision, recall, F1]
121
+ R --> D[diff A vs B]
122
+ R --> X[CI exit code]
123
+ ```
124
+
125
+ - **Field path.** Nested objects become `vendor.name`; items in a list become `line_items[].qty`. Every path is scored on its own.
126
+ - **TP / FP / FN.** A correct value is a TP. A wrong value or a value that cannot be read as the field type counts as one FP and one FN. A value the model left out is an FN. A value the gold does not have is an FP. `n/a` means 0/0.
127
+ - **Error categories.** `wrong_value`, `type_error`, `missing`, `hallucinated`, `parse_failure`. They tell you what kind of mistake it was, not only how many.
128
+ - **Typed comparison.** `number`, `date`, `text`, `id`, `enum`, `bool`. `"1,250.00"`, `"$1,250"` and `1250` are the same number. Ids are always exact: `INV-215` is not `INV-0215`. Fuzzy text matching is on only for the text fields you configure.
129
+ - **List items.** Items are paired by content, not by position (Hungarian algorithm, pure Python), so a reordered list is still correct. An item that matches nothing counts as one missing plus one hallucinated.
130
+
131
+ Field rules (`fields.toml`, or JSON). Fields you do not list get a type inferred from the gold value:
132
+
133
+ ```toml
134
+ date_formats = ["%Y-%m-%d", "%m/%d/%Y"] # first match wins
135
+
136
+ [fields."total"]
137
+ type = "number"
138
+ tol = 0.01
139
+
140
+ [fields."line_items[].desc"]
141
+ type = "text"
142
+ fuzzy = 0.85 # minimum similarity that still counts as a match
143
+
144
+ [fields."notes"]
145
+ ignore = true # reported as skipped, never as passing
146
+ ```
147
+
148
+ ## API
149
+
150
+ ```python
151
+ from extractscore import evaluate_files, Config
152
+ ```
153
+
154
+ | Name | What it does | Main arguments | Returns |
155
+ |---|---|---|---|
156
+ | `evaluate(gold, pred, config=None, *, id_key="id")` | Score a whole dataset held in memory | rows or `{id: dict}` | `Report` |
157
+ | `evaluate_files(gold_path, pred_path, config=None, *, id_key="id")` | Same, reading two JSONL files | paths | `Report` |
158
+ | `compare(gold, pred, config=None)` | Score one document | two dicts | `DocResult` |
159
+ | `diff(report_a, report_b, *, tolerance=0.0)` | Compare two reports from the same gold set | reports, allowed F1 drop | `Diff` (`.regressions`, `.improvements`, `.flips`) |
160
+ | `check_gates(report, *, min_f1=None, field_min=None)` | CI thresholds, overall and per field | `min_f1`, `{path: min}` | `list[GateFailure]` (empty means pass) |
161
+ | `Config.from_file(path)` / `Config.from_dict(data)` / `Config(...)` | Field rules | `fields`, `match_threshold`, `date_formats`, `empty_as_null` | `Config` |
162
+ | `FieldRule(type, tol, fuzzy, casefold, ignore)` | Rule for one field path | | `FieldRule` |
163
+ | `load_jsonl(path, *, strict, id_key="id")` | Read a JSONL file; with `strict=False`, broken lines become `ParseFailure` | path | `dict` of id to row |
164
+
165
+ `Report` has `.fields` (per-path `FieldStats` with `.precision`, `.recall`, `.f1`, `.counts`), `.micro`, `.macro_f1`, `.parse_failures`, `.skipped`, `.top_docs(n)`, `.to_dict()` and `.to_markdown(lang)`. Counts expose `.tp`, `.fp`, `.fn` and `.errors` (category to number). A precision or recall of 0/0 is `None`.
166
+
167
+ Errors: `InputError` (the gold data or a file cannot be read), `ConfigError` (invalid field rules), both subclasses of `ExtractScoreError`. Problems in the **predictions** are never exceptions; they are scored.
168
+
169
+ ## Usage examples
170
+
171
+ Both files below are in [`examples/`](https://github.com/LeeGyeongPil/extractscore/blob/v0.1.0/examples/) and were run as written.
172
+
173
+ **Score one run** (`examples/quickstart.py`):
174
+
175
+ ```python
176
+ from pathlib import Path
177
+
178
+ import extractscore
179
+ from extractscore import Config, evaluate_files
180
+
181
+ sample = Path(extractscore.__file__).parent / "sample"
182
+ config = Config.from_file(sample / "fields.toml")
183
+ report = evaluate_files(sample / "gold.jsonl", sample / "pred_a.jsonl", config)
184
+
185
+ total = report.fields["total"]
186
+ print(f"total: P={total.precision:.3f} R={total.recall:.3f} F1={total.f1:.3f}")
187
+ print("type_error:", total.counts.errors["type_error"])
188
+ print("micro F1:", round(report.micro.f1, 3))
189
+ ```
190
+
191
+ ```text
192
+ total: P=0.833 R=0.833 F1=0.833
193
+ type_error: 1
194
+ micro F1: 0.916
195
+ ```
196
+
197
+ **Compare two runs and check gates** (`examples/compare_runs.py`):
198
+
199
+ ```python
200
+ from pathlib import Path
201
+
202
+ import extractscore
203
+ from extractscore import Config, check_gates, diff, evaluate_files
204
+
205
+ sample = Path(extractscore.__file__).parent / "sample"
206
+ config = Config.from_file(sample / "fields.toml")
207
+ gold = sample / "gold.jsonl"
208
+ a = evaluate_files(gold, sample / "pred_a.jsonl", config)
209
+ b = evaluate_files(gold, sample / "pred_b.jsonl", config)
210
+
211
+ d = diff(a, b)
212
+ print("regressed:", ", ".join(d.regressions))
213
+ print("improved:", ", ".join(d.improvements))
214
+ for failure in check_gates(b, min_f1=0.9, field_min={"total": 0.99}):
215
+ print(f"FAIL {failure.path}: F1 {failure.f1:.3f} < {failure.minimum:.2f}")
216
+ ```
217
+
218
+ ```text
219
+ regressed: date, invoice_no, line_items[].desc, line_items[].price, payment_method, receipt_no, vendor.name
220
+ improved: line_items[].qty, total, vendor.tax_id
221
+ FAIL date: F1 0.727 < 0.90
222
+ FAIL discount: F1 0.000 < 0.90
223
+ FAIL invoice_no: F1 0.333 < 0.90
224
+ FAIL payment_method: F1 0.800 < 0.90
225
+ FAIL receipt_no: F1 0.800 < 0.90
226
+ FAIL total: F1 0.909 < 0.99
227
+ ```
228
+
229
+ **Other output formats.** `--format json` (English keys, stable for scripts) and `--format md` (a Markdown table for a CI job summary), both with `--out FILE`.
230
+
231
+ **Language.** Output is English or Korean: `--lang en|ko`, else `EXTRACTSCORE_LANG`, else `LC_ALL`/`LC_MESSAGES`/`LANG`; anything not starting with `ko` is English. JSON keys do not change.
232
+
233
+ ## When to use it
234
+
235
+ - Good fit: you extract JSON from documents with an LLM and want field-level precision/recall, a before/after comparison on the same gold set, and a CI gate. Scoring is deterministic and calls no model.
236
+ - Out of scope: it does not run your extraction, call a model, or judge free-text quality. It compares JSON to a gold file you provide.
237
+ - Other tools may suit you better when you need a general LLM evaluation pipeline ([promptfoo](https://github.com/promptfoo/promptfoo), [DeepEval](https://deepeval.com/docs/metrics-json-correctness), [Pydantic Evals](https://pydantic.dev/docs/ai/evals/evaluators/built-in/)), a document-QA similarity score ([anls_star](https://pypi.org/project/anls-star/)), or a benchmark for structured extraction ([ExtractBench](https://github.com/ContextualAI/extract-bench)). This project is narrower: it scores one thing, typed JSON fields with list alignment, and compares two runs. Their features change, so check their docs rather than this page.
238
+
239
+ ## Limits
240
+
241
+ Read these before you trust a number.
242
+
243
+ - **Slash dates are month first.** By default `11/03/2026` is read as 2026-11-03. If your documents write day first, put that format first in `fields.toml`: `date_formats = ["%d/%m/%Y", "%Y-%m-%d"]`. The first format that parses wins, so an ambiguous date follows the order you give.
244
+ - **European decimal commas are not understood in 0.1.0.** `"1.250,00"` is not read as 1250, so a correct value is counted as `wrong_value`. Normalise such values before scoring.
245
+ - **A broken prediction line without a recoverable `id` is dropped.** If the line is cut off but the `id` can still be found, the document is scored as `parse_failure`. If it cannot, that document is scored as having no prediction at all.
246
+ - **Big lists are slower.** List items are paired with a pure-Python Hungarian algorithm (cubic time). A warning appears above 500 unmatched items in one list; a 600-item list took roughly 2 to 9 s across runs on the test machine.
247
+
248
+ ## Version and compatibility
249
+
250
+ - Follows [Semantic Versioning](https://semver.org/). While the version is 0.x, the API can change in a minor release, and any change is listed in [CHANGELOG.md](https://github.com/LeeGyeongPil/extractscore/blob/v0.1.0/CHANGELOG.md).
251
+
252
+ ## Development
253
+
254
+ ```bash
255
+ git clone https://github.com/LeeGyeongPil/extractscore && cd extractscore
256
+ uv run pytest
257
+ ```
258
+
259
+ ## License
260
+
261
+ MIT.
@@ -0,0 +1,239 @@
1
+ # extractscore
2
+
3
+ [English](README.md) | **한국어**
4
+
5
+ 문서(송장·영수증·계약서)에서 LLM 으로 뽑은 JSON 의 정확도를 필드 단위 정밀도·재현율·F1 로 재고, 같은 정답 세트로 두 번의 실행(모델·프롬프트 변경 전후)을 비교하는 Python 라이브러리와 CLI 입니다. 런타임 의존성이 없고 Python 3.11 이상이 필요합니다.
6
+
7
+ ![연습용 목의 필드별 점수 표](docs/images/01-score.ko.png)
8
+
9
+ *정답 JSONL 과 비교한 필드별 TP/FP/FN, 정밀도, 재현율, F1, 오류 범주.*
10
+
11
+ ## 설치
12
+
13
+ ```bash
14
+ uv add git+https://github.com/LeeGyeongPil/extractscore # 또는: pip install git+https://github.com/LeeGyeongPil/extractscore
15
+ ```
16
+
17
+ 프로젝트에 추가하지 않고 clone 해서 써 보려면: `git clone https://github.com/LeeGyeongPil/extractscore && cd extractscore && uv run extractscore demo`. uv 가 없다면: `curl -LsSf https://astral.sh/uv/install.sh | sh`.
18
+
19
+ Python 3.11 이상(표준 라이브러리로 TOML 을 읽습니다). 런타임 의존성 없음.
20
+
21
+ ## 빠르게 시작하기
22
+
23
+ 패키지에는 **연습용 목(mock)** 이 들어 있습니다. 지어낸 송장·영수증 6건, 정답, 그리고 LLM 이 흔히 하는 실수를 흉내 내 **손으로 쓴** 예측 두 벌입니다. 모델을 호출하지 않으므로 오프라인에서 돌아갑니다. 자기 데이터도 정답 파일과 모델 출력만 바꾸면 똑같이 동작합니다.
24
+
25
+ ```bash
26
+ uv run extractscore demo # uv 프로젝트나 clone 안에서. pip 로 설치했다면: extractscore demo
27
+ ```
28
+
29
+ A 실행의 점수와 A→B 비교가 출력됩니다. 한 실행만 직접 채점하려면:
30
+
31
+ ```bash
32
+ uv run extractscore demo --export my-eval # 정답·예측·규칙·원문을 복사
33
+ uv run extractscore score my-eval/gold.jsonl my-eval/pred_a.jsonl --config my-eval/fields.toml
34
+ ```
35
+
36
+ ```text
37
+ 필드 형 TP FP FN 정밀도 재현율 F1 오류
38
+ -------------------- ------ -- -- -- ------ ------ ----- --------------
39
+ date date 6 0 0 1.000 1.000 1.000 -
40
+ discount number 0 1 0 0.000 n/a 0.000 hallucinated=1
41
+ invoice_no id 2 1 1 0.667 0.667 0.667 wrong_value=1
42
+ line_items[].desc text 16 0 0 1.000 1.000 1.000 -
43
+ line_items[].price number 16 0 0 1.000 1.000 1.000 -
44
+ line_items[].qty number 13 3 3 0.812 0.812 0.812 wrong_value=3
45
+ payment_method enum 3 0 0 1.000 1.000 1.000 -
46
+ receipt_no id 3 0 0 1.000 1.000 1.000 -
47
+ total number 5 1 1 0.833 0.833 0.833 type_error=1
48
+ vendor.name text 6 0 0 1.000 1.000 1.000 -
49
+ vendor.tax_id id 1 0 2 1.000 0.333 0.500 missing=2
50
+ 마이크로 (전체 필드) 71 6 7 0.922 0.910 0.916
51
+
52
+ 매크로 F1 (필드별 F1 평균): 0.801
53
+ 전체 오류: hallucinated=1, missing=2, type_error=1, wrong_value=4
54
+ 건너뜀 - 채점하지 않았고 통과로도 세지 않음: notes
55
+ ```
56
+
57
+ (실제 명령은 머리말, 오류가 많은 문서 목록, 오류 범주 설명도 함께 출력하고, 위 표는 그 핵심입니다. 영어 출력은 `--lang en` 을 붙이면 됩니다.)
58
+
59
+ 읽는 법: `total` 은 맞은 값 5개(TP), 틀린 값 1개(FP 1 + FN 1)라서 정밀도와 재현율이 모두 5/6 입니다. `vendor.tax_id` 는 값이 있을 때 틀린 적은 없지만 두 번 빠뜨렸으므로 정밀도 1.0, 재현율 0.33 입니다.
60
+
61
+ ### 내 파일로 채점하기
62
+
63
+ 정답 파일은 줄마다 JSON 객체 하나, `id` 와 정답 필드를 담습니다.
64
+
65
+ ```json
66
+ {"id": "inv-001", "invoice_no": "INV-0042", "total": 1250.0, "line_items": [{"desc": "Widget A", "qty": 2, "price": 300.0}]}
67
+ ```
68
+
69
+ 예측 파일도 같은 모양이고 같은 `id` 를 씁니다. JSON 이 아닌 줄(응답이 잘린 경우 등)은 실행을 멈추지 않고 그 문서의 파싱 실패로 채점됩니다.
70
+
71
+ ### 두 실행 비교와 CI 게이트
72
+
73
+ ```bash
74
+ extractscore diff gold.jsonl pred_before.jsonl pred_after.jsonl --config fields.toml --fail-on-regression
75
+ extractscore score gold.jsonl pred.jsonl --config fields.toml --min-f1 0.95
76
+ ```
77
+
78
+ 종료 코드 0 은 통과, 1 은 게이트 실패, 2 는 입력·설정 오류입니다. 바로 쓸 수 있는 GitHub Actions 예시는 [`examples/ci.yml`](examples/ci.yml) 입니다. 이 예시는 `uvx extractscore` 를 쓰므로 PyPI 에 올라간 뒤에 동작합니다. 그 전에는 `uvx --from git+https://github.com/LeeGyeongPil/extractscore extractscore ...` 를 쓰세요.
79
+
80
+ ![다섯 필드에서 실패한 게이트](docs/images/02-gate.ko.png)
81
+
82
+ *`--min-f1 0.95` 는 기준 미만인 필드를 모두 나열하고 종료 코드 1 로 끝납니다.*
83
+
84
+ ![A와 B 비교](docs/images/03-diff.ko.png)
85
+
86
+ *`diff` 는 필드별 F1 의 전후 값과, 문서에서 맞음↔틀림으로 뒤집힌 필드를 보여 줍니다.*
87
+
88
+ 목의 B 실행은 `line_items[].qty` 와 `vendor.tax_id` 를 고치지만 `date` 와 `invoice_no` 는 나빠집니다. 후퇴한 7개 필드의 상당수는 잘린 JSON 한 줄(`rcp-003`) 때문입니다. 깨진 응답 하나가 그 문서의 모든 필드를 깎습니다. 그래서 비교 결과는 합계뿐 아니라 뒤집힌 문서도 보여 줍니다.
89
+
90
+ ## 핵심 개념
91
+
92
+ ```mermaid
93
+ flowchart LR
94
+ G[gold.jsonl] --> W[필드 순회]
95
+ P[pred.jsonl] --> W
96
+ C[fields.toml] --> W
97
+ W --> S[필드별 TP / FP / FN]
98
+ S --> R[정밀도, 재현율, F1]
99
+ R --> D[A와 B 비교]
100
+ R --> X[CI 종료 코드]
101
+ ```
102
+
103
+ - **필드 경로.** 중첩 객체는 `vendor.name`, 목록 항목은 `line_items[].qty` 로 부릅니다. 경로마다 따로 채점합니다.
104
+ - **TP / FP / FN.** 맞은 값은 TP. 틀린 값, 또는 필드 형으로 읽을 수 없는 값은 FP 1 + FN 1. 모델이 빠뜨린 값은 FN. 정답에 없는데 모델이 낸 값은 FP. `n/a` 는 0/0 입니다.
105
+ - **오류 범주.** `wrong_value`, `type_error`, `missing`, `hallucinated`, `parse_failure`. 몇 개 틀렸는지뿐 아니라 어떤 실수인지 알려 줍니다.
106
+ - **형별 비교.** `number`, `date`, `text`, `id`, `enum`, `bool`. `"1,250.00"`, `"$1,250"`, `1250` 은 같은 숫자입니다. id 는 항상 정확히 일치해야 합니다(`INV-215` 와 `INV-0215` 는 다름). 유사 문자열 비교는 설정한 text 필드에만 적용됩니다.
107
+ - **목록 항목.** 위치가 아니라 내용으로 짝을 짓습니다(헝가리안 알고리즘, 순수 Python). 순서가 바뀌어도 정답으로 처리하고, 짝이 없는 항목은 누락 1 + 환각 1 입니다.
108
+
109
+ 필드 규칙(`fields.toml` 또는 JSON). 적지 않은 필드는 정답 값에서 형을 추론합니다.
110
+
111
+ ```toml
112
+ date_formats = ["%Y-%m-%d", "%m/%d/%Y"] # 먼저 맞는 형식을 씀
113
+
114
+ [fields."total"]
115
+ type = "number"
116
+ tol = 0.01
117
+
118
+ [fields."line_items[].desc"]
119
+ type = "text"
120
+ fuzzy = 0.85 # 이 유사도 이상이면 같은 값으로 봄
121
+
122
+ [fields."notes"]
123
+ ignore = true # 채점하지 않고 '건너뜀' 으로 표시(통과로 세지 않음)
124
+ ```
125
+
126
+ ## API
127
+
128
+ ```python
129
+ from extractscore import evaluate_files, Config
130
+ ```
131
+
132
+ | 이름 | 하는 일 | 주요 인자 | 반환 |
133
+ |---|---|---|---|
134
+ | `evaluate(gold, pred, config=None, *, id_key="id")` | 메모리에 있는 데이터셋 전체를 채점 | 행 목록 또는 `{id: dict}` | `Report` |
135
+ | `evaluate_files(gold_path, pred_path, config=None, *, id_key="id")` | 같은 일을 JSONL 두 파일에서 | 경로 | `Report` |
136
+ | `compare(gold, pred, config=None)` | 문서 하나를 채점 | dict 두 개 | `DocResult` |
137
+ | `diff(report_a, report_b, *, tolerance=0.0)` | 같은 정답으로 낸 두 보고서 비교 | 보고서, 허용할 F1 하락 | `Diff` (`.regressions`, `.improvements`, `.flips`) |
138
+ | `check_gates(report, *, min_f1=None, field_min=None)` | CI 기준(전체·필드별) | `min_f1`, `{경로: 최소값}` | `list[GateFailure]` (비어 있으면 통과) |
139
+ | `Config.from_file(path)` / `Config.from_dict(data)` / `Config(...)` | 필드 규칙 | `fields`, `match_threshold`, `date_formats`, `empty_as_null` | `Config` |
140
+ | `FieldRule(type, tol, fuzzy, casefold, ignore)` | 필드 하나의 규칙 | | `FieldRule` |
141
+ | `load_jsonl(path, *, strict, id_key="id")` | JSONL 읽기. `strict=False` 면 깨진 줄이 `ParseFailure` 가 됨 | 경로 | id 별 행 `dict` |
142
+
143
+ `Report` 는 `.fields`(경로별 `FieldStats`: `.precision`, `.recall`, `.f1`, `.counts`), `.micro`, `.macro_f1`, `.parse_failures`, `.skipped`, `.top_docs(n)`, `.to_dict()`, `.to_markdown(lang)` 를 가집니다. counts 는 `.tp`, `.fp`, `.fn`, `.errors`(범주별 개수)를 노출하고, 0/0 인 정밀도·재현율은 `None` 입니다.
144
+
145
+ 예외: `InputError`(정답 데이터나 파일을 읽을 수 없음), `ConfigError`(필드 규칙 오류). 둘 다 `ExtractScoreError` 의 하위 클래스입니다. **예측** 쪽 문제는 예외가 아니라 점수로 처리합니다.
146
+
147
+ ## 사용 예
148
+
149
+ 아래 두 파일은 [`examples/`](examples/) 에 있고 그대로 실행해 확인했습니다.
150
+
151
+ **한 실행 채점** (`examples/quickstart.py`):
152
+
153
+ ```python
154
+ from pathlib import Path
155
+
156
+ import extractscore
157
+ from extractscore import Config, evaluate_files
158
+
159
+ sample = Path(extractscore.__file__).parent / "sample"
160
+ config = Config.from_file(sample / "fields.toml")
161
+ report = evaluate_files(sample / "gold.jsonl", sample / "pred_a.jsonl", config)
162
+
163
+ total = report.fields["total"]
164
+ print(f"total: P={total.precision:.3f} R={total.recall:.3f} F1={total.f1:.3f}")
165
+ print("type_error:", total.counts.errors["type_error"])
166
+ print("micro F1:", round(report.micro.f1, 3))
167
+ ```
168
+
169
+ ```text
170
+ total: P=0.833 R=0.833 F1=0.833
171
+ type_error: 1
172
+ micro F1: 0.916
173
+ ```
174
+
175
+ **두 실행 비교와 게이트 확인** (`examples/compare_runs.py`):
176
+
177
+ ```python
178
+ from pathlib import Path
179
+
180
+ import extractscore
181
+ from extractscore import Config, check_gates, diff, evaluate_files
182
+
183
+ sample = Path(extractscore.__file__).parent / "sample"
184
+ config = Config.from_file(sample / "fields.toml")
185
+ gold = sample / "gold.jsonl"
186
+ a = evaluate_files(gold, sample / "pred_a.jsonl", config)
187
+ b = evaluate_files(gold, sample / "pred_b.jsonl", config)
188
+
189
+ d = diff(a, b)
190
+ print("regressed:", ", ".join(d.regressions))
191
+ print("improved:", ", ".join(d.improvements))
192
+ for failure in check_gates(b, min_f1=0.9, field_min={"total": 0.99}):
193
+ print(f"FAIL {failure.path}: F1 {failure.f1:.3f} < {failure.minimum:.2f}")
194
+ ```
195
+
196
+ ```text
197
+ regressed: date, invoice_no, line_items[].desc, line_items[].price, payment_method, receipt_no, vendor.name
198
+ improved: line_items[].qty, total, vendor.tax_id
199
+ FAIL date: F1 0.727 < 0.90
200
+ FAIL discount: F1 0.000 < 0.90
201
+ FAIL invoice_no: F1 0.333 < 0.90
202
+ FAIL payment_method: F1 0.800 < 0.90
203
+ FAIL receipt_no: F1 0.800 < 0.90
204
+ FAIL total: F1 0.909 < 0.99
205
+ ```
206
+
207
+ **다른 출력 형식.** `--format json`(영어 키, 스크립트용)과 `--format md`(CI 요약용 Markdown 표). 둘 다 `--out FILE` 과 함께 쓸 수 있습니다.
208
+
209
+ **언어.** 출력은 영어 또는 한국어입니다: `--lang en|ko`, 없으면 `EXTRACTSCORE_LANG`, 없으면 `LC_ALL`/`LC_MESSAGES`/`LANG`. `ko` 로 시작하지 않으면 영어입니다. JSON 키는 바뀌지 않습니다.
210
+
211
+ ## 언제 쓰나
212
+
213
+ - 이럴 때: 문서에서 LLM 으로 JSON 을 뽑고, 필드 단위 정밀도·재현율과 같은 정답 세트로의 전후 비교, CI 게이트가 필요할 때. 채점은 결정적이며 모델을 호출하지 않습니다.
214
+ - 범위 밖: 추출 자체를 실행하거나 모델을 호출하지 않고, 자유 서술의 품질도 판단하지 않습니다. 준비한 정답 파일과 JSON 을 비교할 뿐입니다.
215
+ - 다른 도구가 더 맞는 경우: 범용 LLM 평가 파이프라인([promptfoo](https://github.com/promptfoo/promptfoo), [DeepEval](https://deepeval.com/docs/metrics-json-correctness), [Pydantic Evals](https://pydantic.dev/docs/ai/evals/evaluators/built-in/)), 문서 질의응답 유사도 점수([anls_star](https://pypi.org/project/anls-star/)), 구조화 추출 벤치마크([ExtractBench](https://github.com/ContextualAI/extract-bench)). 이 프로젝트는 범위가 좁아서, 형이 있는 JSON 필드를 목록 짝짓기와 함께 채점하고 두 실행을 비교하는 일 하나만 합니다. 다른 도구의 기능은 바뀌므로 이 문서 대신 각 공식 문서를 확인하세요.
216
+
217
+ ## 한계
218
+
219
+ 숫자를 믿기 전에 읽어 두세요.
220
+
221
+ - **슬래시 날짜는 월이 먼저입니다.** 기본값에서 `11/03/2026` 은 2026-11-03 으로 읽습니다. 문서가 일을 먼저 쓴다면 `fields.toml` 에서 그 형식을 앞에 두세요: `date_formats = ["%d/%m/%Y", "%Y-%m-%d"]`. 먼저 맞는 형식이 쓰이므로 모호한 날짜는 적은 순서를 따릅니다.
222
+ - **유럽식 소수점 쉼표는 0.1.0 에서 인식하지 못합니다.** `"1.250,00"` 은 1250 으로 읽지 않아서, 맞은 값이 `wrong_value` 로 세어집니다. 채점 전에 값을 정리하세요.
223
+ - **`id` 를 건질 수 없는 깨진 예측 줄은 버려집니다.** 줄이 잘렸어도 `id` 를 찾을 수 있으면 그 문서는 `parse_failure` 로 채점됩니다. 찾을 수 없으면 그 문서는 예측이 아예 없는 것으로 채점합니다.
224
+ - **큰 목록은 느립니다.** 목록 항목 짝짓기는 순수 Python 헝가리안 알고리즘(세제곱 시간)입니다. 한 목록에서 짝 없는 항목이 500개를 넘으면 경고가 나오고, 600개짜리 목록은 시험 환경에서 실행마다 약 2~9초 걸렸습니다.
225
+
226
+ ## 버전·호환성
227
+
228
+ - [유의적 버전(SemVer)](https://semver.org/lang/ko/)을 따릅니다. 0.x 동안은 마이너 버전에서 API 가 바뀔 수 있고, 바뀌면 [CHANGELOG.md](CHANGELOG.md) 에 적습니다.
229
+
230
+ ## 개발·기여
231
+
232
+ ```bash
233
+ git clone https://github.com/LeeGyeongPil/extractscore && cd extractscore
234
+ uv run pytest
235
+ ```
236
+
237
+ ## 라이선스
238
+
239
+ MIT.