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.
Files changed (28) hide show
  1. structured_data_extraction/__init__.py +8 -0
  2. structured_data_extraction/classify.py +148 -0
  3. structured_data_extraction/config.py +62 -0
  4. structured_data_extraction/convert.py +165 -0
  5. structured_data_extraction/embed.py +270 -0
  6. structured_data_extraction/extract.py +441 -0
  7. structured_data_extraction/features.py +160 -0
  8. structured_data_extraction/filter_records.py +133 -0
  9. structured_data_extraction/main.py +329 -0
  10. structured_data_extraction/matching.py +153 -0
  11. structured_data_extraction/models/section_classifier/docling_cats.joblib +0 -0
  12. structured_data_extraction/models/section_classifier/rf_classifier.joblib +0 -0
  13. structured_data_extraction/models/section_classifier/tfidf_char_vectorizer.joblib +0 -0
  14. structured_data_extraction/models/section_classifier/tfidf_vectorizer.joblib +0 -0
  15. structured_data_extraction/paths.py +34 -0
  16. structured_data_extraction/rater.py +188 -0
  17. structured_data_extraction/records.py +71 -0
  18. structured_data_extraction/search.py +110 -0
  19. structured_data_extraction/tables.py +406 -0
  20. structured_data_extraction/templates/extraction.j2 +51 -0
  21. structured_data_extraction/templates/schema_discovery.j2 +91 -0
  22. structured_data_extraction/vocab.py +47 -0
  23. structured_data_extraction-0.1.0.dist-info/METADATA +234 -0
  24. structured_data_extraction-0.1.0.dist-info/RECORD +28 -0
  25. structured_data_extraction-0.1.0.dist-info/WHEEL +5 -0
  26. structured_data_extraction-0.1.0.dist-info/entry_points.txt +11 -0
  27. structured_data_extraction-0.1.0.dist-info/licenses/LICENSE +21 -0
  28. 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