fastdocparse 0.1.0__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.
- docextract/__init__.py +48 -0
- docextract/cache.py +72 -0
- docextract/cli.py +140 -0
- docextract/config.py +38 -0
- docextract/example_schemas.py +13 -0
- docextract/grounding.py +306 -0
- docextract/json_repair.py +79 -0
- docextract/llm_client.py +82 -0
- docextract/ocr_engine.py +105 -0
- docextract/parser.py +313 -0
- docextract/pdf_utils.py +262 -0
- docextract/prompt_compiler.py +86 -0
- docextract/py.typed +0 -0
- docextract/result.py +42 -0
- docextract/schema.py +94 -0
- docextract/schema_compiler.py +68 -0
- docextract/schemas/invoice.json +73 -0
- docextract/schemas/shipment_manifest.json +40 -0
- fastdocparse-0.1.0.dist-info/METADATA +132 -0
- fastdocparse-0.1.0.dist-info/RECORD +24 -0
- fastdocparse-0.1.0.dist-info/WHEEL +5 -0
- fastdocparse-0.1.0.dist-info/entry_points.txt +2 -0
- fastdocparse-0.1.0.dist-info/licenses/LICENSE +21 -0
- fastdocparse-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Recover a JSON object from raw LLM output that isn't guaranteed to be clean JSON."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_json_from_llm(text: str) -> Dict[str, Any]:
|
|
9
|
+
"""Safely parse JSON from LLM output, handling markdown blocks and `<think>` tags."""
|
|
10
|
+
text = text.strip()
|
|
11
|
+
text = re.sub(r"<think\b[^>]*>.*?</think>", "", text, flags=re.DOTALL | re.IGNORECASE)
|
|
12
|
+
|
|
13
|
+
match = re.search(r"```json\s*(.*?)\s*```", text, re.DOTALL | re.IGNORECASE)
|
|
14
|
+
if match:
|
|
15
|
+
try:
|
|
16
|
+
return json.loads(match.group(1).strip())
|
|
17
|
+
except Exception:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
brace_positions = [i for i, c in enumerate(text) if c == "{"]
|
|
21
|
+
for pos in reversed(brace_positions):
|
|
22
|
+
end = text.rfind("}", pos)
|
|
23
|
+
if end > pos:
|
|
24
|
+
try:
|
|
25
|
+
obj = json.loads(text[pos:end + 1])
|
|
26
|
+
if isinstance(obj, dict):
|
|
27
|
+
return obj
|
|
28
|
+
except Exception:
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
# Nothing above found valid JSON — likely the response was cut off mid-object
|
|
32
|
+
# (hit max_tokens). Try to close whatever was left open, so the fields that were
|
|
33
|
+
# fully generated before the cutoff still come back instead of the whole response
|
|
34
|
+
# being discarded.
|
|
35
|
+
return _repair_truncated_json(text)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _repair_truncated_json(text: str) -> Dict[str, Any]:
|
|
39
|
+
start = text.find("{")
|
|
40
|
+
if start == -1:
|
|
41
|
+
return {}
|
|
42
|
+
snippet = text[start:]
|
|
43
|
+
|
|
44
|
+
stack = []
|
|
45
|
+
in_string = False
|
|
46
|
+
escape = False
|
|
47
|
+
|
|
48
|
+
for ch in snippet:
|
|
49
|
+
if in_string:
|
|
50
|
+
if escape:
|
|
51
|
+
escape = False
|
|
52
|
+
elif ch == "\\":
|
|
53
|
+
escape = True
|
|
54
|
+
elif ch == '"':
|
|
55
|
+
in_string = False
|
|
56
|
+
else:
|
|
57
|
+
if ch == '"':
|
|
58
|
+
in_string = True
|
|
59
|
+
elif ch in "{[":
|
|
60
|
+
stack.append(ch)
|
|
61
|
+
elif ch in "}]" and stack:
|
|
62
|
+
stack.pop()
|
|
63
|
+
|
|
64
|
+
repaired = snippet
|
|
65
|
+
if in_string:
|
|
66
|
+
repaired += '"'
|
|
67
|
+
# Drop a dangling comma or an incomplete "key": fragment with no value at all —
|
|
68
|
+
# neither can be closed into valid JSON by just appending brackets.
|
|
69
|
+
repaired = re.sub(r',\s*"[^"]*"?\s*:?\s*$', "", repaired)
|
|
70
|
+
repaired = re.sub(r',\s*$', "", repaired)
|
|
71
|
+
|
|
72
|
+
for opener in reversed(stack):
|
|
73
|
+
repaired += "}" if opener == "{" else "]"
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
obj = json.loads(repaired)
|
|
77
|
+
return obj if isinstance(obj, dict) else {}
|
|
78
|
+
except Exception:
|
|
79
|
+
return {}
|
docextract/llm_client.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""OpenAI-compatible LLM client for document extraction."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
from openai import (
|
|
7
|
+
APIConnectionError,
|
|
8
|
+
APITimeoutError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
OpenAI,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LLMClientError(Exception):
|
|
16
|
+
"""Raised when a call to the LLM endpoint fails (bad auth, unreachable, or exhausted retries)."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LLMClient:
|
|
20
|
+
"""A thin adapter over any OpenAI-compatible endpoint."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, base_url: Optional[str] = None, api_key: Optional[str] = None, model: Optional[str] = None):
|
|
23
|
+
"""Initialize the LLM client.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
base_url: The base URL of the OpenAI-compatible API. Defaults to OpenAI if None.
|
|
27
|
+
api_key: The API key. Can be dummy for local endpoints like Ollama.
|
|
28
|
+
model: The model string to use for completions.
|
|
29
|
+
"""
|
|
30
|
+
# If not provided, it will fallback to standard environment variables if used.
|
|
31
|
+
self.base_url = base_url
|
|
32
|
+
self.api_key = api_key or "dummy-key-for-local"
|
|
33
|
+
self.model = model or "gpt-4o-mini"
|
|
34
|
+
|
|
35
|
+
self.client = OpenAI(
|
|
36
|
+
base_url=self.base_url,
|
|
37
|
+
api_key=self.api_key,
|
|
38
|
+
timeout=60.0
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def _call(self, messages: List[dict], temperature: float, max_tokens: int, retries: int = 2, backoff: float = 1.0) -> str:
|
|
42
|
+
"""Run a chat completion, retrying transient failures and raising LLMClientError on exhaustion."""
|
|
43
|
+
last_error: Optional[Exception] = None
|
|
44
|
+
for attempt in range(retries + 1):
|
|
45
|
+
try:
|
|
46
|
+
response = self.client.chat.completions.create(
|
|
47
|
+
model=self.model,
|
|
48
|
+
messages=messages,
|
|
49
|
+
temperature=temperature,
|
|
50
|
+
max_tokens=max_tokens,
|
|
51
|
+
)
|
|
52
|
+
return response.choices[0].message.content or ""
|
|
53
|
+
except AuthenticationError as e:
|
|
54
|
+
raise LLMClientError(f"Authentication failed for model '{self.model}'. Check your API key.") from e
|
|
55
|
+
except (APIConnectionError, APITimeoutError, RateLimitError) as e:
|
|
56
|
+
last_error = e
|
|
57
|
+
if attempt < retries:
|
|
58
|
+
time.sleep(backoff * (attempt + 1))
|
|
59
|
+
continue
|
|
60
|
+
except Exception as e:
|
|
61
|
+
raise LLMClientError(f"LLM call to '{self.model}' failed: {e}") from e
|
|
62
|
+
|
|
63
|
+
raise LLMClientError(
|
|
64
|
+
f"Could not reach the LLM endpoint at {self.base_url or 'https://api.openai.com'} "
|
|
65
|
+
f"after {retries + 1} attempts: {last_error}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def extract(self, prompt: str, document_text: str, temperature: float = 0.0, max_tokens: int = 4096) -> str:
|
|
69
|
+
"""Run the prompt against the LLM to get the structured extraction."""
|
|
70
|
+
final_prompt = prompt.replace("{document_text}", document_text)
|
|
71
|
+
return self._call(
|
|
72
|
+
[
|
|
73
|
+
{"role": "system", "content": "You are a ultra-fast document extractor. Output strict JSON."},
|
|
74
|
+
{"role": "user", "content": final_prompt},
|
|
75
|
+
],
|
|
76
|
+
temperature=temperature,
|
|
77
|
+
max_tokens=max_tokens,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def complete(self, prompt: str, temperature: float = 0.0, max_tokens: int = 2048) -> str:
|
|
81
|
+
"""Run a single free-form prompt against the LLM (no document-extraction framing)."""
|
|
82
|
+
return self._call([{"role": "user", "content": prompt}], temperature=temperature, max_tokens=max_tokens)
|
docextract/ocr_engine.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Phase 1 OCR Engine: Fast, layout-aware OCR for images and scanned PDFs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import io
|
|
7
|
+
import logging
|
|
8
|
+
import threading
|
|
9
|
+
from typing import List, Tuple
|
|
10
|
+
from PIL import Image
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# Whether the package is installed at all — checked via find_spec, which locates the
|
|
15
|
+
# module without executing it, so this costs nothing at import time. The actual
|
|
16
|
+
# `import rapidocr_onnxruntime` (which pulls in onnxruntime, opencv, numpy — measured
|
|
17
|
+
# at ~1.2s) and RapidOCR() construction only happen lazily, on first real OCR call, via
|
|
18
|
+
# _get_rapid_ocr() below. Without this, every `import docextract` would eagerly pay
|
|
19
|
+
# that cost even for code that never touches OCR (e.g. just building a Schema).
|
|
20
|
+
HAS_RAPID_OCR = importlib.util.find_spec("rapidocr_onnxruntime") is not None
|
|
21
|
+
|
|
22
|
+
_rapid_ocr = None
|
|
23
|
+
_init_lock = threading.Lock()
|
|
24
|
+
_init_failed = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_rapid_ocr():
|
|
28
|
+
global _rapid_ocr, _init_failed
|
|
29
|
+
if _rapid_ocr is not None or _init_failed:
|
|
30
|
+
return _rapid_ocr
|
|
31
|
+
with _init_lock:
|
|
32
|
+
if _rapid_ocr is not None or _init_failed:
|
|
33
|
+
return _rapid_ocr
|
|
34
|
+
try:
|
|
35
|
+
from rapidocr_onnxruntime import RapidOCR
|
|
36
|
+
_rapid_ocr = RapidOCR()
|
|
37
|
+
except Exception:
|
|
38
|
+
logger.warning("RapidOCR failed to initialize; OCR extraction will return empty text.", exc_info=True)
|
|
39
|
+
_init_failed = True
|
|
40
|
+
return _rapid_ocr
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_ocr_lock = threading.Lock()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def extract_text_from_image_ocr(image_bytes: bytes, structured_mode: bool = False, min_confidence: float = 0.3) -> str:
|
|
47
|
+
"""Extract layout-preserved text from an image (PNG/JPG) using local OCR engine.
|
|
48
|
+
|
|
49
|
+
Returns a clean, line-grouped text representation preserving horizontal spacing.
|
|
50
|
+
"""
|
|
51
|
+
if not HAS_RAPID_OCR:
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
rapid_ocr = _get_rapid_ocr()
|
|
55
|
+
if rapid_ocr is None:
|
|
56
|
+
return ""
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
img = Image.open(io.BytesIO(image_bytes))
|
|
60
|
+
if img.mode != "RGB":
|
|
61
|
+
img = img.convert("RGB")
|
|
62
|
+
|
|
63
|
+
buf = io.BytesIO()
|
|
64
|
+
img.save(buf, format="JPEG")
|
|
65
|
+
|
|
66
|
+
with _ocr_lock:
|
|
67
|
+
result, _ = rapid_ocr(buf.getvalue())
|
|
68
|
+
if not result:
|
|
69
|
+
return ""
|
|
70
|
+
|
|
71
|
+
lines: List[Tuple[float, float, str]] = []
|
|
72
|
+
for box, text, conf in result:
|
|
73
|
+
conf_val = float(conf) if conf is not None else 0.0
|
|
74
|
+
if conf_val > min_confidence and text and str(text).strip():
|
|
75
|
+
y0 = min(pt[1] for pt in box)
|
|
76
|
+
x0 = min(pt[0] for pt in box)
|
|
77
|
+
lines.append((y0, x0, str(text).strip()))
|
|
78
|
+
|
|
79
|
+
lines.sort(key=lambda item: (round(item[0] / 15), item[1]))
|
|
80
|
+
|
|
81
|
+
grouped_lines: List[str] = []
|
|
82
|
+
current_y_group = -1
|
|
83
|
+
current_line_parts: List[str] = []
|
|
84
|
+
|
|
85
|
+
for y0, x0, text in lines:
|
|
86
|
+
group = round(y0 / 15)
|
|
87
|
+
if structured_mode:
|
|
88
|
+
text_formatted = f"[X:{int(x0)}] {text}"
|
|
89
|
+
else:
|
|
90
|
+
text_formatted = text
|
|
91
|
+
|
|
92
|
+
if group != current_y_group:
|
|
93
|
+
if current_line_parts:
|
|
94
|
+
grouped_lines.append(" ".join(current_line_parts))
|
|
95
|
+
current_y_group = group
|
|
96
|
+
current_line_parts = [text_formatted]
|
|
97
|
+
else:
|
|
98
|
+
current_line_parts.append(text_formatted)
|
|
99
|
+
|
|
100
|
+
if current_line_parts:
|
|
101
|
+
grouped_lines.append(" ".join(current_line_parts))
|
|
102
|
+
|
|
103
|
+
return "\n".join(grouped_lines)
|
|
104
|
+
except Exception:
|
|
105
|
+
return ""
|
docextract/parser.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Parser module orchestrating document extraction."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import pymupdf
|
|
11
|
+
|
|
12
|
+
from .cache import Cache, make_cache_key
|
|
13
|
+
from .config import ExtractionConfig
|
|
14
|
+
from .grounding import Issue, _is_present, check_substring, cross_check, validate_field_constraints
|
|
15
|
+
from .json_repair import parse_json_from_llm as _parse_json_from_llm
|
|
16
|
+
from .llm_client import LLMClient
|
|
17
|
+
from .ocr_engine import extract_text_from_image_ocr
|
|
18
|
+
from .pdf_utils import chunk_document_text, extract_text_from_pdf, pdf_to_page_images
|
|
19
|
+
from .prompt_compiler import compile_prompt
|
|
20
|
+
from .schema import Schema
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EmptyDocumentError(ValueError):
|
|
26
|
+
"""Raised when no text could be extracted from a document by any route."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_LAYOUT_TAG_RE = re.compile(r"\[X:\d+\]\s*")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _strip_layout_tags(text: str) -> str:
|
|
33
|
+
"""Remove the "[X:nnn]" column-position markers structured_mode adds for the LLM's
|
|
34
|
+
benefit before running any grounding check against the text. Those digits are pixel
|
|
35
|
+
coordinates, not document content — left in, they can splice into the middle of a
|
|
36
|
+
real value's character stream (e.g. an address split across OCR lines) and break a
|
|
37
|
+
fuzzy match that would otherwise succeed, flagging a correct value as ungrounded.
|
|
38
|
+
"""
|
|
39
|
+
return _LAYOUT_TAG_RE.sub("", text)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class UnknownIngestionKindError(ValueError):
|
|
43
|
+
"""Raised when extract()/aextract() is given a kind with no registered handler.
|
|
44
|
+
|
|
45
|
+
Kept distinct from a bare ValueError so callers (the CLI included) can tell "you
|
|
46
|
+
passed an unregistered --kind" apart from any other ValueError that might surface
|
|
47
|
+
from deeper in the pipeline (e.g. from inside a custom ingestion handler).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _merge_extracted_data(results: List[Dict[str, Any]], chunks: List[str], schema: Schema) -> Dict[str, Any]:
|
|
52
|
+
"""Merge per-chunk extractions into one result.
|
|
53
|
+
|
|
54
|
+
List fields concatenate across chunks (unchanged). For scalar fields, when more than
|
|
55
|
+
one chunk produced a non-null value, prefer whichever one is grounded in the chunk
|
|
56
|
+
it actually came from — we have that chunk's source text right here, so use it
|
|
57
|
+
instead of blindly taking the first chunk's answer regardless of whether it's real.
|
|
58
|
+
Falls back to first-non-null when nothing grounds (matches the old behavior exactly,
|
|
59
|
+
so a single-chunk document — the common case — is unaffected).
|
|
60
|
+
"""
|
|
61
|
+
if not results:
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
merged: Dict[str, Any] = {}
|
|
65
|
+
for f in schema.fields:
|
|
66
|
+
merged[f.name] = [] if f.type == "list" else None
|
|
67
|
+
|
|
68
|
+
for f in schema.fields:
|
|
69
|
+
if f.type == "list":
|
|
70
|
+
for res in results:
|
|
71
|
+
val = res.get(f.name)
|
|
72
|
+
if val and isinstance(val, list):
|
|
73
|
+
merged[f.name].extend(val)
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
first_non_null = None
|
|
77
|
+
grounded_value = None
|
|
78
|
+
for res, chunk_text in zip(results, chunks):
|
|
79
|
+
val = res.get(f.name)
|
|
80
|
+
if not _is_present(val):
|
|
81
|
+
continue
|
|
82
|
+
if first_non_null is None:
|
|
83
|
+
first_non_null = val
|
|
84
|
+
if grounded_value is None and check_substring(
|
|
85
|
+
val, _strip_layout_tags(chunk_text), numeric=f.is_numeric, date=f.is_date
|
|
86
|
+
):
|
|
87
|
+
grounded_value = val
|
|
88
|
+
break
|
|
89
|
+
merged[f.name] = grounded_value if grounded_value is not None else first_non_null
|
|
90
|
+
|
|
91
|
+
return merged
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Ingestion handlers keyed by document kind. To support a new input format
|
|
95
|
+
# (e.g. DOCX), add a handler here and register it — no changes needed to
|
|
96
|
+
# DocumentParser itself.
|
|
97
|
+
def _ingest_pdf(document_bytes: bytes, structured_mode: bool, config: ExtractionConfig) -> str:
|
|
98
|
+
doc_text = extract_text_from_pdf(document_bytes, max_pages=config.max_pages, structured_mode=structured_mode)
|
|
99
|
+
if len(doc_text.strip()) < 30:
|
|
100
|
+
# Scanned PDF: run local OCR on first page image
|
|
101
|
+
logger.info("Digital text layer too short (%d chars); falling back to OCR on page 1.", len(doc_text.strip()))
|
|
102
|
+
pages = pdf_to_page_images(
|
|
103
|
+
document_bytes, max_pages=1, dpi=config.pdf_render_dpi, max_dim=config.max_image_dim
|
|
104
|
+
)
|
|
105
|
+
if pages:
|
|
106
|
+
doc_text = extract_text_from_image_ocr(
|
|
107
|
+
pages[0].png_bytes, structured_mode=structured_mode, min_confidence=config.ocr_min_confidence
|
|
108
|
+
)
|
|
109
|
+
return doc_text
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _ingest_image(document_bytes: bytes, structured_mode: bool, config: ExtractionConfig) -> str:
|
|
113
|
+
return extract_text_from_image_ocr(
|
|
114
|
+
document_bytes, structured_mode=structured_mode, min_confidence=config.ocr_min_confidence
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
INGESTION_HANDLERS: Dict[str, Callable[[bytes, bool, ExtractionConfig], str]] = {
|
|
119
|
+
"pdf": _ingest_pdf,
|
|
120
|
+
"image": _ingest_image,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def register_default_ingestion_handler(kind: str, handler: Callable[[bytes, bool, ExtractionConfig], str]) -> None:
|
|
125
|
+
"""Register a new default ingestion route, e.g. for DOCX/XLSX support, process-wide.
|
|
126
|
+
|
|
127
|
+
Named distinctly from DocumentParser.register_ingestion_handler() on purpose — that
|
|
128
|
+
one scopes a handler to a single instance; this one changes what *new* instances get
|
|
129
|
+
by default. They used to share a name, which made it easy to call the process-wide
|
|
130
|
+
one by habit when a scoped registration was actually intended.
|
|
131
|
+
|
|
132
|
+
handler receives (document_bytes, structured_mode, config) and returns the extracted
|
|
133
|
+
text. This affects DocumentParser instances created *after* this call — each instance
|
|
134
|
+
copies the default registry at construction time, so it can't be silently clobbered by
|
|
135
|
+
another part of the program (or another test) registering a different handler under
|
|
136
|
+
the same kind later.
|
|
137
|
+
"""
|
|
138
|
+
INGESTION_HANDLERS[kind] = handler
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class DocumentParser:
|
|
142
|
+
"""Orchestrates document extraction."""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
client: LLMClient,
|
|
147
|
+
config: Optional[ExtractionConfig] = None,
|
|
148
|
+
cache: Optional[Cache] = None,
|
|
149
|
+
ingestion_handlers: Optional[Dict[str, Callable[[bytes, bool, ExtractionConfig], str]]] = None,
|
|
150
|
+
):
|
|
151
|
+
self.client = client
|
|
152
|
+
self.config = config or ExtractionConfig()
|
|
153
|
+
self.cache = cache
|
|
154
|
+
# Copied, not referenced, so registering a handler on one instance (or globally,
|
|
155
|
+
# after this instance already exists) never affects an instance already in use.
|
|
156
|
+
self._ingestion_handlers = dict(ingestion_handlers) if ingestion_handlers is not None else dict(INGESTION_HANDLERS)
|
|
157
|
+
|
|
158
|
+
def register_ingestion_handler(self, kind: str, handler: Callable[[bytes, bool, ExtractionConfig], str]) -> None:
|
|
159
|
+
"""Register an ingestion route scoped to this DocumentParser instance only."""
|
|
160
|
+
self._ingestion_handlers[kind] = handler
|
|
161
|
+
|
|
162
|
+
def extract(
|
|
163
|
+
self,
|
|
164
|
+
document_bytes: bytes,
|
|
165
|
+
schema: Schema,
|
|
166
|
+
is_image: bool = False,
|
|
167
|
+
rules: list = None,
|
|
168
|
+
kind: Optional[str] = None,
|
|
169
|
+
) -> Dict[str, Any]:
|
|
170
|
+
"""Extract information from a document matching the schema.
|
|
171
|
+
|
|
172
|
+
kind overrides routing when set (must be registered via register_ingestion_handler
|
|
173
|
+
first, e.g. kind="docx"). Otherwise routing falls back to is_image, as before.
|
|
174
|
+
"""
|
|
175
|
+
structured_mode = any(f.type == "list" for f in schema.fields)
|
|
176
|
+
resolved_kind = kind or ("image" if is_image else "pdf")
|
|
177
|
+
handler = self._resolve_handler(resolved_kind)
|
|
178
|
+
logger.info("Extracting schema=%r via kind=%r (structured_mode=%s)", schema.name, resolved_kind, structured_mode)
|
|
179
|
+
|
|
180
|
+
cache_key = None
|
|
181
|
+
if self.cache is not None and not rules:
|
|
182
|
+
# Keyed on the handler itself, not just the kind name — two DocumentParser
|
|
183
|
+
# instances can register different handlers under the same kind, and a
|
|
184
|
+
# shared cache must not conflate them.
|
|
185
|
+
cache_key = make_cache_key(document_bytes, schema, resolved_kind, self.config, handler)
|
|
186
|
+
cached = self.cache.get(cache_key)
|
|
187
|
+
if cached is not None:
|
|
188
|
+
logger.info("Cache hit for schema=%r.", schema.name)
|
|
189
|
+
return cached
|
|
190
|
+
|
|
191
|
+
doc_text = handler(document_bytes, structured_mode, self.config)
|
|
192
|
+
is_truncated, truncation_reason = self._check_truncation(document_bytes, resolved_kind)
|
|
193
|
+
if is_truncated:
|
|
194
|
+
logger.warning(truncation_reason)
|
|
195
|
+
|
|
196
|
+
if not doc_text.strip():
|
|
197
|
+
raise EmptyDocumentError("Could not extract any text from the document.")
|
|
198
|
+
|
|
199
|
+
chunks = chunk_document_text(doc_text, max_tokens=self.config.chunk_max_tokens)
|
|
200
|
+
logger.info("Document split into %d chunk(s) for extraction.", len(chunks))
|
|
201
|
+
merged_data = self._run_extraction(chunks, schema)
|
|
202
|
+
|
|
203
|
+
result = self._build_result(schema, merged_data, doc_text, rules, is_truncated, truncation_reason)
|
|
204
|
+
|
|
205
|
+
if cache_key is not None:
|
|
206
|
+
self.cache.set(cache_key, result)
|
|
207
|
+
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
async def aextract(
|
|
211
|
+
self,
|
|
212
|
+
document_bytes: bytes,
|
|
213
|
+
schema: Schema,
|
|
214
|
+
is_image: bool = False,
|
|
215
|
+
rules: list = None,
|
|
216
|
+
kind: Optional[str] = None,
|
|
217
|
+
) -> Dict[str, Any]:
|
|
218
|
+
"""Async wrapper around extract(). The work itself is still synchronous (OCR,
|
|
219
|
+
PDF parsing, and the LLM SDK calls are all blocking) — this runs it in a
|
|
220
|
+
background thread so an asyncio event loop (e.g. inside a FastAPI route) isn't
|
|
221
|
+
blocked while it happens, not because the underlying pipeline became non-blocking.
|
|
222
|
+
"""
|
|
223
|
+
loop = asyncio.get_running_loop()
|
|
224
|
+
call = functools.partial(self.extract, document_bytes, schema, is_image=is_image, rules=rules, kind=kind)
|
|
225
|
+
return await loop.run_in_executor(None, call)
|
|
226
|
+
|
|
227
|
+
def _resolve_handler(self, kind: str) -> Callable[[bytes, bool, ExtractionConfig], str]:
|
|
228
|
+
handler = self._ingestion_handlers.get(kind)
|
|
229
|
+
if handler is None:
|
|
230
|
+
raise UnknownIngestionKindError(f"No ingestion handler registered for document kind {kind!r}")
|
|
231
|
+
return handler
|
|
232
|
+
|
|
233
|
+
def _check_truncation(self, document_bytes: bytes, kind: str) -> tuple[bool, Optional[str]]:
|
|
234
|
+
if kind != "pdf":
|
|
235
|
+
return False, None
|
|
236
|
+
try:
|
|
237
|
+
doc = pymupdf.open(stream=document_bytes, filetype="pdf")
|
|
238
|
+
try:
|
|
239
|
+
if len(doc) > self.config.max_pages:
|
|
240
|
+
return True, f"Document is {len(doc)} pages long, truncated to {self.config.max_pages} pages."
|
|
241
|
+
return False, None
|
|
242
|
+
finally:
|
|
243
|
+
doc.close()
|
|
244
|
+
except Exception:
|
|
245
|
+
return False, None
|
|
246
|
+
|
|
247
|
+
def _run_extraction(self, chunks: List[str], schema: Schema) -> Dict[str, Any]:
|
|
248
|
+
prompt_template = compile_prompt(schema)
|
|
249
|
+
max_workers = self.config.max_concurrent_chunks
|
|
250
|
+
|
|
251
|
+
if max_workers <= 1 or len(chunks) <= 1:
|
|
252
|
+
# Default path: identical to the original sequential loop, no thread pool
|
|
253
|
+
# involved — keeps behavior (and mock call ordering in tests) unchanged.
|
|
254
|
+
parsed_chunks = [_parse_json_from_llm(self.client.extract(prompt_template, chunk)) for chunk in chunks]
|
|
255
|
+
else:
|
|
256
|
+
def process(chunk: str) -> Dict[str, Any]:
|
|
257
|
+
return _parse_json_from_llm(self.client.extract(prompt_template, chunk))
|
|
258
|
+
|
|
259
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
260
|
+
parsed_chunks = list(executor.map(process, chunks))
|
|
261
|
+
|
|
262
|
+
return _merge_extracted_data(parsed_chunks, chunks, schema)
|
|
263
|
+
|
|
264
|
+
def _build_result(
|
|
265
|
+
self,
|
|
266
|
+
schema: Schema,
|
|
267
|
+
merged_data: Dict[str, Any],
|
|
268
|
+
doc_text: str,
|
|
269
|
+
rules: Optional[list],
|
|
270
|
+
is_truncated: bool,
|
|
271
|
+
truncation_reason: Optional[str],
|
|
272
|
+
) -> Dict[str, Any]:
|
|
273
|
+
issues = cross_check(schema, merged_data, rules) + validate_field_constraints(schema, merged_data)
|
|
274
|
+
issues_by_field: Dict[str, List[Issue]] = {}
|
|
275
|
+
for issue in issues:
|
|
276
|
+
issues_by_field.setdefault(issue.field, []).append(issue)
|
|
277
|
+
issue_kind_to_flag = {
|
|
278
|
+
"cross_check": "failed_check",
|
|
279
|
+
"missing_required": "missing_required",
|
|
280
|
+
"invalid_format": "invalid_format",
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
final_result: Dict[str, Any] = {
|
|
284
|
+
"_meta": {
|
|
285
|
+
"truncated": is_truncated,
|
|
286
|
+
"truncation_reason": truncation_reason,
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
grounding_text = _strip_layout_tags(doc_text)
|
|
290
|
+
for f in schema.fields:
|
|
291
|
+
value = merged_data.get(f.name, None)
|
|
292
|
+
flags = []
|
|
293
|
+
confidence = "low"
|
|
294
|
+
|
|
295
|
+
if _is_present(value):
|
|
296
|
+
if check_substring(value, grounding_text, numeric=f.is_numeric, date=f.is_date):
|
|
297
|
+
flags.append("grounded")
|
|
298
|
+
confidence = "high"
|
|
299
|
+
else:
|
|
300
|
+
flags.append("ungrounded")
|
|
301
|
+
|
|
302
|
+
for issue in issues_by_field.get(f.name, []):
|
|
303
|
+
flag = issue_kind_to_flag.get(issue.kind, "failed_check")
|
|
304
|
+
if flag not in flags:
|
|
305
|
+
flags.append(flag)
|
|
306
|
+
|
|
307
|
+
final_result[f.name] = {
|
|
308
|
+
"value": value,
|
|
309
|
+
"confidence": confidence,
|
|
310
|
+
"flags": flags,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return final_result
|