structured-data-extraction 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.
- structured_data_extraction/__init__.py +8 -0
- structured_data_extraction/classify.py +148 -0
- structured_data_extraction/config.py +62 -0
- structured_data_extraction/convert.py +165 -0
- structured_data_extraction/embed.py +270 -0
- structured_data_extraction/extract.py +441 -0
- structured_data_extraction/features.py +160 -0
- structured_data_extraction/filter_records.py +133 -0
- structured_data_extraction/main.py +329 -0
- structured_data_extraction/matching.py +153 -0
- structured_data_extraction/models/section_classifier/docling_cats.joblib +0 -0
- structured_data_extraction/models/section_classifier/rf_classifier.joblib +0 -0
- structured_data_extraction/models/section_classifier/tfidf_char_vectorizer.joblib +0 -0
- structured_data_extraction/models/section_classifier/tfidf_vectorizer.joblib +0 -0
- structured_data_extraction/paths.py +34 -0
- structured_data_extraction/rater.py +188 -0
- structured_data_extraction/records.py +71 -0
- structured_data_extraction/search.py +110 -0
- structured_data_extraction/tables.py +406 -0
- structured_data_extraction/templates/extraction.j2 +51 -0
- structured_data_extraction/templates/schema_discovery.j2 +91 -0
- structured_data_extraction/vocab.py +47 -0
- structured_data_extraction-0.1.0.dist-info/METADATA +234 -0
- structured_data_extraction-0.1.0.dist-info/RECORD +28 -0
- structured_data_extraction-0.1.0.dist-info/WHEEL +5 -0
- structured_data_extraction-0.1.0.dist-info/entry_points.txt +11 -0
- structured_data_extraction-0.1.0.dist-info/licenses/LICENSE +21 -0
- structured_data_extraction-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""LLM-based structured extraction : schema discovery and value extraction.
|
|
2
|
+
|
|
3
|
+
1. Schema discovery: a single LLM call (SCHEMA_MODEL) examines the matched
|
|
4
|
+
sections and the user's keys, and returns a per-key field specification
|
|
5
|
+
(sub-fields, types, units) plus a document_type label. The spec is used to
|
|
6
|
+
build a Pydantic model on the fly so the extraction LLM's output is
|
|
7
|
+
validated and typed automatically via the Instructor library.
|
|
8
|
+
|
|
9
|
+
2. Parallel extraction: every model listed in EXTRACTION_MODELS extracts values
|
|
10
|
+
independently, each through Instructor with retries. Running multiple models
|
|
11
|
+
produces overlapping records whose agreement is later scored by rater.py;
|
|
12
|
+
with a single model the extraction still works, just without a consensus
|
|
13
|
+
signal.
|
|
14
|
+
|
|
15
|
+
Prompt templates (schema_discovery.j2 and extraction.j2) are Jinja files that
|
|
16
|
+
ship inside the package. Users can override them by running
|
|
17
|
+
`--dump-templates ./dir` and setting TEMPLATES_DIR to that directory.
|
|
18
|
+
"""
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, List, Optional
|
|
27
|
+
import instructor
|
|
28
|
+
from jinja2 import Environment, ChoiceLoader, FileSystemLoader, PackageLoader
|
|
29
|
+
from openai import OpenAI
|
|
30
|
+
from pydantic import BaseModel, Field, create_model
|
|
31
|
+
from . import config, matching
|
|
32
|
+
from .paths import inter, read_json, cached
|
|
33
|
+
from .tables import strip_code_fence
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_loaders = []
|
|
39
|
+
if os.environ.get("TEMPLATES_DIR"):
|
|
40
|
+
_loaders.append(FileSystemLoader(os.environ["TEMPLATES_DIR"]))
|
|
41
|
+
_loaders.append(PackageLoader("structured_data_extraction", "templates"))
|
|
42
|
+
jinja_env = Environment(loader=ChoiceLoader(_loaders))
|
|
43
|
+
MAX_SECTION_CHARS = 5000
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ExtractionMetadata(BaseModel):
|
|
47
|
+
source: str = ""
|
|
48
|
+
section_type: str = ""
|
|
49
|
+
specified: bool = True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ValueRecord(BaseModel):
|
|
53
|
+
qualifier: str = Field(default="", description="Sub-category or variant")
|
|
54
|
+
condition: str = Field(default="", description="Conditions for this value")
|
|
55
|
+
value: Any = Field(description="The extracted value")
|
|
56
|
+
unit: str = Field(default="", description="Unit of measurement")
|
|
57
|
+
metadata: ExtractionMetadata = Field(default_factory=ExtractionMetadata)
|
|
58
|
+
|
|
59
|
+
_raw = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _raw_client():
|
|
63
|
+
global _raw
|
|
64
|
+
if _raw is None:
|
|
65
|
+
_raw = OpenAI(base_url=os.environ["LLM_BASE_URL"], api_key=os.environ["LLM_API_KEY"])
|
|
66
|
+
return _raw
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _get_client():
|
|
70
|
+
# instructor wraps the client so create(..., response_model=Model) returns a validated
|
|
71
|
+
# Pydantic object instead of raw text
|
|
72
|
+
return instructor.from_openai(_raw_client(), mode=instructor.Mode.JSON)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_extraction_model(schema, user_keys):
|
|
76
|
+
"""Build a Pydantic ExtractionResult model on the fly, one list field per user key.
|
|
77
|
+
|
|
78
|
+
A key whose discovered schema has a `fields` spec gets a bespoke record model (one
|
|
79
|
+
sub-field per entry, plus a synthesised `<name>_unit` when a field is unit-bearing and
|
|
80
|
+
there's no shared `unit`). Keys without a spec fall back to the generic ValueRecord.
|
|
81
|
+
The model is handed to Instructor as the response_model.
|
|
82
|
+
"""
|
|
83
|
+
# number is Optional so a model returning null (e.g. "volume": null) still validates
|
|
84
|
+
type_map = {"string": str, "number": Optional[float], "any": Any, "object": dict}
|
|
85
|
+
# schema keys come straight from the LLM, which may not match normalize_key's casing
|
|
86
|
+
# (e.g. it returns "PSRR" but normalize_key("PSRR") == "psrr"). Look up normalized so an
|
|
87
|
+
# acronym field keeps its rich (differentiator/qualifier) schema instead of falling back
|
|
88
|
+
# to the generic ValueRecord.
|
|
89
|
+
schema_by_norm = ({matching.normalize_key(k): v for k, v in schema.items()}
|
|
90
|
+
if isinstance(schema, dict) else {})
|
|
91
|
+
field_defs = {}
|
|
92
|
+
|
|
93
|
+
for key in user_keys:
|
|
94
|
+
field_name = matching.normalize_key(key)
|
|
95
|
+
entry = schema_by_norm.get(field_name)
|
|
96
|
+
if not isinstance(entry, dict):
|
|
97
|
+
entry = {}
|
|
98
|
+
description = entry.get("description") or key
|
|
99
|
+
|
|
100
|
+
fields_spec = entry.get("fields")
|
|
101
|
+
if not fields_spec:
|
|
102
|
+
field_defs[field_name] = (List[ValueRecord], Field(default_factory=list, description=description))
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
sub_fields = {
|
|
106
|
+
"metadata": (ExtractionMetadata, Field(default_factory=ExtractionMetadata)),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
has_unit_field = "unit" in fields_spec
|
|
110
|
+
for sub_name, sub_def in fields_spec.items():
|
|
111
|
+
if sub_name == "metadata":
|
|
112
|
+
continue
|
|
113
|
+
field_type = type_map.get(sub_def.get("type", "string"), str)
|
|
114
|
+
sub_desc = sub_def.get("description", "")
|
|
115
|
+
default = "" if field_type is str else None
|
|
116
|
+
sub_fields[sub_name] = (field_type, Field(default=default, description=sub_desc))
|
|
117
|
+
if sub_def.get("unit") and not has_unit_field:
|
|
118
|
+
sub_fields[f"{sub_name}_unit"] = (str, Field(default="", description=f"Unit for {sub_name}"))
|
|
119
|
+
|
|
120
|
+
record_model = create_model(f"{field_name}_Record", **sub_fields)
|
|
121
|
+
field_defs[field_name] = (List[record_model], Field(default_factory=list, description=description))
|
|
122
|
+
|
|
123
|
+
return create_model("ExtractionResult", **field_defs)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _merge_cleaned_tables(sections, tables):
|
|
127
|
+
"""Swap each table section's content for its vision-cleaned HTML and tag every
|
|
128
|
+
non-header section with the most recent preceding heading (nearest_header)."""
|
|
129
|
+
table_by_idx = {table["section_idx"]: table for table in tables}
|
|
130
|
+
merged = []
|
|
131
|
+
last_header = ""
|
|
132
|
+
|
|
133
|
+
for i, section in enumerate(sections):
|
|
134
|
+
merged_section = dict(section)
|
|
135
|
+
if section.get("type") == "header":
|
|
136
|
+
last_header = (section.get("content") or "").strip()
|
|
137
|
+
|
|
138
|
+
# tables are keyed by section_idx: try the positional index first, then the stored one
|
|
139
|
+
idx = i if i in table_by_idx else section.get("section_idx")
|
|
140
|
+
if section.get("type") == "table" and idx in table_by_idx:
|
|
141
|
+
cleaned = table_by_idx[idx].get("content", "")
|
|
142
|
+
if cleaned:
|
|
143
|
+
merged_section["content"] = cleaned
|
|
144
|
+
merged_section["refined_format"] = table_by_idx[idx].get("refined_format", "")
|
|
145
|
+
|
|
146
|
+
if section.get("type") != "header" and last_header:
|
|
147
|
+
merged_section["nearest_header"] = last_header
|
|
148
|
+
merged.append(merged_section)
|
|
149
|
+
return merged
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _format_sections(sections, max_chars=MAX_SECTION_CHARS):
|
|
153
|
+
"""Render the matched sections into the prompt block: prefer full_content, prefix the nearest heading, and truncate long prose but
|
|
154
|
+
never tables."""
|
|
155
|
+
parts = []
|
|
156
|
+
for i, section in enumerate(sections):
|
|
157
|
+
sec_type = section.get("type", "unknown")
|
|
158
|
+
sec_idx = section.get("section_idx", i)
|
|
159
|
+
content = section.get("full_content", "") or section.get("content", "")
|
|
160
|
+
header = section.get("nearest_header", "")
|
|
161
|
+
|
|
162
|
+
header_prefix = f"Surrounding section heading: {header}\n" if header else ""
|
|
163
|
+
|
|
164
|
+
if sec_type != "table" and len(content) > max_chars:
|
|
165
|
+
content = content[:max_chars] + "..."
|
|
166
|
+
|
|
167
|
+
parts.append(f"[Section {sec_idx}] ({sec_type}):\n{header_prefix}{content}")
|
|
168
|
+
|
|
169
|
+
return "\n\n".join(parts)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def discover_schema(markdown_text, matched_sections, vocab, user_keys):
|
|
173
|
+
"""Ask the schema LLM for each key's field shape. Returns document_type, schema,
|
|
174
|
+
pulling document_type out of the response's document_type"""
|
|
175
|
+
template = jinja_env.get_template("schema_discovery.j2")
|
|
176
|
+
|
|
177
|
+
section_content = _format_sections(matched_sections)
|
|
178
|
+
vocab_terms = ", ".join(item["term"] for item in vocab[:30]) if vocab else "N/A"
|
|
179
|
+
|
|
180
|
+
prompt = template.render(
|
|
181
|
+
section_content=section_content,
|
|
182
|
+
vocab_terms=vocab_terms,
|
|
183
|
+
user_keys=", ".join(user_keys),
|
|
184
|
+
document_preview=markdown_text[:5000],
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
logger.info("Calling LLM for schema discovery...")
|
|
188
|
+
client = _raw_client()
|
|
189
|
+
resp = client.chat.completions.create(
|
|
190
|
+
model=os.environ["SCHEMA_MODEL"],
|
|
191
|
+
messages=[{"role": "user", "content": prompt}],
|
|
192
|
+
temperature=0,
|
|
193
|
+
)
|
|
194
|
+
raw = strip_code_fence(resp.choices[0].message.content.strip())
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
schema = json.loads(raw)
|
|
198
|
+
if isinstance(schema, dict):
|
|
199
|
+
doc_type = schema.pop("_document_type", "unknown")
|
|
200
|
+
if not isinstance(doc_type, str) or not doc_type.strip():
|
|
201
|
+
doc_type = "unknown"
|
|
202
|
+
return doc_type, schema
|
|
203
|
+
except json.JSONDecodeError:
|
|
204
|
+
logger.error(f"Failed to parse schema: {raw[:200]}")
|
|
205
|
+
return "unknown", {}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def extract_values(client, matched_sections, vocab, user_keys, schema,
|
|
209
|
+
document_type="unknown", source_file="", model=None):
|
|
210
|
+
"""Run one extraction LLM through Instructor and return {field: [record dicts]}.
|
|
211
|
+
Retries up to MAX_LLM_RETRIES; returns {} if every attempt fails."""
|
|
212
|
+
model = model or config.EXTRACTION_MODELS[0]
|
|
213
|
+
template = jinja_env.get_template("extraction.j2")
|
|
214
|
+
|
|
215
|
+
section_content = _format_sections(matched_sections)
|
|
216
|
+
vocab_terms = ", ".join(item["term"] for item in vocab[:30]) if vocab else "N/A"
|
|
217
|
+
|
|
218
|
+
prompt = template.render(
|
|
219
|
+
document_type=document_type,
|
|
220
|
+
section_content=section_content,
|
|
221
|
+
vocab_terms=vocab_terms,
|
|
222
|
+
user_keys=", ".join(user_keys),
|
|
223
|
+
schema=json.dumps(schema, indent=2),
|
|
224
|
+
source_file=source_file,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
extraction_model = _build_extraction_model(schema, user_keys)
|
|
228
|
+
|
|
229
|
+
logger.info(f"Calling LLM for extraction (model={model})...")
|
|
230
|
+
last_err = None
|
|
231
|
+
max_retries = int(os.environ["MAX_LLM_RETRIES"])
|
|
232
|
+
|
|
233
|
+
for attempt in range(max_retries + 1):
|
|
234
|
+
try:
|
|
235
|
+
result = client.chat.completions.create(
|
|
236
|
+
model=model,
|
|
237
|
+
messages=[{"role": "user", "content": prompt}],
|
|
238
|
+
temperature=0.0,
|
|
239
|
+
response_model=extraction_model,
|
|
240
|
+
)
|
|
241
|
+
output = {}
|
|
242
|
+
for field in extraction_model.model_fields:
|
|
243
|
+
records = getattr(result, field, [])
|
|
244
|
+
output[field] = [record.model_dump() for record in records]
|
|
245
|
+
return output
|
|
246
|
+
except Exception as e:
|
|
247
|
+
last_err = e
|
|
248
|
+
logger.warning(f"[{model}] Attempt {attempt + 1} failed: {e}")
|
|
249
|
+
|
|
250
|
+
logger.error(f"[{model}] Extraction failed after {max_retries + 1} attempts: {last_err}")
|
|
251
|
+
return {}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _dedup_extraction(extraction):
|
|
255
|
+
"""Within each field, drop duplicate records, keeping the one from the highest-priority
|
|
256
|
+
section (table > text > list). Records are duplicates when all non-metadata fields match."""
|
|
257
|
+
deduped = {}
|
|
258
|
+
for field, records in extraction.items():
|
|
259
|
+
best = {}
|
|
260
|
+
for record in records:
|
|
261
|
+
# identity key over every field except metadata: equal values -> same fact
|
|
262
|
+
dedup_key = tuple(sorted((k, str(v)) for k, v in record.items() if k != "metadata"))
|
|
263
|
+
|
|
264
|
+
sec_type = record.get("metadata", {}).get("section_type", "unknown")
|
|
265
|
+
existing = best.get(dedup_key)
|
|
266
|
+
if existing is None:
|
|
267
|
+
best[dedup_key] = record
|
|
268
|
+
else:
|
|
269
|
+
old_priority = config.SECTION_TYPE_PRIORITY.get(existing.get("metadata", {}).get("section_type", "unknown"), 99)
|
|
270
|
+
new_priority = config.SECTION_TYPE_PRIORITY.get(sec_type, 99)
|
|
271
|
+
if new_priority < old_priority:
|
|
272
|
+
best[dedup_key] = record
|
|
273
|
+
deduped[field] = list(best.values())
|
|
274
|
+
return deduped
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _neighbor_line(sections, idx, side):
|
|
278
|
+
"""Return the adjacent line beside section `idx`: the last line of the previous section
|
|
279
|
+
or the first line of the next. Empty string if that neighbour is a header/table or out of bounds."""
|
|
280
|
+
j = idx - 1 if side == "prev" else idx + 1
|
|
281
|
+
if not 0 <= j < len(sections):
|
|
282
|
+
return ""
|
|
283
|
+
neighbor = sections[j]
|
|
284
|
+
if neighbor.get("type") in ("header", "table"):
|
|
285
|
+
return ""
|
|
286
|
+
lines = [ln for ln in (neighbor.get("content") or "").splitlines() if ln.strip()]
|
|
287
|
+
if not lines:
|
|
288
|
+
return ""
|
|
289
|
+
return lines[-1] if side == "prev" else lines[0]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _prepare_sections(stem):
|
|
293
|
+
"""Load cached matches/sections/vocab and build the deduped list of sections to extract from.
|
|
294
|
+
|
|
295
|
+
Each matched section is widened by one neighbour line on each side (so an orphaned label
|
|
296
|
+
keeps its value), then collapsed by section_idx keeping the highest-similarity hit.
|
|
297
|
+
Returns (markdown_text, vocab, sections_sorted_by_similarity).
|
|
298
|
+
"""
|
|
299
|
+
matches = read_json(inter(stem, "matches.json"))
|
|
300
|
+
sections = read_json(inter(stem, "sections.json"))
|
|
301
|
+
vocab = read_json(inter(stem, "vocab.json"))
|
|
302
|
+
|
|
303
|
+
if inter(stem, "tables.json").exists():
|
|
304
|
+
sections = _merge_cleaned_tables(sections, read_json(inter(stem, "tables.json")))
|
|
305
|
+
|
|
306
|
+
md_path = Path(config.MARKDOWN_DIR) / f"{stem}.md"
|
|
307
|
+
md_text = md_path.read_text(encoding="utf-8") if md_path.exists() else ""
|
|
308
|
+
|
|
309
|
+
best_by_idx = {}
|
|
310
|
+
for key_matches in matches.values():
|
|
311
|
+
for match in key_matches:
|
|
312
|
+
idx = match.get("section_idx", 0)
|
|
313
|
+
enriched = dict(match)
|
|
314
|
+
if idx < len(sections):
|
|
315
|
+
content = sections[idx].get("content", "")
|
|
316
|
+
sec_type = sections[idx].get("type", "unknown")
|
|
317
|
+
if sec_type != "table":
|
|
318
|
+
# widen by one neighbor line each side so an orphaned label keeps
|
|
319
|
+
# its value (and vice versa)
|
|
320
|
+
prev_line = _neighbor_line(sections, idx, "prev")
|
|
321
|
+
next_line = _neighbor_line(sections, idx, "next")
|
|
322
|
+
content = "\n".join(p for p in (prev_line, content, next_line) if p)
|
|
323
|
+
enriched["full_content"] = content
|
|
324
|
+
enriched["type"] = sec_type
|
|
325
|
+
similarity = enriched.get("similarity", 0)
|
|
326
|
+
if idx not in best_by_idx or similarity > best_by_idx[idx].get("similarity", 0):
|
|
327
|
+
best_by_idx[idx] = enriched
|
|
328
|
+
|
|
329
|
+
unique = sorted(best_by_idx.values(), key=lambda x: x.get("similarity", 0), reverse=True)
|
|
330
|
+
return md_text, vocab, unique
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _run_one_model(model_name, stem, out_dir, unique, vocab, user_keys, schema, doc_type):
|
|
334
|
+
"""Extract with one model, dedup, stamp each record with its model/doc_type, and write
|
|
335
|
+
output/<stem>_extraction_<slug>.json. Returns the cached file unchanged on a re-run."""
|
|
336
|
+
slug = matching.model_slug(model_name)
|
|
337
|
+
out_path = out_dir / f"{stem}_extraction_{slug}.json"
|
|
338
|
+
if out_path.exists() and not config.FORCE:
|
|
339
|
+
logger.info(f"[{model_name}] already exists, skipping")
|
|
340
|
+
return json.loads(out_path.read_text(encoding="utf-8"))
|
|
341
|
+
|
|
342
|
+
client = _get_client()
|
|
343
|
+
extraction = extract_values(client, unique, vocab, user_keys, schema,
|
|
344
|
+
doc_type, f"{stem}.pdf", model=model_name)
|
|
345
|
+
|
|
346
|
+
if not extraction:
|
|
347
|
+
# extract_values returns {} only when every attempt failed; surface it as a failure
|
|
348
|
+
# rather than writing an empty file that a later run would treat as a cached success.
|
|
349
|
+
raise RuntimeError("no extraction produced (all attempts failed)")
|
|
350
|
+
|
|
351
|
+
pre = sum(len(v) for v in extraction.values())
|
|
352
|
+
extraction = _dedup_extraction(extraction)
|
|
353
|
+
post = sum(len(v) for v in extraction.values())
|
|
354
|
+
if post < pre:
|
|
355
|
+
logger.info(f"[{model_name}] Dedup: {pre} → {post}")
|
|
356
|
+
|
|
357
|
+
# tag each record with which model produced it
|
|
358
|
+
for records in extraction.values():
|
|
359
|
+
for record in records:
|
|
360
|
+
metadata = record.get("metadata")
|
|
361
|
+
if isinstance(metadata, dict):
|
|
362
|
+
metadata["document_type"] = doc_type
|
|
363
|
+
metadata["model"] = model_name
|
|
364
|
+
|
|
365
|
+
total = sum(len(v) for v in extraction.values())
|
|
366
|
+
out_path.write_text(json.dumps(extraction, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
367
|
+
logger.info(f"[{model_name}] Extracted {total} records → {out_path}")
|
|
368
|
+
return extraction
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def extract_parallel(stem, user_keys):
|
|
372
|
+
"""Run every configured extraction model over one document in parallel.
|
|
373
|
+
|
|
374
|
+
Stamps run provenance (run.json), discovers the schema once for all models, then fans
|
|
375
|
+
_run_one_model out across EXTRACTION_WORKERS threads. Returns the per-model extraction
|
|
376
|
+
dicts for the runs that succeeded.
|
|
377
|
+
"""
|
|
378
|
+
out_dir = Path(config.OUTPUT_DIR)
|
|
379
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
380
|
+
|
|
381
|
+
models = config.EXTRACTION_MODELS
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
cached(inter(stem, "run.json"),
|
|
385
|
+
lambda: {"extracted_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
386
|
+
"user_keys": list(user_keys)})
|
|
387
|
+
|
|
388
|
+
md_text, vocab, unique = _prepare_sections(stem)
|
|
389
|
+
|
|
390
|
+
# schema discovery once for all models — cached to intermediate/<stem>_schema.json
|
|
391
|
+
disc = cached(inter(stem, "schema.json"),
|
|
392
|
+
lambda: dict(zip(("document_type", "schema"),
|
|
393
|
+
discover_schema(md_text, unique, vocab, user_keys))))
|
|
394
|
+
doc_type, schema = disc["document_type"], disc["schema"]
|
|
395
|
+
logger.info(f"Schema: document_type='{doc_type}'")
|
|
396
|
+
|
|
397
|
+
results = []
|
|
398
|
+
failures = []
|
|
399
|
+
workers = max(1, min(int(os.environ["EXTRACTION_WORKERS"]), len(models)))
|
|
400
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
401
|
+
futures = {
|
|
402
|
+
pool.submit(_run_one_model, m, stem, out_dir, unique, vocab, user_keys, schema, doc_type): m
|
|
403
|
+
for m in models
|
|
404
|
+
}
|
|
405
|
+
for fut in as_completed(futures):
|
|
406
|
+
model_name = futures[fut]
|
|
407
|
+
try:
|
|
408
|
+
results.append(fut.result())
|
|
409
|
+
except Exception as e:
|
|
410
|
+
# one model failing doesn't sink the document — the others still write output.
|
|
411
|
+
# keep only the first line of the message so the user sees the cause, not a trace.
|
|
412
|
+
msg = next((ln for ln in str(e).splitlines() if ln.strip()), type(e).__name__)
|
|
413
|
+
failures.append((model_name, msg))
|
|
414
|
+
logger.error(f"[{model_name}] extraction failed: {msg}")
|
|
415
|
+
|
|
416
|
+
if failures and not results:
|
|
417
|
+
model_name, msg = failures[0]
|
|
418
|
+
logger.error(f"All {len(models)} extraction model(s) failed. First error — [{model_name}]: {msg}")
|
|
419
|
+
elif failures:
|
|
420
|
+
logger.warning(f"Partial extraction: {len(results)}/{len(models)} models OK; "
|
|
421
|
+
f"failed: {', '.join(m for m, _ in failures)}")
|
|
422
|
+
logger.info(f"Parallel extraction done: {len(results)}/{len(models)} succeeded")
|
|
423
|
+
return results
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def main():
|
|
427
|
+
parser = argparse.ArgumentParser(description="Extract values from document")
|
|
428
|
+
parser.add_argument("--stem", required=True)
|
|
429
|
+
parser.add_argument("--keys", nargs="+", required=True)
|
|
430
|
+
args = parser.parse_args()
|
|
431
|
+
|
|
432
|
+
results = extract_parallel(args.stem, args.keys)
|
|
433
|
+
for result in results:
|
|
434
|
+
total = sum(len(records) for records in result.values())
|
|
435
|
+
model = next((rec.get("metadata", {}).get("model")
|
|
436
|
+
for records in result.values() for rec in records), "?")
|
|
437
|
+
print(f"{model}: {total} records")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
if __name__ == "__main__":
|
|
441
|
+
main()
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Feature engineering for the section classifier.
|
|
2
|
+
|
|
3
|
+
Builds the feature matrix that the RandomForest in classify.py consumes.
|
|
4
|
+
Each item becomes a row of:
|
|
5
|
+
- word-level TF-IDF features (fitted at training time, transformed at inference)
|
|
6
|
+
- character n-gram TF-IDF features (same)
|
|
7
|
+
- 30 hand-crafted regex/layout features (pipe presence, bullet patterns, heading
|
|
8
|
+
depth, digit density, sentence count, etc.)
|
|
9
|
+
- one-hot context: the Docling labels of the item itself, its predecessor, and
|
|
10
|
+
its successor, plus the distance to the nearest preceding section header
|
|
11
|
+
|
|
12
|
+
The regex feature vector length and order are fixed by the trained model — adding,
|
|
13
|
+
removing, or reordering features requires retraining.
|
|
14
|
+
"""
|
|
15
|
+
import re
|
|
16
|
+
import numpy as np
|
|
17
|
+
from scipy import sparse
|
|
18
|
+
|
|
19
|
+
# How far (in items) we look back for the nearest section header before giving up.
|
|
20
|
+
DIST_CAP = 20
|
|
21
|
+
|
|
22
|
+
# A line that starts with a bullet (or "-"/"*") then optional space -> list item.
|
|
23
|
+
BULLET_RE = re.compile(r"^[•●○▪►→■□◆◇★☆\-\*]\s*")
|
|
24
|
+
# A lone leading "n" + space + non-space: docling sometimes mangles a "•" bullet into "n".
|
|
25
|
+
N_BULLET_RE = re.compile(r"^n\s+\S")
|
|
26
|
+
# Start of a figure/table caption: "Figure 1", "Fig. 2", "Table 3", "Tab. 4".
|
|
27
|
+
FIG_RE = re.compile(r"^(Fig\.?|Figure|Table|Tab\.)\s*\d", re.I)
|
|
28
|
+
# Characters that signal engineering/scientific units: ohm, micro (two code points), degree, plus-minus.
|
|
29
|
+
UNITS = set("ΩμµΩ°±")
|
|
30
|
+
|
|
31
|
+
# Markdown ATX heading: 1-6 "#" then a space. The captured "#"s give the heading depth.
|
|
32
|
+
HASH_RE = re.compile(r"^(#{1,6})\s")
|
|
33
|
+
# A caption label prefix like "Figure 1: " or "Table 3.2." Stripped before sentence counting
|
|
34
|
+
# so the "1." in "Figure 1." isn't mistaken for a sentence boundary.
|
|
35
|
+
FIG_TABLE_PREFIX_RE = re.compile(r"^(Figure|Fig\.|Table|Tab\.)\s+[A-Z0-9][A-Z0-9\-\.]*[\.\:]\s*", re.I)
|
|
36
|
+
# A sentence boundary: lowercase/quote/paren, then ". ", then a capital letter.
|
|
37
|
+
SENTENCE_BOUNDARY_RE = re.compile(r"[a-z\)\"\']\.\s+[A-Z]")
|
|
38
|
+
# A numbered list item: "1." or "2)" at the start, then a space.
|
|
39
|
+
NUM_LIST_RE = re.compile(r"^\d+[\.\)]\s")
|
|
40
|
+
# A dash bullet: hyphen / en-dash / em-dash at the start, then a space.
|
|
41
|
+
DASH_RE = re.compile(r"^[-–—]\s")
|
|
42
|
+
# A short parenthetical anywhere, e.g. "(typical)", "(see note)" — 1-40 non-")" chars.
|
|
43
|
+
PAREN_RE = re.compile(r"\([^)]{1,40}\)")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _sentence_count(text):
|
|
47
|
+
"""Estimate how many sentences `text` contains.
|
|
48
|
+
|
|
49
|
+
Strips a leading figure/table caption label first (so "Figure 1." reads as 0 boundaries,
|
|
50
|
+
not 1), then counts sentence boundaries. N boundaries means N+1 sentences, hence the `+ 1`.
|
|
51
|
+
A header or list item scores 1; a paragraph scores higher.
|
|
52
|
+
"""
|
|
53
|
+
stripped = FIG_TABLE_PREFIX_RE.sub("", text.strip())
|
|
54
|
+
return len(SENTENCE_BOUNDARY_RE.findall(stripped)) + 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def regex_features(text):
|
|
58
|
+
"""Return the 30 hand-crafted layout/typography features for one text chunk.
|
|
59
|
+
|
|
60
|
+
The vector mixes structural flags (is this a bullet? a heading? a table row?) with shape
|
|
61
|
+
stats (length, uppercase ratio, digit density). See the module docstring: the order and
|
|
62
|
+
length here are fixed by the trained model — do not change without retraining.
|
|
63
|
+
"""
|
|
64
|
+
n_sent = _sentence_count(text)
|
|
65
|
+
words = text.split()
|
|
66
|
+
wc = max(len(words), 1) # word count, floored at 1 to avoid /0
|
|
67
|
+
n = len(text)
|
|
68
|
+
letters = [c for c in text if c.isalpha()]
|
|
69
|
+
upper_ratio = sum(c.isupper() for c in letters) / max(len(letters), 1)
|
|
70
|
+
# Count Title Case words like Voltage. Used as a fraction below
|
|
71
|
+
# (title_words / wc): one sentence-initial capital barely registers, but a heading where
|
|
72
|
+
# most words are Title Case scores high.
|
|
73
|
+
title_words = sum(1 for w in words if len(w) > 1 and w[0].isupper() and w[1:].islower())
|
|
74
|
+
hash_m = HASH_RE.match(text)
|
|
75
|
+
hash_depth = len(hash_m.group(1)) if hash_m else 0 # markdown heading level, 0 if none
|
|
76
|
+
return [
|
|
77
|
+
float("|" in text), # has a pipe -> table row
|
|
78
|
+
float("\n" in text), # spans multiple lines
|
|
79
|
+
float(text.count("\n") > 2), # has many lines -> list/table block
|
|
80
|
+
float(bool(NUM_LIST_RE.match(text))), # starts "1." / "2)" -> numbered list
|
|
81
|
+
float(bool(BULLET_RE.match(text))), # starts with a bullet glyph
|
|
82
|
+
float(bool(N_BULLET_RE.match(text))), # starts "n " -> mangled bullet
|
|
83
|
+
float(bool(DASH_RE.match(text))), # starts with a dash bullet
|
|
84
|
+
float(bool(FIG_RE.match(text))), # "Figure 1" / "Table 2" caption
|
|
85
|
+
float(n < 80), float(n < 40), float(n < 20), # short / shorter / very short
|
|
86
|
+
float(text.endswith(".")), # ends with a period (sentence-like)
|
|
87
|
+
float(text.endswith(":")), # ends with a colon (label / lead-in)
|
|
88
|
+
float(text[-1] in ".:;?!") if text else 0.0, # ends with terminal punctuation
|
|
89
|
+
float(text.count(".") <= 1), # at most one period
|
|
90
|
+
float(bool(PAREN_RE.search(text))), # contains a short parenthetical
|
|
91
|
+
float(sum(1 for w in words[:3] if w.isupper() and len(w) > 1)), # count of ALL-CAPS words in first 3
|
|
92
|
+
float(text.isupper() and any(c.isalpha() for c in text)), # whole text is uppercase
|
|
93
|
+
upper_ratio, # fraction of letters uppercase
|
|
94
|
+
title_words / wc, # fraction of Title-Case words
|
|
95
|
+
float(text[0].isupper()) if text else 0.0, # first character is uppercase
|
|
96
|
+
sum(c.isdigit() for c in text) / max(n, 1), # digit density (table/spec signal)
|
|
97
|
+
float(any(c in UNITS for c in text)), # contains a unit/symbol char
|
|
98
|
+
sum(len(w) for w in words) / wc, # mean word length
|
|
99
|
+
float(n), # character length
|
|
100
|
+
float(wc), # word count
|
|
101
|
+
float(hash_depth > 0), # is a markdown "# " heading
|
|
102
|
+
float(hash_depth) / 6.0, # heading depth, normalised to 0..1
|
|
103
|
+
float(n_sent == 1), # single sentence
|
|
104
|
+
float(min(n_sent, 10)), # sentence count, capped at 10
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _normalize_text(text, dl):
|
|
109
|
+
"""Restore the leading bullet on a list item that lost its marker.
|
|
110
|
+
|
|
111
|
+
Docling occasionally strips the "-" off a `list_item`; we add it back so the tf-idf
|
|
112
|
+
vectorizers see the same surface shape they were fit on (and so the bullet regex flags fire).
|
|
113
|
+
"""
|
|
114
|
+
if dl == "list_item" and text and not BULLET_RE.match(text):
|
|
115
|
+
return "- " + text
|
|
116
|
+
return text
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_features(data, dl_idx, word_vec, char_vec, fit):
|
|
120
|
+
"""Assemble the full sparse feature matrix for a batch of chunks.
|
|
121
|
+
|
|
122
|
+
`data` rows come from `context_rows` (each has text, dl, prev, next, dist). `dl_idx` maps
|
|
123
|
+
a docling label to a column. `word_vec`/`char_vec` are the tf-idf vectorizers; pass
|
|
124
|
+
`fit=True` at training time to fit them, `fit=False` at inference to transform with the
|
|
125
|
+
loaded ones. Output columns are [word tf-idf | char tf-idf | regex flags | context].
|
|
126
|
+
"""
|
|
127
|
+
texts = [_normalize_text(r["text"], r["dl"]) for r in data]
|
|
128
|
+
word = word_vec.fit_transform(texts) if fit else word_vec.transform(texts)
|
|
129
|
+
char = char_vec.fit_transform(texts) if fit else char_vec.transform(texts)
|
|
130
|
+
# Context block: three one-hot label slots (own / prev / next) of width k, plus one distance scalar.
|
|
131
|
+
k = len(dl_idx)
|
|
132
|
+
ctx = np.zeros((len(data), 3 * k + 1), dtype=np.float32)
|
|
133
|
+
for i, r in enumerate(data):
|
|
134
|
+
ctx[i, dl_idx.get(r["dl"], 0)] = 1.0 # this item's own docling label
|
|
135
|
+
ctx[i, k + dl_idx.get(r["prev"], 0)] = 1.0 # the previous item's label
|
|
136
|
+
ctx[i, 2 * k + dl_idx.get(r["next"], 0)] = 1.0 # the next item's label
|
|
137
|
+
ctx[i, 3 * k] = r["dist"] # items since the last section header
|
|
138
|
+
regex = sparse.csr_matrix([regex_features(t) for t in texts])
|
|
139
|
+
return sparse.hstack([word, char, regex, sparse.csr_matrix(ctx)]).tocsr()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def context_rows(chunks, text_key="text"):
|
|
143
|
+
"""Order chunks by `section_id` and attach each one's neighbourhood context.
|
|
144
|
+
|
|
145
|
+
For every chunk we record its own docling label, its previous/next labels, and `dist` =
|
|
146
|
+
how many items back the nearest `section_header` is (capped at DIST_CAP, also used when no
|
|
147
|
+
header precedes it). Returns the sorted chunks alongside the context rows that feed
|
|
148
|
+
`build_features`.
|
|
149
|
+
"""
|
|
150
|
+
chunks = sorted(chunks, key=lambda c: int(c["section_id"]))
|
|
151
|
+
n = len(chunks)
|
|
152
|
+
rows = []
|
|
153
|
+
for i, c in enumerate(chunks):
|
|
154
|
+
# walk backwards from i to the nearest section_header; DIST_CAP if none is found
|
|
155
|
+
dist = next((min(i - b, DIST_CAP) for b in range(i, -1, -1)
|
|
156
|
+
if chunks[b]["docling_label"] == "section_header"), DIST_CAP)
|
|
157
|
+
rows.append({"text": c.get(text_key, c.get("text", "")), "dl": c["docling_label"],
|
|
158
|
+
"prev": chunks[i - 1]["docling_label"] if i > 0 else "",
|
|
159
|
+
"next": chunks[i + 1]["docling_label"] if i < n - 1 else "", "dist": dist})
|
|
160
|
+
return chunks, rows
|