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.
@@ -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
+ ]
@@ -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