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.
- groundextract/__init__.py +68 -0
- groundextract/__main__.py +262 -0
- groundextract/adapters/__init__.py +10 -0
- groundextract/adapters/docling_adapter.py +206 -0
- groundextract/bench.py +188 -0
- groundextract/export.py +126 -0
- groundextract/gate.py +383 -0
- groundextract/grounding.py +414 -0
- groundextract/llm/__init__.py +7 -0
- groundextract/llm/base.py +35 -0
- groundextract/llm/mock.py +23 -0
- groundextract/llm/openweight.py +313 -0
- groundextract/mcp_server.py +500 -0
- groundextract/models.py +112 -0
- groundextract/rules/README.md +155 -0
- groundextract/rules/balance_sheet.yaml +27 -0
- groundextract/rules/corporate_tax_return.yaml +47 -0
- groundextract/rules/income_statement.yaml +41 -0
- groundextract/rules/statement.yaml +24 -0
- groundextract/rules/tax_invoice.yaml +31 -0
- groundextract/rules.py +372 -0
- groundextract-0.1.2.dist-info/METADATA +566 -0
- groundextract-0.1.2.dist-info/RECORD +26 -0
- groundextract-0.1.2.dist-info/WHEEL +4 -0
- groundextract-0.1.2.dist-info/entry_points.txt +2 -0
- groundextract-0.1.2.dist-info/licenses/LICENSE +201 -0
|
@@ -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
|