extractfold 0.1.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.
- extractfold/__init__.py +40 -0
- extractfold/chunking.py +187 -0
- extractfold/cli.py +159 -0
- extractfold/engines/__init__.py +33 -0
- extractfold/engines/_common.py +143 -0
- extractfold/engines/azure_docint_engine.py +135 -0
- extractfold/engines/base.py +510 -0
- extractfold/engines/docfold_llm_engine.py +97 -0
- extractfold/engines/fenic_engine.py +189 -0
- extractfold/engines/google_docai_engine.py +144 -0
- extractfold/engines/instructor_engine.py +96 -0
- extractfold/engines/lift_engine.py +112 -0
- extractfold/engines/llamaextract_engine.py +95 -0
- extractfold/engines/llm_structured_engine.py +188 -0
- extractfold/engines/nuextract_engine.py +115 -0
- extractfold/engines/provider_router_engine.py +135 -0
- extractfold/engines/router.py +7 -0
- extractfold/engines/textract_engine.py +120 -0
- extractfold/evaluation/__init__.py +26 -0
- extractfold/evaluation/metrics.py +164 -0
- extractfold/evaluation/runner.py +144 -0
- extractfold/parsing.py +101 -0
- extractfold/py.typed +1 -0
- extractfold/schema.py +128 -0
- extractfold/text.py +285 -0
- extractfold-0.1.1.dist-info/METADATA +228 -0
- extractfold-0.1.1.dist-info/RECORD +30 -0
- extractfold-0.1.1.dist-info/WHEEL +4 -0
- extractfold-0.1.1.dist-info/entry_points.txt +2 -0
- extractfold-0.1.1.dist-info/licenses/LICENSE +21 -0
extractfold/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Schema-first structured data extraction for documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from extractfold.chunking import TextChunk, chunk_json_array, chunk_rows, chunk_sections, chunk_text
|
|
6
|
+
from extractfold.engines.base import (
|
|
7
|
+
BatchExtractionResult,
|
|
8
|
+
ExtractionCapabilities,
|
|
9
|
+
ExtractionEngine,
|
|
10
|
+
ExtractionResult,
|
|
11
|
+
ExtractionRouter,
|
|
12
|
+
SchemaValidationResult,
|
|
13
|
+
load_schema,
|
|
14
|
+
validate_data,
|
|
15
|
+
)
|
|
16
|
+
from extractfold.schema import SchemaConversionResult, infer_schema, template_to_schema
|
|
17
|
+
from extractfold.text import extract_rows_chunked
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"BatchExtractionResult",
|
|
23
|
+
"ExtractionCapabilities",
|
|
24
|
+
"ExtractionEngine",
|
|
25
|
+
"ExtractionResult",
|
|
26
|
+
"ExtractionRouter",
|
|
27
|
+
"SchemaConversionResult",
|
|
28
|
+
"SchemaValidationResult",
|
|
29
|
+
"TextChunk",
|
|
30
|
+
"__version__",
|
|
31
|
+
"chunk_json_array",
|
|
32
|
+
"chunk_rows",
|
|
33
|
+
"chunk_sections",
|
|
34
|
+
"chunk_text",
|
|
35
|
+
"extract_rows_chunked",
|
|
36
|
+
"infer_schema",
|
|
37
|
+
"load_schema",
|
|
38
|
+
"template_to_schema",
|
|
39
|
+
"validate_data",
|
|
40
|
+
]
|
extractfold/chunking.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Deterministic chunk planning utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class TextChunk:
|
|
14
|
+
"""A planned extraction chunk with stable source coordinates."""
|
|
15
|
+
|
|
16
|
+
text: str
|
|
17
|
+
index: int
|
|
18
|
+
start: int
|
|
19
|
+
end: int
|
|
20
|
+
strategy: str
|
|
21
|
+
overlap: int = 0
|
|
22
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict[str, Any]:
|
|
25
|
+
"""Serialize the chunk to a plain dictionary."""
|
|
26
|
+
return {
|
|
27
|
+
"text": self.text,
|
|
28
|
+
"index": self.index,
|
|
29
|
+
"start": self.start,
|
|
30
|
+
"end": self.end,
|
|
31
|
+
"strategy": self.strategy,
|
|
32
|
+
"overlap": self.overlap,
|
|
33
|
+
"metadata": dict(self.metadata),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def chunk_text(text: str, *, max_chars: int, overlap: int = 0) -> list[TextChunk]:
|
|
38
|
+
"""Plan character-window chunks for plain text."""
|
|
39
|
+
_validate_window(max_chars, overlap)
|
|
40
|
+
if not text:
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
chunks: list[TextChunk] = []
|
|
44
|
+
start = 0
|
|
45
|
+
while start < len(text):
|
|
46
|
+
end = min(start + max_chars, len(text))
|
|
47
|
+
chunks.append(
|
|
48
|
+
TextChunk(
|
|
49
|
+
text=text[start:end],
|
|
50
|
+
index=len(chunks),
|
|
51
|
+
start=start,
|
|
52
|
+
end=end,
|
|
53
|
+
strategy="text",
|
|
54
|
+
overlap=0 if not chunks else overlap,
|
|
55
|
+
metadata={"unit": "char"},
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
if end == len(text):
|
|
59
|
+
break
|
|
60
|
+
start = end - overlap
|
|
61
|
+
return chunks
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def chunk_json_array(json_text: str, *, max_rows: int, overlap: int = 0) -> list[TextChunk]:
|
|
65
|
+
"""Plan row-safe chunks from a JSON array string."""
|
|
66
|
+
rows = json.loads(json_text)
|
|
67
|
+
if not isinstance(rows, list):
|
|
68
|
+
raise ValueError("chunk_json_array expects a JSON array")
|
|
69
|
+
return chunk_rows(rows, max_rows=max_rows, overlap=overlap, strategy="json_array")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def chunk_rows(
|
|
73
|
+
rows: Sequence[Any],
|
|
74
|
+
*,
|
|
75
|
+
max_rows: int,
|
|
76
|
+
overlap: int = 0,
|
|
77
|
+
strategy: str = "rows",
|
|
78
|
+
) -> list[TextChunk]:
|
|
79
|
+
"""Plan row-window chunks from already parsed rows."""
|
|
80
|
+
_validate_window(max_rows, overlap)
|
|
81
|
+
if not rows:
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
chunks: list[TextChunk] = []
|
|
85
|
+
start = 0
|
|
86
|
+
while start < len(rows):
|
|
87
|
+
end = min(start + max_rows, len(rows))
|
|
88
|
+
row_slice = list(rows[start:end])
|
|
89
|
+
chunks.append(
|
|
90
|
+
TextChunk(
|
|
91
|
+
text=json.dumps(row_slice, ensure_ascii=False),
|
|
92
|
+
index=len(chunks),
|
|
93
|
+
start=start,
|
|
94
|
+
end=end,
|
|
95
|
+
strategy=strategy,
|
|
96
|
+
overlap=0 if not chunks else overlap,
|
|
97
|
+
metadata={"unit": "row", "row_start": start, "row_end": end},
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
if end == len(rows):
|
|
101
|
+
break
|
|
102
|
+
start = end - overlap
|
|
103
|
+
return chunks
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def chunk_sections(text: str, *, max_chars: int, overlap: int = 0) -> list[TextChunk]:
|
|
107
|
+
"""Plan section-aware chunks for Markdown or HTML-like text."""
|
|
108
|
+
_validate_window(max_chars, overlap)
|
|
109
|
+
if not text:
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
sections = _find_sections(text)
|
|
113
|
+
if not sections:
|
|
114
|
+
return chunk_text(text, max_chars=max_chars, overlap=overlap)
|
|
115
|
+
|
|
116
|
+
chunks: list[TextChunk] = []
|
|
117
|
+
current_start: int | None = None
|
|
118
|
+
current_end: int | None = None
|
|
119
|
+
current_text = ""
|
|
120
|
+
|
|
121
|
+
for start, end in sections:
|
|
122
|
+
section_text = text[start:end]
|
|
123
|
+
if current_text and len(current_text) + len(section_text) > max_chars:
|
|
124
|
+
chunks.append(
|
|
125
|
+
_section_chunk(
|
|
126
|
+
current_text,
|
|
127
|
+
len(chunks),
|
|
128
|
+
current_start or 0,
|
|
129
|
+
current_end or 0,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
current_start = start
|
|
133
|
+
current_text = section_text
|
|
134
|
+
current_end = end
|
|
135
|
+
else:
|
|
136
|
+
if current_start is None:
|
|
137
|
+
current_start = start
|
|
138
|
+
current_text += section_text
|
|
139
|
+
current_end = end
|
|
140
|
+
|
|
141
|
+
if current_text:
|
|
142
|
+
chunks.append(
|
|
143
|
+
_section_chunk(
|
|
144
|
+
current_text,
|
|
145
|
+
len(chunks),
|
|
146
|
+
current_start or 0,
|
|
147
|
+
current_end or 0,
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
return chunks
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _validate_window(size: int, overlap: int) -> None:
|
|
154
|
+
if size <= 0:
|
|
155
|
+
raise ValueError("chunk size must be positive")
|
|
156
|
+
if overlap < 0:
|
|
157
|
+
raise ValueError("overlap must be non-negative")
|
|
158
|
+
if overlap >= size:
|
|
159
|
+
raise ValueError("overlap must be smaller than chunk size")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _find_sections(text: str) -> list[tuple[int, int]]:
|
|
163
|
+
starts = _markdown_heading_starts(text) or _html_heading_starts(text)
|
|
164
|
+
if not starts:
|
|
165
|
+
return []
|
|
166
|
+
ends = starts[1:] + [len(text)]
|
|
167
|
+
return [(start, end) for start, end in zip(starts, ends) if end > start]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _markdown_heading_starts(text: str) -> list[int]:
|
|
171
|
+
return [match.start() for match in re.finditer(r"(?m)^#{1,6}\s+", text)]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _html_heading_starts(text: str) -> list[int]:
|
|
175
|
+
return [match.start() for match in re.finditer(r"(?i)<h[1-6][^>]*>", text)]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _section_chunk(text: str, index: int, start: int, end: int) -> TextChunk:
|
|
179
|
+
return TextChunk(
|
|
180
|
+
text=text,
|
|
181
|
+
index=index,
|
|
182
|
+
start=start,
|
|
183
|
+
end=end,
|
|
184
|
+
strategy="section",
|
|
185
|
+
overlap=0,
|
|
186
|
+
metadata={"unit": "char"},
|
|
187
|
+
)
|
extractfold/cli.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""extractfold CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from extractfold.engines.base import ExtractionResult, ExtractionRouter, load_schema
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv: list[str] | None = None) -> None:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="extractfold",
|
|
18
|
+
description="Extract schema-conformant dictionaries from documents.",
|
|
19
|
+
)
|
|
20
|
+
sub = parser.add_subparsers(dest="command")
|
|
21
|
+
|
|
22
|
+
extract_p = sub.add_parser("extract", help="Extract structured data from a document")
|
|
23
|
+
extract_p.add_argument("file")
|
|
24
|
+
extract_p.add_argument("--schema", required=True)
|
|
25
|
+
extract_p.add_argument("--engine")
|
|
26
|
+
extract_p.add_argument("--out")
|
|
27
|
+
extract_p.add_argument("--format", choices=["json"], default="json")
|
|
28
|
+
|
|
29
|
+
compare_p = sub.add_parser("compare", help="Compare engines on a document")
|
|
30
|
+
compare_p.add_argument("file")
|
|
31
|
+
compare_p.add_argument("--schema", required=True)
|
|
32
|
+
compare_p.add_argument("--engines")
|
|
33
|
+
|
|
34
|
+
sub.add_parser("list-engines", help="List registered engines")
|
|
35
|
+
|
|
36
|
+
benchmark_p = sub.add_parser("benchmark", help="Run the benchmark harness")
|
|
37
|
+
benchmark_p.add_argument("dataset")
|
|
38
|
+
benchmark_p.add_argument("--engines")
|
|
39
|
+
benchmark_p.add_argument("--out")
|
|
40
|
+
|
|
41
|
+
args = parser.parse_args(argv)
|
|
42
|
+
if args.command is None:
|
|
43
|
+
parser.print_help()
|
|
44
|
+
raise SystemExit(0)
|
|
45
|
+
if args.command == "extract":
|
|
46
|
+
asyncio.run(_cmd_extract(args))
|
|
47
|
+
elif args.command == "compare":
|
|
48
|
+
asyncio.run(_cmd_compare(args))
|
|
49
|
+
elif args.command == "list-engines":
|
|
50
|
+
_cmd_list_engines()
|
|
51
|
+
elif args.command == "benchmark":
|
|
52
|
+
_run_benchmark_cli(args)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _build_router() -> ExtractionRouter:
|
|
56
|
+
from extractfold.engines.azure_docint_engine import AzureDocIntEngine
|
|
57
|
+
from extractfold.engines.docfold_llm_engine import DocfoldLLMEngine
|
|
58
|
+
from extractfold.engines.fenic_engine import FenicEngine
|
|
59
|
+
from extractfold.engines.google_docai_engine import GoogleDocAIEngine
|
|
60
|
+
from extractfold.engines.instructor_engine import InstructorEngine
|
|
61
|
+
from extractfold.engines.lift_engine import LiftEngine
|
|
62
|
+
from extractfold.engines.llamaextract_engine import LlamaExtractEngine
|
|
63
|
+
from extractfold.engines.llm_structured_engine import LLMStructuredEngine
|
|
64
|
+
from extractfold.engines.nuextract_engine import NuExtractEngine
|
|
65
|
+
from extractfold.engines.provider_router_engine import ProviderRouterEngine
|
|
66
|
+
from extractfold.engines.textract_engine import TextractEngine
|
|
67
|
+
|
|
68
|
+
return ExtractionRouter(
|
|
69
|
+
[
|
|
70
|
+
LiftEngine(),
|
|
71
|
+
NuExtractEngine(),
|
|
72
|
+
ProviderRouterEngine(),
|
|
73
|
+
LLMStructuredEngine(),
|
|
74
|
+
InstructorEngine(),
|
|
75
|
+
FenicEngine(),
|
|
76
|
+
LlamaExtractEngine(),
|
|
77
|
+
AzureDocIntEngine(),
|
|
78
|
+
GoogleDocAIEngine(),
|
|
79
|
+
TextractEngine(),
|
|
80
|
+
DocfoldLLMEngine(),
|
|
81
|
+
]
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _cmd_extract(args: argparse.Namespace) -> None:
|
|
86
|
+
router = _build_router()
|
|
87
|
+
schema = load_schema(args.schema)
|
|
88
|
+
result = await router.extract(args.file, schema, engine_hint=args.engine)
|
|
89
|
+
payload = json.dumps(result.to_dict(), ensure_ascii=False, indent=2)
|
|
90
|
+
if args.out:
|
|
91
|
+
Path(args.out).write_text(payload, encoding="utf-8")
|
|
92
|
+
else:
|
|
93
|
+
print(payload)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def _cmd_compare(args: argparse.Namespace) -> None:
|
|
97
|
+
router = _build_router()
|
|
98
|
+
schema = load_schema(args.schema)
|
|
99
|
+
engines = args.engines.split(",") if args.engines else None
|
|
100
|
+
results = await router.compare(args.file, schema, engines=engines)
|
|
101
|
+
print("Engine results")
|
|
102
|
+
print("--------------")
|
|
103
|
+
for name, result in results.items():
|
|
104
|
+
print(f"{name}: valid={result.valid} data={json.dumps(result.data, ensure_ascii=False)}")
|
|
105
|
+
print()
|
|
106
|
+
print("Field agreement")
|
|
107
|
+
print("---------------")
|
|
108
|
+
for field, values in _field_agreement(results).items():
|
|
109
|
+
print(f"{field}: {values}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _cmd_list_engines() -> None:
|
|
113
|
+
engines = _build_router().list_engines()
|
|
114
|
+
print(
|
|
115
|
+
f"{'Engine':<16} {'Avail':<6} {'Conf':<4} {'Prov':<4} "
|
|
116
|
+
f"{'Nest':<4} {'Local':<5} {'Remote':<6} Extensions"
|
|
117
|
+
)
|
|
118
|
+
print("-" * 86)
|
|
119
|
+
for engine in engines:
|
|
120
|
+
caps = engine["capabilities"]
|
|
121
|
+
print(
|
|
122
|
+
f"{engine['name']:<16} {str(engine['available']):<6} "
|
|
123
|
+
f"{_mark(caps['field_confidence']):<4} {_mark(caps['provenance']):<4} "
|
|
124
|
+
f"{_mark(caps['nested_schemas']):<4} {_mark(caps['local']):<5} "
|
|
125
|
+
f"{_mark(caps['remote']):<6} {', '.join(engine['extensions'][:8])}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _run_benchmark_cli(args: argparse.Namespace) -> None:
|
|
130
|
+
import benchmark
|
|
131
|
+
|
|
132
|
+
engines = args.engines.split(",") if args.engines else None
|
|
133
|
+
report = asyncio.run(
|
|
134
|
+
benchmark.run_benchmark(
|
|
135
|
+
args.dataset,
|
|
136
|
+
engines=engines,
|
|
137
|
+
router=_build_router(),
|
|
138
|
+
output_path=args.out,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
print(report.to_table())
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _field_agreement(results: dict[str, ExtractionResult]) -> dict[str, dict[str, Any]]:
|
|
145
|
+
fields = sorted({field for result in results.values() for field in result.data})
|
|
146
|
+
agreement: dict[str, dict[str, Any]] = {}
|
|
147
|
+
for field in fields:
|
|
148
|
+
values = {engine: result.data.get(field) for engine, result in results.items()}
|
|
149
|
+
unique = {json.dumps(value, sort_keys=True) for value in values.values()}
|
|
150
|
+
agreement[field] = {"agree": len(unique) <= 1, "values": values}
|
|
151
|
+
return agreement
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _mark(value: bool) -> str:
|
|
155
|
+
return "+" if value else "-"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
main(sys.argv[1:])
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Extraction engine adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from extractfold.engines.azure_docint_engine import AzureDocIntEngine
|
|
6
|
+
from extractfold.engines.base import ExtractionEngine, ExtractionResult, ExtractionRouter
|
|
7
|
+
from extractfold.engines.docfold_llm_engine import DocfoldLLMEngine
|
|
8
|
+
from extractfold.engines.fenic_engine import FenicEngine
|
|
9
|
+
from extractfold.engines.google_docai_engine import GoogleDocAIEngine
|
|
10
|
+
from extractfold.engines.instructor_engine import InstructorEngine
|
|
11
|
+
from extractfold.engines.lift_engine import LiftEngine
|
|
12
|
+
from extractfold.engines.llamaextract_engine import LlamaExtractEngine
|
|
13
|
+
from extractfold.engines.llm_structured_engine import LLMStructuredEngine
|
|
14
|
+
from extractfold.engines.nuextract_engine import NuExtractEngine
|
|
15
|
+
from extractfold.engines.provider_router_engine import ProviderRouterEngine
|
|
16
|
+
from extractfold.engines.textract_engine import TextractEngine
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AzureDocIntEngine",
|
|
20
|
+
"DocfoldLLMEngine",
|
|
21
|
+
"ExtractionEngine",
|
|
22
|
+
"ExtractionResult",
|
|
23
|
+
"ExtractionRouter",
|
|
24
|
+
"FenicEngine",
|
|
25
|
+
"GoogleDocAIEngine",
|
|
26
|
+
"InstructorEngine",
|
|
27
|
+
"LiftEngine",
|
|
28
|
+
"LLMStructuredEngine",
|
|
29
|
+
"LlamaExtractEngine",
|
|
30
|
+
"NuExtractEngine",
|
|
31
|
+
"ProviderRouterEngine",
|
|
32
|
+
"TextractEngine",
|
|
33
|
+
]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Shared helpers for extraction engine adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from extractfold.engines.base import ExtractionResult, JsonSchema, validate_data
|
|
12
|
+
from extractfold.parsing import normalize_data_payload
|
|
13
|
+
from extractfold.parsing import parse_json_object as _parse_json_object
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def maybe_await(value: Any) -> Any:
|
|
17
|
+
if inspect.isawaitable(value):
|
|
18
|
+
return await value
|
|
19
|
+
return value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_document_text(file_path: str) -> str:
|
|
23
|
+
path = Path(file_path)
|
|
24
|
+
try:
|
|
25
|
+
return path.read_text(encoding="utf-8")
|
|
26
|
+
except UnicodeDecodeError:
|
|
27
|
+
return path.read_bytes().decode("utf-8", errors="ignore")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def elapsed_ms(start: float) -> int:
|
|
31
|
+
return int((time.perf_counter() - start) * 1000)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def extract_data_payload(payload: Any) -> dict[str, Any]:
|
|
35
|
+
if isinstance(payload, ExtractionResult):
|
|
36
|
+
return payload.data
|
|
37
|
+
if hasattr(payload, "model_dump"):
|
|
38
|
+
dumped = payload.model_dump()
|
|
39
|
+
if isinstance(dumped, dict):
|
|
40
|
+
payload = dumped
|
|
41
|
+
return normalize_data_payload(payload)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_json_object(text: str) -> dict[str, Any]:
|
|
45
|
+
"""Backward-compatible object parser for engine adapters."""
|
|
46
|
+
return _parse_json_object(text)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def coerce_data_to_schema(data: dict[str, Any], schema: JsonSchema) -> dict[str, Any]:
|
|
50
|
+
properties = schema.get("properties", {})
|
|
51
|
+
if not isinstance(properties, dict):
|
|
52
|
+
return data
|
|
53
|
+
coerced = dict(data)
|
|
54
|
+
for key, field_schema in properties.items():
|
|
55
|
+
if key not in coerced or not isinstance(field_schema, dict):
|
|
56
|
+
continue
|
|
57
|
+
expected = field_schema.get("type")
|
|
58
|
+
if expected in {"number", "integer"} and isinstance(coerced[key], str):
|
|
59
|
+
coerced[key] = _coerce_number(coerced[key], integer=expected == "integer")
|
|
60
|
+
elif expected == "boolean" and isinstance(coerced[key], str):
|
|
61
|
+
lowered = coerced[key].strip().lower()
|
|
62
|
+
if lowered in {"true", "yes", "1"}:
|
|
63
|
+
coerced[key] = True
|
|
64
|
+
elif lowered in {"false", "no", "0"}:
|
|
65
|
+
coerced[key] = False
|
|
66
|
+
return coerced
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _coerce_number(value: str, *, integer: bool) -> float | int | str:
|
|
70
|
+
cleaned = re.sub(r"[^0-9.\-]", "", value)
|
|
71
|
+
if cleaned in {"", "-", "."}:
|
|
72
|
+
return value
|
|
73
|
+
try:
|
|
74
|
+
number = float(cleaned)
|
|
75
|
+
except ValueError:
|
|
76
|
+
return value
|
|
77
|
+
return int(number) if integer else number
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def confidence_from_fields(payload: Any) -> dict[str, float]:
|
|
81
|
+
if not isinstance(payload, dict):
|
|
82
|
+
return {}
|
|
83
|
+
if isinstance(payload.get("field_confidence"), dict):
|
|
84
|
+
return {
|
|
85
|
+
str(key): float(value)
|
|
86
|
+
for key, value in payload["field_confidence"].items()
|
|
87
|
+
if isinstance(value, (int, float))
|
|
88
|
+
}
|
|
89
|
+
fields = payload.get("fields")
|
|
90
|
+
if isinstance(fields, dict):
|
|
91
|
+
out: dict[str, float] = {}
|
|
92
|
+
for name, item in fields.items():
|
|
93
|
+
if isinstance(item, dict) and isinstance(item.get("confidence"), (int, float)):
|
|
94
|
+
out[str(name)] = float(item["confidence"])
|
|
95
|
+
return out
|
|
96
|
+
return {}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def provenance_from_fields(payload: Any) -> dict[str, Any]:
|
|
100
|
+
if not isinstance(payload, dict):
|
|
101
|
+
return {}
|
|
102
|
+
if isinstance(payload.get("provenance"), dict):
|
|
103
|
+
return payload["provenance"]
|
|
104
|
+
fields = payload.get("fields")
|
|
105
|
+
if isinstance(fields, dict):
|
|
106
|
+
out: dict[str, Any] = {}
|
|
107
|
+
for name, item in fields.items():
|
|
108
|
+
if isinstance(item, dict):
|
|
109
|
+
out[str(name)] = {
|
|
110
|
+
key: value
|
|
111
|
+
for key, value in item.items()
|
|
112
|
+
if key not in {"value", "confidence"}
|
|
113
|
+
}
|
|
114
|
+
return {key: value for key, value in out.items() if value}
|
|
115
|
+
return {}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def build_result(
|
|
119
|
+
*,
|
|
120
|
+
data: dict[str, Any],
|
|
121
|
+
engine_name: str,
|
|
122
|
+
schema: JsonSchema,
|
|
123
|
+
start: float,
|
|
124
|
+
raw: Any,
|
|
125
|
+
field_confidence: dict[str, float] | None = None,
|
|
126
|
+
provenance: dict[str, Any] | None = None,
|
|
127
|
+
metadata: dict[str, Any] | None = None,
|
|
128
|
+
pages: int | None = None,
|
|
129
|
+
) -> ExtractionResult:
|
|
130
|
+
data = coerce_data_to_schema(data, schema)
|
|
131
|
+
validation = validate_data(data, schema)
|
|
132
|
+
return ExtractionResult(
|
|
133
|
+
data=data,
|
|
134
|
+
engine_name=engine_name,
|
|
135
|
+
schema=schema,
|
|
136
|
+
field_confidence=field_confidence or {},
|
|
137
|
+
provenance=provenance or {},
|
|
138
|
+
valid=validation.valid,
|
|
139
|
+
raw=raw,
|
|
140
|
+
metadata=metadata or {},
|
|
141
|
+
pages=pages,
|
|
142
|
+
processing_time_ms=elapsed_ms(start),
|
|
143
|
+
)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Azure Document Intelligence extraction adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from extractfold.engines._common import build_result, maybe_await
|
|
10
|
+
from extractfold.engines.base import (
|
|
11
|
+
ExtractionCapabilities,
|
|
12
|
+
ExtractionEngine,
|
|
13
|
+
ExtractionResult,
|
|
14
|
+
load_schema,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_SUPPORTED_EXTENSIONS = {"pdf", "png", "jpg", "jpeg", "tiff", "tif", "bmp", "docx"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AzureDocIntEngine(ExtractionEngine):
|
|
21
|
+
"""Adapter for Azure Document Intelligence prebuilt/query-field extraction."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
endpoint: str | None = None,
|
|
26
|
+
key: str | None = None,
|
|
27
|
+
model_id: str = "prebuilt-layout",
|
|
28
|
+
client: Any | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._endpoint = endpoint or os.getenv("AZURE_DOCINT_ENDPOINT")
|
|
31
|
+
self._key = key or os.getenv("AZURE_DOCINT_KEY")
|
|
32
|
+
self._model_id = model_id
|
|
33
|
+
self._client = client
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def name(self) -> str:
|
|
37
|
+
return "azure_docint"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def supported_extensions(self) -> set[str]:
|
|
41
|
+
return _SUPPORTED_EXTENSIONS
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def capabilities(self) -> ExtractionCapabilities:
|
|
45
|
+
return ExtractionCapabilities(
|
|
46
|
+
field_confidence=True,
|
|
47
|
+
provenance=True,
|
|
48
|
+
nested_schemas=False,
|
|
49
|
+
batch=False,
|
|
50
|
+
local=False,
|
|
51
|
+
remote=True,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def is_available(self) -> bool:
|
|
55
|
+
if self._client is not None:
|
|
56
|
+
return True
|
|
57
|
+
try:
|
|
58
|
+
import azure.ai.documentintelligence # noqa: F401
|
|
59
|
+
except ImportError:
|
|
60
|
+
return False
|
|
61
|
+
return bool(self._endpoint and self._key)
|
|
62
|
+
|
|
63
|
+
async def extract(self, file_path: str, schema, **kwargs: Any) -> ExtractionResult:
|
|
64
|
+
schema_obj = load_schema(schema)
|
|
65
|
+
start = time.perf_counter()
|
|
66
|
+
if self._client is not None:
|
|
67
|
+
raw = await maybe_await(
|
|
68
|
+
self._client(
|
|
69
|
+
file_path=file_path,
|
|
70
|
+
schema=schema_obj,
|
|
71
|
+
model_id=self._model_id,
|
|
72
|
+
**kwargs,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
else:
|
|
76
|
+
raw = await maybe_await(self._call_azure(file_path, schema_obj, **kwargs))
|
|
77
|
+
data, confidence, provenance = self._parse_fields(raw)
|
|
78
|
+
pages = _pages_from_raw(raw)
|
|
79
|
+
return build_result(
|
|
80
|
+
data=data,
|
|
81
|
+
engine_name=self.name,
|
|
82
|
+
schema=schema_obj,
|
|
83
|
+
start=start,
|
|
84
|
+
raw=raw,
|
|
85
|
+
field_confidence=confidence,
|
|
86
|
+
provenance=provenance,
|
|
87
|
+
metadata={"model_id": self._model_id},
|
|
88
|
+
pages=pages,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def _call_azure(self, file_path: str, schema: dict[str, Any], **kwargs: Any) -> dict[str, Any]:
|
|
92
|
+
from azure.ai.documentintelligence import DocumentIntelligenceClient
|
|
93
|
+
from azure.ai.documentintelligence.models import AnalyzeDocumentRequest
|
|
94
|
+
from azure.core.credentials import AzureKeyCredential
|
|
95
|
+
|
|
96
|
+
if not self._endpoint or not self._key:
|
|
97
|
+
raise RuntimeError("AZURE_DOCINT_ENDPOINT and AZURE_DOCINT_KEY are required")
|
|
98
|
+
client = DocumentIntelligenceClient(
|
|
99
|
+
endpoint=self._endpoint,
|
|
100
|
+
credential=AzureKeyCredential(self._key),
|
|
101
|
+
)
|
|
102
|
+
with open(file_path, "rb") as file_obj:
|
|
103
|
+
poller = client.begin_analyze_document(
|
|
104
|
+
self._model_id,
|
|
105
|
+
AnalyzeDocumentRequest(bytes_source=file_obj.read()),
|
|
106
|
+
)
|
|
107
|
+
result = poller.result()
|
|
108
|
+
fields: dict[str, Any] = {}
|
|
109
|
+
for name in schema.get("properties", {}):
|
|
110
|
+
fields[name] = {"value": getattr(result, "content", ""), "confidence": None}
|
|
111
|
+
return {"fields": fields, "pages": len(getattr(result, "pages", []) or [])}
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _parse_fields(raw: Any) -> tuple[dict[str, Any], dict[str, float], dict[str, Any]]:
|
|
115
|
+
fields = raw.get("fields", {}) if isinstance(raw, dict) else {}
|
|
116
|
+
data: dict[str, Any] = {}
|
|
117
|
+
confidence: dict[str, float] = {}
|
|
118
|
+
provenance: dict[str, Any] = {}
|
|
119
|
+
for name, item in fields.items():
|
|
120
|
+
if isinstance(item, dict):
|
|
121
|
+
data[name] = item.get("value")
|
|
122
|
+
if isinstance(item.get("confidence"), (int, float)):
|
|
123
|
+
confidence[name] = float(item["confidence"])
|
|
124
|
+
provenance[name] = {
|
|
125
|
+
key: value
|
|
126
|
+
for key, value in item.items()
|
|
127
|
+
if key not in {"value", "confidence"}
|
|
128
|
+
}
|
|
129
|
+
else:
|
|
130
|
+
data[name] = item
|
|
131
|
+
return data, confidence, {key: value for key, value in provenance.items() if value}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _pages_from_raw(raw: Any) -> int | None:
|
|
135
|
+
return raw.get("pages") if isinstance(raw, dict) and isinstance(raw.get("pages"), int) else None
|