groundextract 0.1.2__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.
@@ -0,0 +1,68 @@
1
+ """GroundExtract-KR — value-level truth gate for Korean regulatory documents.
2
+
3
+ Every extracted number is forced to (1) cite verbatim evidence, (2) satisfy
4
+ domain arithmetic invariants; anything ungrounded or rule-violating is
5
+ auto-discarded at confidence 0 before it reaches downstream.
6
+ """
7
+
8
+ from .gate import run_gate, summarize
9
+ from .grounding import ground_value, match_value
10
+ from .models import (
11
+ BBox,
12
+ Check,
13
+ ExtractedValue,
14
+ MatchKind,
15
+ Verdict,
16
+ VerifiedField,
17
+ )
18
+ from .rules import RulePack, default_rules_dir, evaluate_pack, load_rule_pack
19
+
20
+ __version__ = "0.1.2"
21
+
22
+
23
+ def available_doc_types() -> list[str]:
24
+ """Document types that ship with a bundled rule pack, sorted by name."""
25
+ return sorted(p.stem for p in default_rules_dir().glob("*.yaml"))
26
+
27
+
28
+ def load_pack(doc_type: str) -> RulePack:
29
+ """Load a bundled rule pack by document type, e.g. ``load_pack("tax_invoice")``.
30
+
31
+ Prefer this over ``load_rule_pack("rules/<doc_type>.yaml")``: that relative
32
+ path only resolves from a repository checkout, while the bundled packs are
33
+ located through :func:`default_rules_dir`, so this works from any working
34
+ directory and from a ``pip install``ed wheel.
35
+
36
+ Raises ``FileNotFoundError`` (listing the available types) when no pack
37
+ ships for ``doc_type``. Use :func:`load_rule_pack` for your own YAML files.
38
+ """
39
+ if not doc_type.replace("_", "").isalnum(): # no path separators / traversal
40
+ raise ValueError(f"invalid doc_type: {doc_type!r}")
41
+ path = default_rules_dir() / f"{doc_type}.yaml"
42
+ if not path.is_file():
43
+ raise FileNotFoundError(
44
+ f"no bundled rule pack for doc_type {doc_type!r}; "
45
+ f"available: {', '.join(available_doc_types()) or '(none)'}"
46
+ )
47
+ return load_rule_pack(path)
48
+
49
+
50
+ __all__ = [
51
+ "BBox",
52
+ "Check",
53
+ "ExtractedValue",
54
+ "MatchKind",
55
+ "Verdict",
56
+ "VerifiedField",
57
+ "RulePack",
58
+ "available_doc_types",
59
+ "default_rules_dir",
60
+ "load_pack",
61
+ "load_rule_pack",
62
+ "evaluate_pack",
63
+ "ground_value",
64
+ "match_value",
65
+ "run_gate",
66
+ "summarize",
67
+ "__version__",
68
+ ]
@@ -0,0 +1,262 @@
1
+ """CLI: `python -m groundextract` demos the gate, `... verify` runs it on your files.
2
+
3
+ The bare command shows the core value proposition with no keys and no inputs: a
4
+ hallucinated VAT is auto-discarded (confidence 0) while grounded,
5
+ arithmetic-consistent numbers stay verified.
6
+
7
+ `verify` is the same gate over a document and an extraction you supply. It is
8
+ deliberately *not* an extractor — this project verifies what some other OCR/LLM
9
+ produced, so it takes the text and the values as files rather than pretending to
10
+ read a PDF. For the full real-document path see `adapters.DoclingAdapter` +
11
+ `llm.OllamaExtractor`, and for agents the MCP tool `verify_extraction`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import sys
19
+ from collections.abc import Sequence
20
+ from pathlib import Path
21
+
22
+ from . import __version__, available_doc_types, load_pack
23
+ from .gate import run_gate, summarize
24
+ from .llm import MockExtractor
25
+ from .models import ExtractedValue, Verdict, VerifiedField
26
+ from .rules import RulePack, RulePackError, load_rule_pack
27
+
28
+ _DOC = (
29
+ "전자세금계산서\n"
30
+ "공급가액 1,000,000원\n"
31
+ "세액 100,000원\n"
32
+ "합계금액 1,100,000원\n"
33
+ )
34
+
35
+ _DESCRIPTION = (
36
+ "Run the built-in GroundExtract-KR demo: a canned Korean tax invoice is fed to a "
37
+ "mock extractor that hallucinates the VAT, and the gate auto-discards every value "
38
+ "it cannot ground in the text or reconcile with the rule pack. No API key, no "
39
+ "network, no randomness."
40
+ )
41
+
42
+ _EPILOG = """\
43
+ This command takes no document arguments; it always runs the same demo.
44
+
45
+ To verify your own numbers:
46
+ CLI : ... verify --doc doc.txt --values values.json --doc-type tax_invoice
47
+ library : from groundextract import ExtractedValue, load_pack, run_gate
48
+ agents : python -m groundextract.mcp_server (MCP tool `verify_extraction`)
49
+ bench : python -m groundextract.bench (NumHall-KR regression suite)
50
+
51
+ Docs: https://github.com/sos37591-prog/groundextract-kr
52
+ """
53
+
54
+ _VERIFY_DESCRIPTION = (
55
+ "Run the gate over a document and an extraction you already have. This is not an "
56
+ "extractor: --doc is the plain text your OCR/LLM read the numbers out of, and "
57
+ "--values is what it claims to have found."
58
+ )
59
+
60
+ _VERIFY_EPILOG = """\
61
+ values.json is a list of objects — the same shape the MCP tool accepts:
62
+
63
+ [
64
+ {"field": "supply", "raw": "1,000,000원", "number": 1000000,
65
+ "grounding_quote": "공급가액 1,000,000원"},
66
+ {"field": "vat", "raw": "100,000원", "number": 100000},
67
+ {"field": "total", "raw": "1,100,000원", "number": 1100000}
68
+ ]
69
+
70
+ `number` and `grounding_quote` are optional. `number` is cross-checked against
71
+ `raw` and must agree; omit it and it is parsed from `raw`.
72
+
73
+ Exit code: 0 if every field was verified, 1 if any was discarded — so a pipeline
74
+ can gate on it directly. Usage errors exit 2.
75
+ """
76
+
77
+
78
+ def _prog() -> str:
79
+ """Name to print in usage: the console script if that is how we were called."""
80
+ if Path(sys.argv[0] if sys.argv else "").stem == "groundextract":
81
+ return "groundextract"
82
+ return "python -m groundextract"
83
+
84
+
85
+ def _build_parser() -> argparse.ArgumentParser:
86
+ parser = argparse.ArgumentParser(
87
+ prog=_prog(),
88
+ description=_DESCRIPTION,
89
+ epilog=_EPILOG,
90
+ formatter_class=argparse.RawDescriptionHelpFormatter,
91
+ )
92
+ parser.add_argument(
93
+ "--json",
94
+ dest="as_json",
95
+ action="store_true",
96
+ help="emit the demo result as JSON (fields + summary) instead of the text report",
97
+ )
98
+ parser.add_argument("--version", action="version", version=f"groundextract {__version__}")
99
+ # Accepted only so a file argument gets an explanatory error instead of
100
+ # being silently ignored (which would look like the file was processed).
101
+ parser.add_argument("args", nargs="*", metavar="", help=argparse.SUPPRESS)
102
+ return parser
103
+
104
+
105
+ def _build_verify_parser() -> argparse.ArgumentParser:
106
+ parser = argparse.ArgumentParser(
107
+ prog=f"{_prog()} verify",
108
+ description=_VERIFY_DESCRIPTION,
109
+ epilog=_VERIFY_EPILOG,
110
+ formatter_class=argparse.RawDescriptionHelpFormatter,
111
+ )
112
+ parser.add_argument(
113
+ "--doc", required=True, type=Path, metavar="PATH", help="document text file (UTF-8)"
114
+ )
115
+ parser.add_argument(
116
+ "--values", required=True, type=Path, metavar="PATH", help="extracted values, JSON"
117
+ )
118
+ pack = parser.add_mutually_exclusive_group(required=True)
119
+ pack.add_argument(
120
+ "--doc-type",
121
+ choices=available_doc_types(),
122
+ help="use a bundled rule pack",
123
+ )
124
+ pack.add_argument(
125
+ "--rules", type=Path, metavar="PATH", help="use your own rule pack (YAML)"
126
+ )
127
+ parser.add_argument(
128
+ "--json", dest="as_json", action="store_true", help="emit JSON instead of a text report"
129
+ )
130
+ return parser
131
+
132
+
133
+ def _read_values(path: Path, parser: argparse.ArgumentParser) -> list[ExtractedValue]:
134
+ """Parse values.json, reusing the exact contract the MCP tool enforces.
135
+
136
+ The CLI and the MCP tool must accept the same value shape — two parsers would
137
+ drift, and the difference would show up as a document that verifies through
138
+ one path and not the other.
139
+ """
140
+ from .mcp_server import InvalidParamsError, _parse_values
141
+
142
+ try:
143
+ raw = json.loads(path.read_text(encoding="utf-8"))
144
+ except OSError as e:
145
+ parser.error(f"cannot read {path}: {e}")
146
+ except json.JSONDecodeError as e:
147
+ parser.error(f"{path}: invalid JSON ({e})")
148
+ try:
149
+ return _parse_values(raw)
150
+ except InvalidParamsError as e:
151
+ parser.error(f"{path}: {e}")
152
+
153
+
154
+ def _verify_main(argv: Sequence[str]) -> int:
155
+ parser = _build_verify_parser()
156
+ args = parser.parse_args(argv)
157
+
158
+ try:
159
+ text = args.doc.read_text(encoding="utf-8")
160
+ except OSError as e:
161
+ parser.error(f"cannot read {args.doc}: {e}")
162
+ if not text.strip():
163
+ parser.error(f"{args.doc} is empty; there is nothing to ground against")
164
+
165
+ values = _read_values(args.values, parser)
166
+ try:
167
+ pack = load_pack(args.doc_type) if args.doc_type else load_rule_pack(args.rules)
168
+ except RulePackError as e:
169
+ parser.error(str(e))
170
+
171
+ fields = run_gate(values, text, pack)
172
+ if args.as_json:
173
+ payload = {"fields": [f.to_dict() for f in fields], "summary": summarize(fields, pack)}
174
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
175
+ else:
176
+ _print_report(fields, pack, title=f"=== {args.doc.name} ===")
177
+ # Non-zero when anything was discarded, so `... verify && ingest` is safe.
178
+ return 1 if any(f.verdict is Verdict.DISCARDED for f in fields) else 0
179
+
180
+
181
+ def _demo_fields() -> tuple[list[VerifiedField], RulePack]:
182
+ pack = load_pack("tax_invoice")
183
+
184
+ # Two grounded/consistent values + one hallucinated VAT (250,000 is nowhere
185
+ # in the document and breaks vat = supply * 10%).
186
+ extractor = MockExtractor(
187
+ [
188
+ ExtractedValue("supply", "1,000,000원", 1_000_000, "공급가액 1,000,000원"),
189
+ ExtractedValue("vat", "250,000원", 250_000, "세액 250,000원"), # hallucinated
190
+ ExtractedValue("total", "1,100,000원", 1_100_000, "합계금액 1,100,000원"),
191
+ ]
192
+ )
193
+ return run_gate(extractor.extract(_DOC, doc_type="tax_invoice"), _DOC, pack), pack
194
+
195
+
196
+ def _print_report(
197
+ fields: list[VerifiedField], pack: RulePack, title: str = "=== GroundExtract-KR demo ==="
198
+ ) -> None:
199
+ print(title)
200
+ for f in fields:
201
+ mark = "OK " if f.verdict.value == "verified" else "XX "
202
+ print(
203
+ f"{mark}{f.field:6} value={f.value.raw:12} "
204
+ f"verdict={f.verdict.value} conf={f.confidence}"
205
+ )
206
+ for c in f.failed_checks:
207
+ print(f" ! {c.name}: {c.detail}")
208
+ print("summary:", json.dumps(summarize(fields, pack), ensure_ascii=False))
209
+
210
+
211
+ def _survive_a_narrow_console() -> None:
212
+ """Never let an unencodable character turn a verdict into a traceback.
213
+
214
+ A check detail is free-form text — rule names, quoted document spans, the
215
+ gate's own prose — and the console it lands on is not. On a Korean Windows
216
+ console (cp949) the em dash in "this verifies nothing — the rule leans on …"
217
+ raised UnicodeEncodeError *while printing the report*, so `verify` crashed on
218
+ exactly the case it exists to catch: an extraction whose arithmetic passes
219
+ only because it leans on ungrounded values. The exit code was still 1, which
220
+ made it look like an ordinary failed verification rather than a crash.
221
+
222
+ Replacing unencodable characters degrades one glyph; raising loses the whole
223
+ report. `errors` is set without touching `encoding`, so console output stays
224
+ in the terminal's own codepage and Korean still renders.
225
+ """
226
+ for stream in (sys.stdout, sys.stderr):
227
+ reconfigure = getattr(stream, "reconfigure", None)
228
+ if reconfigure is not None:
229
+ try:
230
+ reconfigure(errors="replace")
231
+ except (ValueError, OSError): # pragma: no cover - detached/odd stream
232
+ pass
233
+
234
+
235
+ def main(argv: Sequence[str] | None = None) -> int:
236
+ """Run the demo, or dispatch to `verify`. Returns the process exit code."""
237
+ _survive_a_narrow_console()
238
+ raw_args = list(sys.argv[1:] if argv is None else argv)
239
+ if raw_args and raw_args[0] == "verify":
240
+ return _verify_main(raw_args[1:])
241
+
242
+ parser = _build_parser()
243
+ args = parser.parse_args(raw_args)
244
+ if args.args:
245
+ parser.error(
246
+ f"this command runs the built-in demo only and cannot process {args.args[0]!r}. "
247
+ f"To verify your own document use `{_prog()} verify --doc ... --values ...` "
248
+ "(see `verify --help`), the library (groundextract.run_gate), or the MCP tool "
249
+ "`verify_extraction`."
250
+ )
251
+
252
+ fields, pack = _demo_fields()
253
+ if args.as_json:
254
+ payload = {"fields": [f.to_dict() for f in fields], "summary": summarize(fields, pack)}
255
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
256
+ else:
257
+ _print_report(fields, pack)
258
+ return 0
259
+
260
+
261
+ if __name__ == "__main__":
262
+ raise SystemExit(main())
@@ -0,0 +1,10 @@
1
+ """Document conversion adapters (optional heavy backends).
2
+
3
+ Heavy third-party libraries (Docling, ...) are imported lazily inside the
4
+ adapter methods, so importing this package never requires them. Missing
5
+ optional dependencies raise ``ImportError`` with an install hint at call time.
6
+ """
7
+
8
+ from .docling_adapter import DoclingAdapter, SpanInfo
9
+
10
+ __all__ = ["DoclingAdapter", "SpanInfo"]
@@ -0,0 +1,206 @@
1
+ """Docling PDF adapter: PDF -> full text + per-span page/bbox provenance.
2
+
3
+ Docling is an *optional* dependency (``pip install groundextract[docling]``);
4
+ it is imported lazily inside :meth:`DoclingAdapter.convert` so the rest of the
5
+ package (gate, rules, bench) keeps working without it.
6
+
7
+ The Docling API has shifted between releases, so every access to the converted
8
+ document is defensive (``getattr``/``hasattr``). Worst case the adapter still
9
+ returns the full text with no layout info — the gate only *needs* text; page
10
+ and bbox are a bonus for the highlight viewer.
11
+
12
+ Coordinates are copied from Docling as-is (``l/t/r/b`` -> ``x0/y0/x1/y1``);
13
+ depending on the Docling version the origin may be top-left or bottom-left.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from ..models import BBox, ExtractedValue
23
+
24
+ _INSTALL_HINT = (
25
+ "docling is required for DoclingAdapter but is not installed. "
26
+ "Install the optional extra: pip install groundextract[docling]"
27
+ )
28
+
29
+
30
+ def _import_docling() -> Any:
31
+ """Import Docling's ``DocumentConverter`` lazily, with an install hint."""
32
+ try:
33
+ from docling.document_converter import DocumentConverter
34
+ except ImportError as exc:
35
+ raise ImportError(_INSTALL_HINT) from exc
36
+ return DocumentConverter
37
+
38
+
39
+ @dataclass
40
+ class SpanInfo:
41
+ """One text item from the converted document plus its provenance."""
42
+
43
+ text: str
44
+ page: int | None = None
45
+ bbox: BBox | None = None
46
+
47
+
48
+ class DoclingAdapter:
49
+ """Converts a PDF with Docling and maps extracted values back to layout.
50
+
51
+ Usage::
52
+
53
+ adapter = DoclingAdapter()
54
+ result = adapter.convert("invoice.pdf") # {"full_text": ..., "spans": [...]}
55
+ values = extractor.extract(result["full_text"], doc_type="tax_invoice")
56
+ adapter.enrich(values) # fills page/bbox where possible
57
+ """
58
+
59
+ def __init__(self) -> None:
60
+ self.full_text: str = ""
61
+ self.spans: list[SpanInfo] = []
62
+ self._converter: Any = None
63
+
64
+ # --- conversion ------------------------------------------------------------
65
+
66
+ def convert(self, pdf_path: str | Path) -> dict[str, Any]:
67
+ """Convert ``pdf_path`` and return ``{"full_text": str, "spans": [SpanInfo]}``.
68
+
69
+ Raises ``ImportError`` (with an install hint) when docling is missing.
70
+ """
71
+ converter_cls = _import_docling()
72
+ if self._converter is None:
73
+ self._converter = converter_cls()
74
+ result = self._converter.convert(str(pdf_path))
75
+ doc = getattr(result, "document", None)
76
+ if doc is None:
77
+ doc = result
78
+ self.spans = self._collect_spans(doc)
79
+ self.full_text = self._export_text(doc)
80
+ if not self.full_text and self.spans:
81
+ # Fallback: reconstruct the text from the individual spans.
82
+ self.full_text = "\n".join(s.text for s in self.spans)
83
+ return {"full_text": self.full_text, "spans": self.spans}
84
+
85
+ @staticmethod
86
+ def _export_text(doc: Any) -> str:
87
+ """Best-effort plain-text export across Docling versions."""
88
+ for method in ("export_to_text", "export_to_markdown"):
89
+ export = getattr(doc, method, None)
90
+ if not callable(export):
91
+ continue
92
+ try:
93
+ out = export()
94
+ except Exception:
95
+ continue
96
+ if isinstance(out, str) and out:
97
+ return out
98
+ return ""
99
+
100
+ @classmethod
101
+ def _collect_spans(cls, doc: Any) -> list[SpanInfo]:
102
+ """Collect text items with page/bbox provenance, tolerating API drift."""
103
+ spans: list[SpanInfo] = []
104
+ for item in cls._iter_text_items(doc):
105
+ text = getattr(item, "text", None)
106
+ if not text:
107
+ continue
108
+ page, bbox = cls._prov_page_bbox(item)
109
+ spans.append(SpanInfo(text=str(text), page=page, bbox=bbox))
110
+ return spans
111
+
112
+ @staticmethod
113
+ def _iter_text_items(doc: Any) -> list[Any]:
114
+ # TODO(W4/viewer): also collect table-cell items (doc.tables) — invoice
115
+ # amounts usually live inside tables, so span/bbox lookup misses them
116
+ # today (verified 2026-07-18: gate passes via full_text, bbox=None).
117
+ items = getattr(doc, "texts", None)
118
+ if items:
119
+ return list(items)
120
+ iterate = getattr(doc, "iterate_items", None)
121
+ if not callable(iterate):
122
+ return []
123
+ try:
124
+ # iterate_items() yields (item, level) tuples in recent docling-core.
125
+ return [e[0] if isinstance(e, tuple) else e for e in iterate()]
126
+ except Exception:
127
+ return []
128
+
129
+ @staticmethod
130
+ def _prov_page_bbox(item: Any) -> tuple[int | None, BBox | None]:
131
+ """Extract ``(page, BBox|None)`` from an item's first provenance entry."""
132
+ prov = getattr(item, "prov", None)
133
+ if not prov:
134
+ return None, None
135
+ try:
136
+ first = prov[0]
137
+ except (IndexError, TypeError):
138
+ return None, None
139
+
140
+ page: int | None = None
141
+ for attr in ("page_no", "page"):
142
+ raw_page = getattr(first, attr, None)
143
+ if raw_page is not None:
144
+ try:
145
+ page = int(raw_page)
146
+ except (TypeError, ValueError):
147
+ page = None
148
+ break
149
+
150
+ raw_bbox = getattr(first, "bbox", None)
151
+ coords: tuple[float, float, float, float] | None = None
152
+ if raw_bbox is not None:
153
+ try:
154
+ if all(hasattr(raw_bbox, a) for a in ("l", "t", "r", "b")):
155
+ coords = (
156
+ float(raw_bbox.l),
157
+ float(raw_bbox.t),
158
+ float(raw_bbox.r),
159
+ float(raw_bbox.b),
160
+ )
161
+ elif all(hasattr(raw_bbox, a) for a in ("x0", "y0", "x1", "y1")):
162
+ coords = (
163
+ float(raw_bbox.x0),
164
+ float(raw_bbox.y0),
165
+ float(raw_bbox.x1),
166
+ float(raw_bbox.y1),
167
+ )
168
+ except (TypeError, ValueError):
169
+ coords = None
170
+
171
+ if coords is None:
172
+ return page, None
173
+ return page, BBox(page if page is not None else 0, *coords)
174
+
175
+ # --- span lookup / enrichment ----------------------------------------------
176
+
177
+ def find_span(self, text: str) -> SpanInfo | None:
178
+ """Return the first collected span containing ``text`` (substring search)."""
179
+ needle = text.strip() if text else ""
180
+ if not needle:
181
+ return None
182
+ for span in self.spans:
183
+ if needle in span.text:
184
+ return span
185
+ return None
186
+
187
+ def enrich(self, values: list[ExtractedValue]) -> list[ExtractedValue]:
188
+ """Fill ``page``/``bbox`` on each value from its quote (in place).
189
+
190
+ The grounding quote is tried first, then ``raw``. Values whose text is
191
+ not found in any span — or that already carry page/bbox — are left
192
+ untouched. Returns the same list for chaining.
193
+ """
194
+ for value in values:
195
+ span = None
196
+ if value.grounding_quote:
197
+ span = self.find_span(value.grounding_quote)
198
+ if span is None:
199
+ span = self.find_span(value.raw)
200
+ if span is None:
201
+ continue
202
+ if value.page is None:
203
+ value.page = span.page
204
+ if value.bbox is None:
205
+ value.bbox = span.bbox
206
+ return values