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
docextract/pdf_utils.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""PDF utilities: render pages to images, base64 encoding, box coordinate transforms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
import pymupdf # PyMuPDF
|
|
10
|
+
from PIL import Image
|
|
11
|
+
|
|
12
|
+
# Rendering constants
|
|
13
|
+
PDF_RENDER_DPI = 150
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _safe_open_pdf(pdf_bytes: bytes) -> Optional["pymupdf.Document"]:
|
|
17
|
+
"""pymupdf.open() raises pymupdf.FileDataError/EmptyFileError (both RuntimeError
|
|
18
|
+
subclasses, not ValueError) on corrupt or non-PDF bytes — a raw file upload gone
|
|
19
|
+
wrong is an expected, common failure mode here, not a programming error, so it
|
|
20
|
+
shouldn't propagate as an unhandled crash. Returns None on failure; callers treat
|
|
21
|
+
that the same as "no text found," which the rest of the pipeline already handles."""
|
|
22
|
+
try:
|
|
23
|
+
return pymupdf.open(stream=pdf_bytes, filetype="pdf")
|
|
24
|
+
except Exception:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class PageImage:
|
|
30
|
+
"""A rendered PDF page."""
|
|
31
|
+
index: int # 0-based page index
|
|
32
|
+
png_bytes: bytes # PNG image bytes
|
|
33
|
+
width: int # image width in pixels
|
|
34
|
+
height: int # image height in pixels
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def pdf_to_page_images(pdf_bytes: bytes, max_pages: int = 3, dpi: int = PDF_RENDER_DPI, max_dim: int = 1536) -> List[PageImage]:
|
|
38
|
+
"""Render up to max_pages of a PDF to a PNG image at the given DPI.
|
|
39
|
+
|
|
40
|
+
Returns a list of PageImage in page order.
|
|
41
|
+
"""
|
|
42
|
+
pages: List[PageImage] = []
|
|
43
|
+
doc = _safe_open_pdf(pdf_bytes)
|
|
44
|
+
if doc is None:
|
|
45
|
+
return pages
|
|
46
|
+
try:
|
|
47
|
+
zoom = dpi / 72.0 # 72 DPI is the PDF default
|
|
48
|
+
matrix = pymupdf.Matrix(zoom, zoom)
|
|
49
|
+
for i in range(min(len(doc), max_pages)):
|
|
50
|
+
page = doc[i]
|
|
51
|
+
pix = page.get_pixmap(matrix=matrix, alpha=False)
|
|
52
|
+
|
|
53
|
+
# Convert to PIL Image for resizing
|
|
54
|
+
img = Image.open(io.BytesIO(pix.tobytes("png")))
|
|
55
|
+
|
|
56
|
+
# Resize to max_dim for better OCR fidelity
|
|
57
|
+
if img.width > max_dim or img.height > max_dim:
|
|
58
|
+
ratio = min(max_dim / img.width, max_dim / img.height)
|
|
59
|
+
new_size = (int(img.width * ratio), int(img.height * ratio))
|
|
60
|
+
img = img.resize(new_size, Image.Resampling.LANCZOS)
|
|
61
|
+
|
|
62
|
+
# Convert back to bytes
|
|
63
|
+
buf = io.BytesIO()
|
|
64
|
+
img.save(buf, format="PNG")
|
|
65
|
+
png = buf.getvalue()
|
|
66
|
+
|
|
67
|
+
pages.append(
|
|
68
|
+
PageImage(index=i, png_bytes=png, width=img.width, height=img.height)
|
|
69
|
+
)
|
|
70
|
+
finally:
|
|
71
|
+
doc.close()
|
|
72
|
+
return pages
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def image_bytes_to_page_image(img_bytes: bytes) -> PageImage:
|
|
76
|
+
"""Convert image bytes (PNG/JPG) to a PageImage.
|
|
77
|
+
|
|
78
|
+
The image is loaded, resized for faster processing, and re-encoded as PNG.
|
|
79
|
+
"""
|
|
80
|
+
img = Image.open(io.BytesIO(img_bytes))
|
|
81
|
+
# Convert to RGB if necessary (for JPEGs with transparency, etc.)
|
|
82
|
+
if img.mode != "RGB":
|
|
83
|
+
img = img.convert("RGB")
|
|
84
|
+
|
|
85
|
+
# Resize to max 1536x1536 for better OCR fidelity
|
|
86
|
+
max_dim = 1536
|
|
87
|
+
if img.width > max_dim or img.height > max_dim:
|
|
88
|
+
ratio = min(max_dim / img.width, max_dim / img.height)
|
|
89
|
+
new_size = (int(img.width * ratio), int(img.height * ratio))
|
|
90
|
+
img = img.resize(new_size, Image.Resampling.LANCZOS)
|
|
91
|
+
|
|
92
|
+
buf = io.BytesIO()
|
|
93
|
+
img.save(buf, format="PNG")
|
|
94
|
+
return PageImage(index=0, png_bytes=buf.getvalue(), width=img.width, height=img.height)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _grid_to_markdown(grid: List[List[str]]) -> str:
|
|
98
|
+
"""Convert a 2D table array into a clean Markdown table string."""
|
|
99
|
+
if not grid or not grid[0]:
|
|
100
|
+
return ""
|
|
101
|
+
header = [str(c or "").strip().replace("\n", " ") for c in grid[0]]
|
|
102
|
+
if not any(header):
|
|
103
|
+
return ""
|
|
104
|
+
lines = ["| " + " | ".join(header) + " |", "| " + " | ".join(["---"] * len(header)) + " |"]
|
|
105
|
+
for row in grid[1:]:
|
|
106
|
+
row_cells = [str(c or "").strip().replace("\n", " ") for c in row]
|
|
107
|
+
if not any(row_cells):
|
|
108
|
+
continue
|
|
109
|
+
while len(row_cells) < len(header):
|
|
110
|
+
row_cells.append("")
|
|
111
|
+
lines.append("| " + " | ".join(row_cells[:len(header)]) + " |")
|
|
112
|
+
return "\n".join(lines)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _bbox_overlaps(b1: Tuple[float, float, float, float], b2: Tuple[float, float, float, float], thresh: float = 0.5) -> bool:
|
|
116
|
+
"""Check if block b1 overlaps significantly with table bbox b2."""
|
|
117
|
+
x0 = max(b1[0], b2[0])
|
|
118
|
+
y0 = max(b1[1], b2[1])
|
|
119
|
+
x1 = min(b1[2], b2[2])
|
|
120
|
+
y1 = min(b1[3], b2[3])
|
|
121
|
+
if x1 <= x0 or y1 <= y0:
|
|
122
|
+
return False
|
|
123
|
+
inter_area = (x1 - x0) * (y1 - y0)
|
|
124
|
+
b1_area = (b1[2] - b1[0]) * (b1[3] - b1[1])
|
|
125
|
+
return (inter_area / b1_area) > thresh if b1_area > 0 else False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def extract_layout_markdown_from_pdf(pdf_bytes: bytes, max_pages: int = 15, structured_mode: bool = False) -> str:
|
|
129
|
+
"""Phase 1 Engine: Extract digital PDF into layout-preserved Markdown text."""
|
|
130
|
+
doc = _safe_open_pdf(pdf_bytes)
|
|
131
|
+
pages_md: List[str] = []
|
|
132
|
+
if doc is None:
|
|
133
|
+
return ""
|
|
134
|
+
try:
|
|
135
|
+
for i in range(min(len(doc), max_pages)):
|
|
136
|
+
page = doc[i]
|
|
137
|
+
|
|
138
|
+
# 1. Extract tables
|
|
139
|
+
table_bboxes: List[Tuple[float, float, float, float]] = []
|
|
140
|
+
table_markdowns: List[Tuple[float, str]] = [] # (y0, markdown_str)
|
|
141
|
+
try:
|
|
142
|
+
tabs = page.find_tables()
|
|
143
|
+
for tab in tabs.tables:
|
|
144
|
+
grid = tab.extract()
|
|
145
|
+
md = _grid_to_markdown(grid)
|
|
146
|
+
if md:
|
|
147
|
+
table_bboxes.append(tab.bbox)
|
|
148
|
+
table_markdowns.append((tab.bbox[1], md))
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
# 2. Extract text blocks outside tables
|
|
153
|
+
blocks = page.get_text("blocks")
|
|
154
|
+
non_table_blocks: List[Tuple[float, float, str]] = [] # (y0, x0, text)
|
|
155
|
+
for b in blocks:
|
|
156
|
+
if len(b) >= 5 and b[4].strip():
|
|
157
|
+
bbox = (b[0], b[1], b[2], b[3])
|
|
158
|
+
# Check overlap with any extracted table
|
|
159
|
+
if not any(_bbox_overlaps(bbox, tb) for tb in table_bboxes):
|
|
160
|
+
if structured_mode:
|
|
161
|
+
non_table_blocks.append((b[1], b[0], f"[X:{int(b[0])}] {b[4].strip()}"))
|
|
162
|
+
else:
|
|
163
|
+
non_table_blocks.append((b[1], b[0], b[4].strip()))
|
|
164
|
+
|
|
165
|
+
# 3. Merge blocks & tables spatially top-to-bottom
|
|
166
|
+
all_elements: List[Tuple[float, float, str]] = []
|
|
167
|
+
for y0, x0, text in non_table_blocks:
|
|
168
|
+
all_elements.append((y0, x0, text))
|
|
169
|
+
for y0, md in table_markdowns:
|
|
170
|
+
all_elements.append((y0, 0.0, f"\n{md}\n"))
|
|
171
|
+
|
|
172
|
+
# Sort by Y position (top-to-bottom) then X position
|
|
173
|
+
all_elements.sort(key=lambda elem: (round(elem[0] / 15), elem[1]))
|
|
174
|
+
|
|
175
|
+
page_content = "\n\n".join(text for _, _, text in all_elements)
|
|
176
|
+
if page_content.strip():
|
|
177
|
+
pages_md.append(f"--- PAGE {i + 1} ---\n{page_content}")
|
|
178
|
+
finally:
|
|
179
|
+
doc.close()
|
|
180
|
+
return "\n\n".join(pages_md)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def extract_text_from_pdf(pdf_bytes: bytes, max_pages: int = 15, structured_mode: bool = False) -> str:
|
|
184
|
+
"""Extract layout-preserved markdown text from a digital PDF."""
|
|
185
|
+
layout_md = extract_layout_markdown_from_pdf(pdf_bytes, max_pages=max_pages, structured_mode=structured_mode)
|
|
186
|
+
if layout_md.strip():
|
|
187
|
+
return layout_md
|
|
188
|
+
|
|
189
|
+
# Fallback to plain text
|
|
190
|
+
doc = _safe_open_pdf(pdf_bytes)
|
|
191
|
+
texts: List[str] = []
|
|
192
|
+
if doc is None:
|
|
193
|
+
return ""
|
|
194
|
+
try:
|
|
195
|
+
for i in range(min(len(doc), max_pages)):
|
|
196
|
+
page_text = doc[i].get_text("text")
|
|
197
|
+
if page_text.strip():
|
|
198
|
+
texts.append(f"--- Page {i + 1} ---\n{page_text}")
|
|
199
|
+
finally:
|
|
200
|
+
doc.close()
|
|
201
|
+
return "\n\n".join(texts)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def is_digital_pdf(pdf_bytes: bytes, min_chars: int = 80) -> bool:
|
|
205
|
+
"""Return True if the PDF has a readable text layer (i.e. is not a scan)."""
|
|
206
|
+
doc = _safe_open_pdf(pdf_bytes)
|
|
207
|
+
if doc is None:
|
|
208
|
+
return False
|
|
209
|
+
total_chars = 0
|
|
210
|
+
try:
|
|
211
|
+
for i in range(min(len(doc), 3)):
|
|
212
|
+
total_chars += len(doc[i].get_text("text").strip())
|
|
213
|
+
if total_chars >= min_chars:
|
|
214
|
+
return True
|
|
215
|
+
finally:
|
|
216
|
+
doc.close()
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def chunk_document_text(text: str, max_tokens: int = 3000) -> List[str]:
|
|
221
|
+
"""
|
|
222
|
+
Split document text into chunks based on page delimiters.
|
|
223
|
+
Approximates tokens via character count (4 chars ~ 1 token).
|
|
224
|
+
"""
|
|
225
|
+
max_chars = max_tokens * 4
|
|
226
|
+
pages = text.split("--- PAGE ")
|
|
227
|
+
# The first element might be empty or preamble before the first page.
|
|
228
|
+
# We need to re-attach the delimiter for the rest.
|
|
229
|
+
page_texts = []
|
|
230
|
+
for i, p in enumerate(pages):
|
|
231
|
+
if not p.strip():
|
|
232
|
+
continue
|
|
233
|
+
prefix = "--- PAGE " if i > 0 else ""
|
|
234
|
+
page_texts.append(prefix + p)
|
|
235
|
+
|
|
236
|
+
chunks = []
|
|
237
|
+
current_chunk = ""
|
|
238
|
+
|
|
239
|
+
for pt in page_texts:
|
|
240
|
+
if len(current_chunk) + len(pt) > max_chars and current_chunk:
|
|
241
|
+
chunks.append(current_chunk.strip())
|
|
242
|
+
current_chunk = pt
|
|
243
|
+
else:
|
|
244
|
+
current_chunk += "\n\n" + pt if current_chunk else pt
|
|
245
|
+
|
|
246
|
+
if current_chunk:
|
|
247
|
+
chunks.append(current_chunk.strip())
|
|
248
|
+
|
|
249
|
+
# A single page (or a document with no "--- PAGE" delimiters at all) can itself
|
|
250
|
+
# exceed max_chars — the loop above only splits *between* pages, so that chunk
|
|
251
|
+
# would otherwise sail past the configured budget by an arbitrary amount. Hard-split
|
|
252
|
+
# anything still oversized; a mid-sentence cut here is far better than silently
|
|
253
|
+
# blowing an LLM's context window.
|
|
254
|
+
final_chunks = []
|
|
255
|
+
for chunk in chunks:
|
|
256
|
+
if len(chunk) <= max_chars:
|
|
257
|
+
final_chunks.append(chunk)
|
|
258
|
+
else:
|
|
259
|
+
for start in range(0, len(chunk), max_chars):
|
|
260
|
+
final_chunks.append(chunk[start:start + max_chars])
|
|
261
|
+
|
|
262
|
+
return final_chunks
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Prompt compiler for generating dynamic Chain-of-Thought prompts from schemas."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
from .schema import Schema, Field
|
|
6
|
+
|
|
7
|
+
def _generate_json_structure(fields: list[Field]) -> Dict[str, Any]:
|
|
8
|
+
"""Generate a sample JSON structure representing the schema."""
|
|
9
|
+
structure = {}
|
|
10
|
+
for f in fields:
|
|
11
|
+
if f.type == "list" and f.sub_fields:
|
|
12
|
+
structure[f.name] = [_generate_json_structure(f.sub_fields)]
|
|
13
|
+
else:
|
|
14
|
+
structure[f.name] = f"<{f.type}> or null"
|
|
15
|
+
return structure
|
|
16
|
+
|
|
17
|
+
def compile_prompt(schema: Schema) -> str:
|
|
18
|
+
"""Compile a deterministic CoT prompt from a Pydantic schema."""
|
|
19
|
+
|
|
20
|
+
# Generate reasoning steps
|
|
21
|
+
reasoning_steps = []
|
|
22
|
+
for f in schema.fields:
|
|
23
|
+
if f.type == "list" and f.sub_fields:
|
|
24
|
+
sub_names = ", ".join([sub.name for sub in f.sub_fields])
|
|
25
|
+
reasoning_steps.append(f"• What are the items for '{f.name}'? Identify {sub_names}.")
|
|
26
|
+
else:
|
|
27
|
+
reasoning_steps.append(f"• What is the '{f.name}'? {f.description}")
|
|
28
|
+
|
|
29
|
+
# Generate extraction guide
|
|
30
|
+
extraction_guide = []
|
|
31
|
+
idx = 1
|
|
32
|
+
for f in schema.fields:
|
|
33
|
+
if f.type == "list" and f.sub_fields:
|
|
34
|
+
sub_desc = ", ".join([f"{sub.name}: {sub.description}" for sub in f.sub_fields])
|
|
35
|
+
extraction_guide.append(f"{idx}. {f.name}: List of items containing ({sub_desc}).")
|
|
36
|
+
else:
|
|
37
|
+
extraction_guide.append(f"{idx}. {f.name}: {f.description}")
|
|
38
|
+
idx += 1
|
|
39
|
+
|
|
40
|
+
json_structure = _generate_json_structure(schema.fields)
|
|
41
|
+
json_str = json.dumps(json_structure, indent=2)
|
|
42
|
+
|
|
43
|
+
examples_text = ""
|
|
44
|
+
json_step_num = 3
|
|
45
|
+
if schema.examples:
|
|
46
|
+
examples_text = "\n═══════════════════════════════════════════════\nSTEP 3 — EXAMPLES\n═══════════════════════════════════════════════\n"
|
|
47
|
+
for i, (doc_snippet, expected_json) in enumerate(schema.examples, 1):
|
|
48
|
+
examples_text += f"Example {i}:\nDocument Snippet:\n{doc_snippet}\n\nExpected Output:\n```json\n{json.dumps(expected_json, indent=2)}\n```\n\n"
|
|
49
|
+
json_step_num = 4
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
prompt = f"""You are an expert document analyst.
|
|
53
|
+
|
|
54
|
+
Below is text extracted directly from a document (via layout-aware text parsing or OCR).
|
|
55
|
+
Analyze the document step by step and extract the requested fields for schema: {schema.name}.
|
|
56
|
+
|
|
57
|
+
═══════════════════════════════════════════════
|
|
58
|
+
STEP 1 — DOCUMENT REASONING (think out loud)
|
|
59
|
+
═══════════════════════════════════════════════
|
|
60
|
+
{chr(10).join(reasoning_steps)}
|
|
61
|
+
|
|
62
|
+
═══════════════════════════════════════════════
|
|
63
|
+
STEP 2 — FIELD EXTRACTION GUIDE
|
|
64
|
+
═══════════════════════════════════════════════
|
|
65
|
+
{chr(10).join(extraction_guide)}
|
|
66
|
+
|
|
67
|
+
{examples_text}═══════════════════════════════════════════════
|
|
68
|
+
STEP {json_step_num} — JSON OUTPUT
|
|
69
|
+
═══════════════════════════════════════════════
|
|
70
|
+
Output ONLY a valid JSON object in this exact structure:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{json_str}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
RULES:
|
|
77
|
+
- For addresses or multiline text: NEVER leave null if text is visible in the document. Combine all lines into a single string.
|
|
78
|
+
- Numbers only for numeric fields. No symbols/commas.
|
|
79
|
+
- If a field is missing, set to null.
|
|
80
|
+
|
|
81
|
+
══════════════════════
|
|
82
|
+
DOCUMENT TEXT:
|
|
83
|
+
══════════════════════
|
|
84
|
+
{{document_text}}
|
|
85
|
+
"""
|
|
86
|
+
return prompt
|
docextract/py.typed
ADDED
|
File without changes
|
docextract/result.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Typed view of DocumentParser.extract()'s output.
|
|
2
|
+
|
|
3
|
+
extract() itself keeps returning a plain dict — that's what the CLI needs for
|
|
4
|
+
zero-friction json.dumps(), and changing it would break every existing caller and
|
|
5
|
+
test. This model is an opt-in convenience for callers who want attribute access
|
|
6
|
+
and validation instead of raw dict indexing:
|
|
7
|
+
|
|
8
|
+
result = parser.extract(document_bytes, schema)
|
|
9
|
+
typed = ExtractionResult.from_raw(result)
|
|
10
|
+
typed.fields["invoice_number"].value
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FieldResult(BaseModel):
|
|
19
|
+
value: Any
|
|
20
|
+
confidence: str
|
|
21
|
+
flags: List[str]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ExtractionMeta(BaseModel):
|
|
25
|
+
truncated: bool
|
|
26
|
+
truncation_reason: Optional[str] = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ExtractionResult(BaseModel):
|
|
30
|
+
meta: ExtractionMeta
|
|
31
|
+
fields: Dict[str, FieldResult]
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_raw(cls, raw: Dict[str, Any]) -> "ExtractionResult":
|
|
35
|
+
"""Build a typed result from the dict returned by DocumentParser.extract()."""
|
|
36
|
+
raw = dict(raw)
|
|
37
|
+
meta = raw.pop("_meta", None)
|
|
38
|
+
if meta is None:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"Missing '_meta' key — expected the dict returned by DocumentParser.extract()."
|
|
41
|
+
)
|
|
42
|
+
return cls(meta=ExtractionMeta(**meta), fields={k: FieldResult(**v) for k, v in raw.items()})
|
docextract/schema.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Schema definitions for document extraction."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Optional, Tuple, Dict, Any, Union
|
|
6
|
+
from pydantic import BaseModel, field_validator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Field(BaseModel):
|
|
10
|
+
"""Defines a single field to extract from a document."""
|
|
11
|
+
name: str
|
|
12
|
+
description: str
|
|
13
|
+
type: str = "text" # "text", "number", "date", "currency", "list"
|
|
14
|
+
required: bool = False
|
|
15
|
+
pattern: Optional[str] = None # regex the extracted string value must fully match, e.g. HS/container codes
|
|
16
|
+
enum: Optional[List[str]] = None # allowed values, e.g. shipment status, incoterms
|
|
17
|
+
sub_fields: Optional[List['Field']] = None
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def is_numeric(self) -> bool:
|
|
21
|
+
"""True for field types that should be grounded/merged by parsed numeric value
|
|
22
|
+
rather than exact string match (see grounding.check_substring's numeric= flag)."""
|
|
23
|
+
return self.type in ("number", "currency")
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def is_date(self) -> bool:
|
|
27
|
+
"""True for date fields — grounded by parsed calendar date rather than exact
|
|
28
|
+
string match (see grounding.check_substring's date= flag), since a normalized
|
|
29
|
+
"2021-04-22" won't literally appear in a source document that says "22Apr2021"."""
|
|
30
|
+
return self.type == "date"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Schema(BaseModel):
|
|
34
|
+
"""Defines a complete document schema."""
|
|
35
|
+
name: str = "DocumentSchema"
|
|
36
|
+
fields: List[Field]
|
|
37
|
+
examples: Optional[List[Tuple[str, Dict[str, Any]]]] = None
|
|
38
|
+
|
|
39
|
+
@field_validator('fields')
|
|
40
|
+
@classmethod
|
|
41
|
+
def validate_field_names(cls, v):
|
|
42
|
+
if any(f.name == "_meta" for f in v):
|
|
43
|
+
raise ValueError(
|
|
44
|
+
"'_meta' is a reserved field name (used for truncation metadata in the "
|
|
45
|
+
"extraction result) and cannot be used as a schema field name."
|
|
46
|
+
)
|
|
47
|
+
return v
|
|
48
|
+
|
|
49
|
+
@field_validator('examples')
|
|
50
|
+
@classmethod
|
|
51
|
+
def validate_examples(cls, v):
|
|
52
|
+
if v is not None:
|
|
53
|
+
for ex in v:
|
|
54
|
+
if not isinstance(ex, tuple) or len(ex) != 2:
|
|
55
|
+
raise ValueError("Each example must be a tuple of (document_snippet, expected_json)")
|
|
56
|
+
if not isinstance(ex[0], str) or not isinstance(ex[1], dict):
|
|
57
|
+
raise ValueError("Each example must be a tuple of (str, dict)")
|
|
58
|
+
return v
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Schema":
|
|
62
|
+
"""Build a Schema from a plain dict (as loaded from JSON/YAML) — no Python code needed."""
|
|
63
|
+
return cls.model_validate(data)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_json(cls, path: Union[str, Path]) -> "Schema":
|
|
67
|
+
"""Load a Schema from a .json file."""
|
|
68
|
+
with open(path, "r") as f:
|
|
69
|
+
return cls.from_dict(json.load(f))
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_yaml(cls, path: Union[str, Path]) -> "Schema":
|
|
73
|
+
"""Load a Schema from a .yaml/.yml file."""
|
|
74
|
+
import yaml # optional dependency; only needed for this path
|
|
75
|
+
|
|
76
|
+
with open(path, "r") as f:
|
|
77
|
+
return cls.from_dict(yaml.safe_load(f))
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_file(cls, path: Union[str, Path]) -> "Schema":
|
|
81
|
+
"""Load a Schema from a .json or .yaml/.yml file, dispatching on extension."""
|
|
82
|
+
path = Path(path)
|
|
83
|
+
suffix = path.suffix.lower()
|
|
84
|
+
if suffix in (".yaml", ".yml"):
|
|
85
|
+
return cls.from_yaml(path)
|
|
86
|
+
if suffix == ".json":
|
|
87
|
+
return cls.from_json(path)
|
|
88
|
+
raise ValueError(f"Unsupported schema file extension {suffix!r} (expected .json, .yaml, or .yml)")
|
|
89
|
+
|
|
90
|
+
def get_field(self, name: str) -> Optional[Field]:
|
|
91
|
+
for f in self.fields:
|
|
92
|
+
if f.name == name:
|
|
93
|
+
return f
|
|
94
|
+
return None
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Compile a plain-English description of what to extract into a Schema, using the LLM.
|
|
2
|
+
|
|
3
|
+
Lets a non-technical user describe fields in their own words instead of writing JSON:
|
|
4
|
+
|
|
5
|
+
"I want the bill of lading number (starts with BL-), the container number,
|
|
6
|
+
the shipment status which is one of in_transit, delivered, or customs_hold,
|
|
7
|
+
and the destination country. Bill of lading and destination are required."
|
|
8
|
+
|
|
9
|
+
The LLM proposes field names/types/constraints; the caller should review the
|
|
10
|
+
result (or the saved schema file) before relying on it for real extraction —
|
|
11
|
+
a hallucinated schema silently corrupts every later run, so this is meant as
|
|
12
|
+
an authoring aid with a human in the loop, not a fully implicit step.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Any, Dict
|
|
16
|
+
|
|
17
|
+
from .json_repair import parse_json_from_llm
|
|
18
|
+
from .llm_client import LLMClient
|
|
19
|
+
from .schema import Schema
|
|
20
|
+
|
|
21
|
+
SCHEMA_GEN_PROMPT = """You are a data schema designer. A user described, in plain language, what \
|
|
22
|
+
information they want extracted from a document. Convert their description into a JSON schema.
|
|
23
|
+
|
|
24
|
+
Output ONLY a valid JSON object with this exact shape:
|
|
25
|
+
{{
|
|
26
|
+
"name": "<short PascalCase schema name>",
|
|
27
|
+
"fields": [
|
|
28
|
+
{{
|
|
29
|
+
"name": "<snake_case field name>",
|
|
30
|
+
"description": "<clear description of what to extract, written for a document-extraction assistant to follow>",
|
|
31
|
+
"type": "text" | "number" | "date" | "currency" | "list",
|
|
32
|
+
"required": true | false,
|
|
33
|
+
"pattern": "<regex the value must fully match, or null if the user didn't mention a specific format>",
|
|
34
|
+
"enum": ["<allowed value>", "..."] or null,
|
|
35
|
+
"sub_fields": [ {{ "name": "...", "description": "..." }} ]
|
|
36
|
+
}}
|
|
37
|
+
]
|
|
38
|
+
}}
|
|
39
|
+
|
|
40
|
+
Rules:
|
|
41
|
+
- Use "list" + "sub_fields" only for repeating/tabular data (e.g. line items, containers). Every other field omits "sub_fields" (null).
|
|
42
|
+
- Set "required": true only if the description implies the field is essential/mandatory.
|
|
43
|
+
- Set "pattern" only when the description mentions a specific code/ID format (e.g. "starts with BL-", "6 digits"). Otherwise null.
|
|
44
|
+
- Set "enum" only when the description lists a fixed set of allowed values. Otherwise null.
|
|
45
|
+
- Field names must be snake_case, no spaces, no punctuation.
|
|
46
|
+
- Do not invent fields the user didn't ask for.
|
|
47
|
+
|
|
48
|
+
User's description:
|
|
49
|
+
\"\"\"
|
|
50
|
+
{description}
|
|
51
|
+
\"\"\"
|
|
52
|
+
|
|
53
|
+
Output ONLY the JSON object, nothing else."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def compile_schema_from_description(description: str, client: LLMClient) -> Schema:
|
|
57
|
+
"""Turn a natural-language description into a Schema via one LLM call."""
|
|
58
|
+
prompt = SCHEMA_GEN_PROMPT.format(description=description)
|
|
59
|
+
raw_response = client.complete(prompt)
|
|
60
|
+
data: Dict[str, Any] = parse_json_from_llm(raw_response)
|
|
61
|
+
|
|
62
|
+
if not data or not data.get("fields"):
|
|
63
|
+
raise ValueError(
|
|
64
|
+
"Could not turn that description into a schema. Try listing the specific fields you want, "
|
|
65
|
+
"e.g. 'invoice number, total price, and a list of line items with product name and quantity'."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return Schema.from_dict(data)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Invoice",
|
|
3
|
+
"fields": [
|
|
4
|
+
{
|
|
5
|
+
"name": "invoice_number",
|
|
6
|
+
"description": "Invoice No / Bill No / Reference number.",
|
|
7
|
+
"required": true
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"name": "invoice_date",
|
|
11
|
+
"description": "YYYY-MM-DD format.",
|
|
12
|
+
"type": "date"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "exporter_name",
|
|
16
|
+
"description": "Full name of the seller/exporter/supplier issuing the invoice. Look for company name at the top left, letterhead, or footer signature block."
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "exporter_address",
|
|
20
|
+
"description": "Complete address of the exporter (combine all address lines with comma separators)."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "importer_name",
|
|
24
|
+
"description": "Full name of the buyer/consignee/importer. Look for 'Consignee', 'Bill To', 'Buyer', 'Importer', 'Ship To'."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "importer_address",
|
|
28
|
+
"description": "Complete address of the importer/consignee (combine all address lines)."
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "currency",
|
|
32
|
+
"description": "3-letter ISO code (USD, EUR, GBP, INR, etc.).",
|
|
33
|
+
"enum": ["USD", "EUR", "GBP", "INR", "JPY", "CNY"]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "total_price",
|
|
37
|
+
"description": "Final grand total invoice amount due (numeric only).",
|
|
38
|
+
"type": "number",
|
|
39
|
+
"required": true
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "line_items",
|
|
43
|
+
"description": "Every product row.",
|
|
44
|
+
"type": "list",
|
|
45
|
+
"sub_fields": [
|
|
46
|
+
{ "name": "product_name", "description": "Name of the product" },
|
|
47
|
+
{ "name": "product_description", "description": "Description of the product" },
|
|
48
|
+
{ "name": "hs_code", "description": "HSN/HS code" },
|
|
49
|
+
{ "name": "unit_price", "description": "Price per unit" },
|
|
50
|
+
{ "name": "quantity", "description": "Quantity" }
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"examples": [
|
|
55
|
+
[
|
|
56
|
+
"GLOBAL EXPORTS INC.\n123 Market St, San Francisco, CA 94105, USA\n\nBILL TO:\nAcme Corp\n456 Industrial Way, Seattle, WA 98101\n\nInvoice: INV-9011\nDate: 04/15/2023\n\nItem\tQty\tPrice\tTotal\nWidget A\t10\t$5.00\t$50.00\nSuper Widget\t2\t$25.00\t$50.00\n\nGrand Total: USD 100.00",
|
|
57
|
+
{
|
|
58
|
+
"invoice_number": "INV-9011",
|
|
59
|
+
"invoice_date": "2023-04-15",
|
|
60
|
+
"exporter_name": "GLOBAL EXPORTS INC.",
|
|
61
|
+
"exporter_address": "123 Market St, San Francisco, CA 94105, USA",
|
|
62
|
+
"importer_name": "Acme Corp",
|
|
63
|
+
"importer_address": "456 Industrial Way, Seattle, WA 98101",
|
|
64
|
+
"currency": "USD",
|
|
65
|
+
"total_price": 100.0,
|
|
66
|
+
"line_items": [
|
|
67
|
+
{ "product_name": "Widget A", "product_description": "Widget A", "hs_code": null, "unit_price": 5.0, "quantity": 10 },
|
|
68
|
+
{ "product_name": "Super Widget", "product_description": "Super Widget", "hs_code": null, "unit_price": 25.0, "quantity": 2 }
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
]
|
|
73
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ShipmentManifest",
|
|
3
|
+
"fields": [
|
|
4
|
+
{
|
|
5
|
+
"name": "bill_of_lading",
|
|
6
|
+
"description": "Bill of Lading (B/L) number identifying the shipment.",
|
|
7
|
+
"required": true,
|
|
8
|
+
"pattern": "BL-\\d{4,}"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"name": "container_number",
|
|
12
|
+
"description": "Shipping container number, ISO 6346 format (4 letters + 7 digits).",
|
|
13
|
+
"pattern": "[A-Z]{4}\\d{7}"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "hs_code",
|
|
17
|
+
"description": "Harmonized System code for the primary goods.",
|
|
18
|
+
"pattern": "\\d{6,10}"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"name": "shipment_status",
|
|
22
|
+
"description": "Current status of the shipment.",
|
|
23
|
+
"enum": ["in_transit", "delivered", "customs_hold", "delayed"]
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "origin_port",
|
|
27
|
+
"description": "Port or place of loading."
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "destination_country",
|
|
31
|
+
"description": "Country of final destination.",
|
|
32
|
+
"required": true
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "gross_weight_kg",
|
|
36
|
+
"description": "Total gross weight in kilograms (numeric only).",
|
|
37
|
+
"type": "number"
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|