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,148 @@
|
|
|
1
|
+
"""Section classifier : re-labels Docling items with a supervised RandomForest.
|
|
2
|
+
|
|
3
|
+
Docling's built-in labels are unreliable on dense datasheets: multi-sentence
|
|
4
|
+
captions get tagged as "text", and sub-headers are often labelled "list_item".
|
|
5
|
+
A small RandomForest (trained on hand-labelled datasheet items) predicts a corrected section type for every item.
|
|
6
|
+
|
|
7
|
+
The classifier uses TF-IDF features (word- and character-level), 30 hand-crafted
|
|
8
|
+
layout/typography features from features.py, and one-hot context from the
|
|
9
|
+
surrounding Docling labels. Its output — sections.json — is the canonical
|
|
10
|
+
content stream that every downstream step (embedding, search, extraction) reads.
|
|
11
|
+
|
|
12
|
+
The trained model (~89 MB: one joblib per vectoriser plus the forest) ships
|
|
13
|
+
inside the package under models/section_classifier/.
|
|
14
|
+
"""
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
from collections import Counter
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
import joblib
|
|
21
|
+
from . import config
|
|
22
|
+
from .features import build_features, context_rows
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Section classifier is a random forest model trained on labeled sections from the Docling dataset.
|
|
27
|
+
# It uses TF-IDF features of the text and character n-grams, as well as the Docling label of each section,
|
|
28
|
+
# to predict the section type (table, text, list, etc)
|
|
29
|
+
class SectionClassifier:
|
|
30
|
+
|
|
31
|
+
def __init__(self, model_dir=None):
|
|
32
|
+
d = Path(model_dir) if model_dir else Path(config.SECTION_CLASSIFIER_DIR)
|
|
33
|
+
self.rf = joblib.load(d / "rf_classifier.joblib")
|
|
34
|
+
self.word_vec = joblib.load(d / "tfidf_vectorizer.joblib")
|
|
35
|
+
self.char_vec = joblib.load(d / "tfidf_char_vectorizer.joblib")
|
|
36
|
+
self.dl_cats = joblib.load(d / "docling_cats.joblib")
|
|
37
|
+
self.dl_idx = {c: i for i, c in enumerate(self.dl_cats)}
|
|
38
|
+
|
|
39
|
+
def classify(self, chunks):
|
|
40
|
+
"""
|
|
41
|
+
Predict a section type (table, text, list, ...) for each chunk.
|
|
42
|
+
|
|
43
|
+
Each chunk is a dict with "section_id", "docling_label", and "text";
|
|
44
|
+
"page" and "bbox" are passed through if present.
|
|
45
|
+
Returns the chunks sorted by section_id, each with "section_type" added.
|
|
46
|
+
"""
|
|
47
|
+
if not chunks:
|
|
48
|
+
return []
|
|
49
|
+
ordered, rows = context_rows(chunks, text_key="text")
|
|
50
|
+
X = build_features(rows, self.dl_idx, self.word_vec, self.char_vec, fit=False)
|
|
51
|
+
preds = self.rf.predict(X)
|
|
52
|
+
return [dict(c, section_type=str(p)) for c, p in zip(ordered, preds)]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_classifier = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_classifier():
|
|
59
|
+
"""Load the ~89 MB random forest + its vectorizers once and reuse across PDFs."""
|
|
60
|
+
global _classifier
|
|
61
|
+
if _classifier is None:
|
|
62
|
+
_classifier = SectionClassifier()
|
|
63
|
+
return _classifier
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def classify_sections(pdf_stem):
|
|
67
|
+
"""
|
|
68
|
+
Disk wrapper around SectionClassifier.classify for the batch pipeline.
|
|
69
|
+
Reads {stem}_items.json, runs the items with text through classify(),
|
|
70
|
+
and writes {stem}_sections.json (skips if it already exists).
|
|
71
|
+
|
|
72
|
+
Returns the list of classified sections — each a dict with
|
|
73
|
+
"type", "content", "docling_label", "page", "bbox", and "section_idx".
|
|
74
|
+
"""
|
|
75
|
+
items_path = Path(config.INTERMEDIATE_DIR) / f"{pdf_stem}_items.json"
|
|
76
|
+
out_path = Path(config.INTERMEDIATE_DIR) / f"{pdf_stem}_sections.json"
|
|
77
|
+
|
|
78
|
+
if not items_path.exists():
|
|
79
|
+
raise FileNotFoundError(f"Items not found: {items_path}. Run convert.py first.")
|
|
80
|
+
|
|
81
|
+
if out_path.exists() and not config.FORCE:
|
|
82
|
+
logger.info(f"Skipping {pdf_stem} — sections already exist")
|
|
83
|
+
return json.loads(out_path.read_text(encoding="utf-8"))
|
|
84
|
+
|
|
85
|
+
items = json.loads(items_path.read_text(encoding="utf-8"))
|
|
86
|
+
logger.info(f"Classifying {len(items)} items for {pdf_stem}...")
|
|
87
|
+
|
|
88
|
+
model_dir = Path(config.SECTION_CLASSIFIER_DIR)
|
|
89
|
+
if not model_dir.exists():
|
|
90
|
+
raise FileNotFoundError(f"Classifier not found at {model_dir}.")
|
|
91
|
+
|
|
92
|
+
# only classify items that have text
|
|
93
|
+
text_chunks = [
|
|
94
|
+
{
|
|
95
|
+
"section_id": i,
|
|
96
|
+
"docling_label": item.get("label", "text"),
|
|
97
|
+
"text": text,
|
|
98
|
+
"page": item.get("page"),
|
|
99
|
+
"bbox": item.get("bbox"),
|
|
100
|
+
}
|
|
101
|
+
for i, item in enumerate(items)
|
|
102
|
+
if (text := (item.get("text") or "").strip())
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
predicted = get_classifier().classify(text_chunks)
|
|
106
|
+
|
|
107
|
+
# build output keeping original item order
|
|
108
|
+
by_idx = {chunk["section_id"]: chunk for chunk in predicted}
|
|
109
|
+
sections = []
|
|
110
|
+
for i, item in enumerate(items):
|
|
111
|
+
if i in by_idx:
|
|
112
|
+
chunk = by_idx[i]
|
|
113
|
+
sections.append({
|
|
114
|
+
"type": chunk["section_type"],
|
|
115
|
+
"content": chunk["text"],
|
|
116
|
+
"docling_label": chunk["docling_label"],
|
|
117
|
+
"page": chunk["page"],
|
|
118
|
+
"bbox": chunk["bbox"],
|
|
119
|
+
"section_idx": i,
|
|
120
|
+
})
|
|
121
|
+
elif item.get("label") == "picture":
|
|
122
|
+
sections.append({
|
|
123
|
+
"type": "picture",
|
|
124
|
+
"content": "",
|
|
125
|
+
"docling_label": item.get("label"),
|
|
126
|
+
"page": item.get("page"),
|
|
127
|
+
"bbox": item.get("bbox"),
|
|
128
|
+
"section_idx": i,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
out_path.write_text(json.dumps(sections, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
133
|
+
logger.info(f"Classified {len(sections)} sections → {out_path}")
|
|
134
|
+
return sections
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main():
|
|
138
|
+
parser = argparse.ArgumentParser(description="Classify sections")
|
|
139
|
+
parser.add_argument("--stem", required=True)
|
|
140
|
+
args = parser.parse_args()
|
|
141
|
+
|
|
142
|
+
result = classify_sections(args.stem)
|
|
143
|
+
counts = Counter(s["type"] for s in result)
|
|
144
|
+
print(f"Classified {len(result)} sections: {dict(counts)}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
main()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Global configuration: directory layout, device selection, and tuning knobs.
|
|
2
|
+
|
|
3
|
+
Directory paths default to subdirectories of the
|
|
4
|
+
working directory (pdf/, markdown/, intermediate/, output/).
|
|
5
|
+
|
|
6
|
+
The embedding device is auto-detected: CUDA if available, then MPS (Apple
|
|
7
|
+
Silicon), then CPU. Override with EMBEDDING_DEVICE in .env.
|
|
8
|
+
|
|
9
|
+
Tuning constants (similarity threshold, search top-K, vision complexity
|
|
10
|
+
threshold, etc.) have sensible defaults but can be overridden per-run in .env.
|
|
11
|
+
"""
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from importlib import resources
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
CWD = Path.cwd()
|
|
19
|
+
PDF_DIR = CWD / "pdf"
|
|
20
|
+
MARKDOWN_DIR = CWD / "markdown"
|
|
21
|
+
INTERMEDIATE_DIR = CWD / "intermediate"
|
|
22
|
+
OUTPUT_DIR = CWD / "output"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
SECTION_CLASSIFIER_DIR = resources.files("structured_data_extraction") / "models" / "section_classifier"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _auto_device():
|
|
29
|
+
import torch
|
|
30
|
+
if torch.cuda.is_available():
|
|
31
|
+
return "cuda"
|
|
32
|
+
if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
|
|
33
|
+
return "mps"
|
|
34
|
+
return "cpu"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
EMBEDDING_DEVICE = os.environ.get("EMBEDDING_DEVICE") or _auto_device()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# The models to run for extraction, one or many
|
|
41
|
+
# The first one is the "primary": used wherever a single model is needed
|
|
42
|
+
EXTRACTION_MODELS = [m.strip() for m in os.environ["EXTRACTION_MODELS"].split(",") if m.strip()]
|
|
43
|
+
assert EXTRACTION_MODELS, "EXTRACTION_MODELS in .env must list at least one model"
|
|
44
|
+
|
|
45
|
+
# When True (set by main.py --force), every cached pipeline step recomputes instead of skipping.
|
|
46
|
+
FORCE = False
|
|
47
|
+
|
|
48
|
+
# Section-type preference when deduping records that describe the same fact: table beats text beats list.
|
|
49
|
+
SECTION_TYPE_PRIORITY = {"table": 0, "text": 1, "list": 2}
|
|
50
|
+
|
|
51
|
+
# Tuning constants — overridable in .env; the values here are the defaults if unset.
|
|
52
|
+
def _env_bool(name, default):
|
|
53
|
+
v = os.environ.get(name)
|
|
54
|
+
return default if v is None else v.strip().lower() in ("1", "true", "yes", "on")
|
|
55
|
+
|
|
56
|
+
VOCAB_TOP_N = int(os.environ.get("VOCAB_TOP_N", 50))
|
|
57
|
+
VOCAB_MAX_NGRAM = int(os.environ.get("VOCAB_MAX_NGRAM", 4))
|
|
58
|
+
SIMILARITY_THRESHOLD = float(os.environ.get("SIMILARITY_THRESHOLD", 0.55))
|
|
59
|
+
SEARCH_TOP_K = int(os.environ.get("SEARCH_TOP_K", 25))
|
|
60
|
+
VISION_COMPLEXITY_THRESHOLD = float(os.environ.get("VISION_COMPLEXITY_THRESHOLD", 0.15))
|
|
61
|
+
VISION_PARALLELISM = int(os.environ.get("VISION_PARALLELISM", 4))
|
|
62
|
+
EXPAND_KEYS = _env_bool("EXPAND_KEYS", True)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""PDF-to-structured-items conversion via Docling.
|
|
2
|
+
|
|
3
|
+
Converts a PDF into two artifacts: a full-document Markdown file and a
|
|
4
|
+
per-item JSON list (_items.json) where each item carries its Docling label,
|
|
5
|
+
text, page number, and bounding box.
|
|
6
|
+
|
|
7
|
+
OCR is disabled because the target documents are born-digital; Docling's table
|
|
8
|
+
structure model runs in FAST mode (roughly 2x faster than ACCURATE) because the
|
|
9
|
+
downstream vision-refinement step in tables.py re-reads complex tables anyway,
|
|
10
|
+
so the speed/accuracy trade-off favours throughput here.
|
|
11
|
+
|
|
12
|
+
Bounding boxes are stored in Docling's native BOTTOMLEFT coordinate origin.
|
|
13
|
+
Any consumer that needs top-left origin (e.g. PyMuPDF rendering in tables.py)
|
|
14
|
+
converts on read.
|
|
15
|
+
"""
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import re
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from docling.document_converter import DocumentConverter, PdfFormatOption
|
|
22
|
+
from docling.datamodel.base_models import InputFormat
|
|
23
|
+
from docling.datamodel.pipeline_options import PdfPipelineOptions, TableFormerMode
|
|
24
|
+
from docling.backend.pypdfium2_backend import PyPdfiumDocumentBackend
|
|
25
|
+
from . import config
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_converter = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_converter():
|
|
33
|
+
"""Build the Docling converter on first use — it loads vision models, so importing
|
|
34
|
+
this module shouldn't pay that cost."""
|
|
35
|
+
global _converter
|
|
36
|
+
if _converter is None:
|
|
37
|
+
#Custom Docling pipeline configuration for PDF conversion
|
|
38
|
+
pipeline_opts = PdfPipelineOptions()
|
|
39
|
+
pipeline_opts.do_ocr = False
|
|
40
|
+
pipeline_opts.do_table_structure = True
|
|
41
|
+
pipeline_opts.table_structure_options.mode = TableFormerMode.FAST #reduced runtime by 2x compared to accurate mode,similar output quality
|
|
42
|
+
#since we pay the price of vision refinement anyways at least we have speed
|
|
43
|
+
_converter = DocumentConverter(
|
|
44
|
+
format_options={
|
|
45
|
+
InputFormat.PDF: PdfFormatOption(
|
|
46
|
+
pipeline_options=pipeline_opts,
|
|
47
|
+
backend=PyPdfiumDocumentBackend, # since we don't need OCR, we can use the faster PyPdfium backend instead of Tesseract
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
return _converter
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_CTRL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]") # control characters that break JSON and Markdown rendering
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _scrub(text):
|
|
58
|
+
"""Remove control characters from text, which can break JSON and Markdown rendering."""
|
|
59
|
+
return _CTRL_RE.sub("", text) if text else text
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get_item_text(item, label, doc):
|
|
63
|
+
"""Turn one docling item into Markdown text.
|
|
64
|
+
|
|
65
|
+
Docling's whole-doc export gives one big string, we need the text for
|
|
66
|
+
each item on its own so it stays lined up with that item's label, page, and
|
|
67
|
+
bbox. So process each item by type: tables go through docling, list items
|
|
68
|
+
get their marker in front, a title gets one '#' and a section_header gets
|
|
69
|
+
'#' x its docling level, everything else is just its plain text. Gives back
|
|
70
|
+
"" when an item has no text.
|
|
71
|
+
|
|
72
|
+
The header depth feeds the section classifier (features.regex_features reads it
|
|
73
|
+
back off the '#' prefix), so keep emitting the level even though the markdown
|
|
74
|
+
export wouldn't strictly need it.
|
|
75
|
+
"""
|
|
76
|
+
# tables store content differently, need export_to_markdown
|
|
77
|
+
if label == "table":
|
|
78
|
+
try:
|
|
79
|
+
return item.export_to_markdown(doc)
|
|
80
|
+
except TypeError:
|
|
81
|
+
return item.export_to_markdown() # older docling: no doc arg
|
|
82
|
+
|
|
83
|
+
raw = getattr(item, "text", "") or ""
|
|
84
|
+
if label == "list_item":
|
|
85
|
+
marker = getattr(item, "marker", "") or ""
|
|
86
|
+
return f"{marker} {raw}".strip() if raw else ""
|
|
87
|
+
if label == "section_header":
|
|
88
|
+
lvl = getattr(item, "level", None)
|
|
89
|
+
depth = min(max(int(lvl) if lvl else 2, 1), 6) # docling level, default 2, clamped to 1..6
|
|
90
|
+
return ("#" * depth + " " + raw) if raw else ""
|
|
91
|
+
if label == "title":
|
|
92
|
+
return ("# " + raw) if raw else ""
|
|
93
|
+
return raw
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _get_bbox(item):
|
|
97
|
+
if not hasattr(item, 'prov') or not item.prov:
|
|
98
|
+
return None, None
|
|
99
|
+
prov = item.prov[0]
|
|
100
|
+
page = prov.page_no
|
|
101
|
+
bbox = None
|
|
102
|
+
if hasattr(prov, 'bbox') and prov.bbox:
|
|
103
|
+
bbox = {
|
|
104
|
+
"l": prov.bbox.l, "t": prov.bbox.t,
|
|
105
|
+
"r": prov.bbox.r, "b": prov.bbox.b,
|
|
106
|
+
"coord_origin": str(prov.bbox.coord_origin),
|
|
107
|
+
}
|
|
108
|
+
return page, bbox
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def convert_pdf(pdf_path):
|
|
112
|
+
pdf_path = Path(pdf_path)
|
|
113
|
+
if not pdf_path.exists():
|
|
114
|
+
raise FileNotFoundError(f"PDF not found: {pdf_path}")
|
|
115
|
+
|
|
116
|
+
out_md = Path(config.MARKDOWN_DIR) / f"{pdf_path.stem}.md"
|
|
117
|
+
out_items = Path(config.INTERMEDIATE_DIR) / f"{pdf_path.stem}_items.json"
|
|
118
|
+
out_md.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
out_items.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
|
|
121
|
+
if out_md.exists() and out_items.exists() and not config.FORCE:
|
|
122
|
+
logger.info(f"Skipping {pdf_path.name} — already exists")
|
|
123
|
+
return str(out_md)
|
|
124
|
+
|
|
125
|
+
logger.info(f"Converting {pdf_path.name}...")
|
|
126
|
+
try:
|
|
127
|
+
result = _get_converter().convert(str(pdf_path))
|
|
128
|
+
doc = result.document
|
|
129
|
+
|
|
130
|
+
md_text = _scrub(doc.export_to_markdown())
|
|
131
|
+
out_md.write_text(md_text, encoding="utf-8")
|
|
132
|
+
|
|
133
|
+
# collect items from docling
|
|
134
|
+
items = []
|
|
135
|
+
for item, _ in doc.iterate_items():
|
|
136
|
+
label = str(item.label) if hasattr(item, 'label') else "unknown"
|
|
137
|
+
text = _scrub(_get_item_text(item, label, doc))
|
|
138
|
+
page, bbox = _get_bbox(item)
|
|
139
|
+
items.append({"label": label, "text": text, "page": page, "bbox": bbox})
|
|
140
|
+
|
|
141
|
+
out_items.write_text(json.dumps(items, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
logger.info(f"Saved markdown ({len(md_text)} chars) + {len(items)} items → {out_md}")
|
|
145
|
+
return str(out_md)
|
|
146
|
+
|
|
147
|
+
except Exception as e:
|
|
148
|
+
logger.error(f"Failed to convert {pdf_path.name}: {e}")
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main():
|
|
153
|
+
parser = argparse.ArgumentParser(description="Convert PDF to Markdown")
|
|
154
|
+
parser.add_argument("--input", required=True, help="Path to PDF file")
|
|
155
|
+
args = parser.parse_args()
|
|
156
|
+
|
|
157
|
+
result = convert_pdf(args.input)
|
|
158
|
+
if result:
|
|
159
|
+
print(f"Done: {result}")
|
|
160
|
+
else:
|
|
161
|
+
print("Conversion failed.")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
main()
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Embedding of document sections, table rows, and user keys.
|
|
2
|
+
|
|
3
|
+
Encodes every section and individual table row into dense vectors with a
|
|
4
|
+
SentenceTransformer model (default: nomic-embed-text-v1.5), then encodes the
|
|
5
|
+
user's extraction keys into the same space so search.py can match them via
|
|
6
|
+
cosine similarity.
|
|
7
|
+
|
|
8
|
+
nomic requires task-type prefixes: sections are prefixed with "search_document:"
|
|
9
|
+
and keys with "search_query:". Without these prefixes similarity scores collapse
|
|
10
|
+
and SIMILARITY_THRESHOLD stops discriminating relevant from irrelevant hits.
|
|
11
|
+
|
|
12
|
+
Sections are stacked before rows in the embedding matrix, search.py relies on
|
|
13
|
+
that ordering to tell section-level hits from row-level hits using num_sections
|
|
14
|
+
as the boundary index.
|
|
15
|
+
|
|
16
|
+
Table rows are parsed into per-row text (with group headers prepended) so that
|
|
17
|
+
search can match an individual parameter row inside a large table rather than
|
|
18
|
+
only matching the table as a whole.
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
import numpy as np
|
|
27
|
+
from sentence_transformers import SentenceTransformer
|
|
28
|
+
from . import config
|
|
29
|
+
from .paths import inter, read_json, write_json
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
EMBED_BATCH_SIZE = int(os.environ.get("EMBED_BATCH_SIZE", 4))
|
|
35
|
+
|
|
36
|
+
_model_cache = {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _load_model(model_name=None):
|
|
40
|
+
"""Load the embedding model (from the given name or env), caching it so the hundreds-of-MB
|
|
41
|
+
SentenceTransformer is loaded once and reused across PDFs."""
|
|
42
|
+
name = model_name or os.environ["EMBEDDING_MODEL"]
|
|
43
|
+
if name not in _model_cache:
|
|
44
|
+
logger.info(f"Loading embedding model: {name} on {config.EMBEDDING_DEVICE}")
|
|
45
|
+
_model_cache[name] = SentenceTransformer(name, device=config.EMBEDDING_DEVICE, trust_remote_code=True)
|
|
46
|
+
return _model_cache[name]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _expand_key(key, vocab):
|
|
50
|
+
"""Return a tuple of (expanded_key, original_key). If EXPAND_KEYS is False, returns (key, key)."""
|
|
51
|
+
if not config.EXPAND_KEYS:
|
|
52
|
+
return key, key
|
|
53
|
+
|
|
54
|
+
key_lower = key.lower()
|
|
55
|
+
key_words = key_lower.split()
|
|
56
|
+
expansions = []
|
|
57
|
+
for item in vocab:
|
|
58
|
+
term_lower = item["term"].lower()
|
|
59
|
+
term_words = term_lower.split()
|
|
60
|
+
if key_lower in term_lower or all(word in term_words for word in key_words):
|
|
61
|
+
expansions.append(item["term"])
|
|
62
|
+
|
|
63
|
+
if expansions:
|
|
64
|
+
return " ".join([key, " ".join(expansions[:3])]), key
|
|
65
|
+
return key, key
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_group_header(cells):
|
|
69
|
+
"""Determine if a row is likely a group header based on its content."""
|
|
70
|
+
if len(cells) <= 1 or not cells[0]:
|
|
71
|
+
return False
|
|
72
|
+
empty_count = sum(1 for cell in cells[1:] if not cell)
|
|
73
|
+
standalone_nums = sum(
|
|
74
|
+
1 for cell in cells[1:]
|
|
75
|
+
if cell and re.match(r'^[\±\-\+]?\d+\.?\d*\s*[a-zA-Zµ°/²³%]*$', cell.strip()) # matches standalone numbers with optional units
|
|
76
|
+
)
|
|
77
|
+
has_equals = '=' in cells[0]
|
|
78
|
+
return empty_count >= len(cells[1:]) * 0.6 and standalone_nums < 2 and not has_equals # if most cells are empty, few standalone numbers,
|
|
79
|
+
# and no equals sign in the first cell, it's likely a group header
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_html_rows(content):
|
|
83
|
+
"""Parse table rows from HTML content, return a list of row dictionaries."""
|
|
84
|
+
rows = []
|
|
85
|
+
for n, tr in enumerate(re.findall(r"<tr\b[^>]*>(.*?)</tr>", content, re.S | re.I)): # find all <tr>...</tr> blocks
|
|
86
|
+
cells = [re.sub(r"<[^>]+>", "", c).strip()
|
|
87
|
+
for c in re.findall(r"<t[dh]\b[^>]*>(.*?)</t[dh]>", tr, re.S | re.I)] # find all <td> or <th> blocks within the <tr>
|
|
88
|
+
dedup = []
|
|
89
|
+
for c in cells: # collapse repeats from rowspan/colspan expansion
|
|
90
|
+
if not dedup or dedup[-1] != c:
|
|
91
|
+
dedup.append(c)
|
|
92
|
+
row_text = " | ".join(c for c in dedup if c)
|
|
93
|
+
if row_text.strip():
|
|
94
|
+
rows.append({"row_number": n, "row_text": row_text, "group_header": ""})
|
|
95
|
+
return rows
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_table_rows(content):
|
|
99
|
+
"""Parse table rows from markdown content, return a list of row dictionaries."""
|
|
100
|
+
if "<tr" in content.lower(): # pre-embed refined tables are HTML, not markdown pipes
|
|
101
|
+
return _parse_html_rows(content)
|
|
102
|
+
lines = content.strip().split('\n')
|
|
103
|
+
rows = []
|
|
104
|
+
group_header = ""
|
|
105
|
+
col_headers = []
|
|
106
|
+
row_num = 0
|
|
107
|
+
|
|
108
|
+
for line in lines:
|
|
109
|
+
stripped = line.strip()
|
|
110
|
+
if not stripped or not stripped.startswith('|'):
|
|
111
|
+
continue
|
|
112
|
+
if re.match(r'^\|[\s\-:|]+\|$', stripped):
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
cells = [cell.strip() for cell in stripped.split('|')]
|
|
116
|
+
if cells and cells[0] == '':
|
|
117
|
+
cells = cells[1:]
|
|
118
|
+
if cells and cells[-1] == '':
|
|
119
|
+
cells = cells[:-1]
|
|
120
|
+
if not cells:
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
non_empty = [cell for cell in cells if cell]
|
|
125
|
+
if non_empty and all(cell in ('Min', 'Typ', 'Max') for cell in non_empty) and len(non_empty) >= 3:
|
|
126
|
+
col_headers = cells
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
if _is_group_header(cells):
|
|
130
|
+
group_header = cells[0]
|
|
131
|
+
row_text = ' | '.join(cell for cell in cells if cell)
|
|
132
|
+
if row_text.strip():
|
|
133
|
+
rows.append({"row_number": row_num, "row_text": row_text, "group_header": ""})
|
|
134
|
+
row_num += 1
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
if col_headers and len(col_headers) == len(cells):
|
|
138
|
+
annotated = []
|
|
139
|
+
for i, cell in enumerate(cells):
|
|
140
|
+
if cell and col_headers[i]:
|
|
141
|
+
annotated.append(f"[{col_headers[i]}] {cell}")
|
|
142
|
+
else:
|
|
143
|
+
annotated.append(cell)
|
|
144
|
+
row_text = ' | '.join(annotated)
|
|
145
|
+
else:
|
|
146
|
+
row_text = ' | '.join(cells)
|
|
147
|
+
|
|
148
|
+
if row_text.strip():
|
|
149
|
+
rows.append({"row_number": row_num, "row_text": row_text, "group_header": group_header})
|
|
150
|
+
row_num += 1
|
|
151
|
+
|
|
152
|
+
return rows
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _build_section_texts(sections, tables):
|
|
156
|
+
"""Build per-section text list, replacing table sections with their cleaned markdown content."""
|
|
157
|
+
sec_texts = [f"{section['type']}: {section['content']}" for section in sections]
|
|
158
|
+
table_by_idx = {table["section_idx"]: table for table in tables}
|
|
159
|
+
for i, section in enumerate(sections):
|
|
160
|
+
if section.get("type") == "table" and i in table_by_idx:
|
|
161
|
+
cleaned = table_by_idx[i].get("content", "")
|
|
162
|
+
if cleaned:
|
|
163
|
+
sec_texts[i] = f"table: {cleaned}"
|
|
164
|
+
return sec_texts, table_by_idx
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _parse_all_rows(sections, table_by_idx):
|
|
168
|
+
"""Parse table rows from all table sections, return (row_texts, row_map)."""
|
|
169
|
+
row_texts = []
|
|
170
|
+
row_map = []
|
|
171
|
+
for i, section in enumerate(sections):
|
|
172
|
+
if section.get("type") != "table" or i not in table_by_idx:
|
|
173
|
+
continue
|
|
174
|
+
cleaned = table_by_idx[i].get("content", "")
|
|
175
|
+
if not cleaned:
|
|
176
|
+
continue
|
|
177
|
+
for row_info in _parse_table_rows(cleaned):
|
|
178
|
+
txt = row_info["row_text"]
|
|
179
|
+
if row_info["group_header"]:
|
|
180
|
+
txt = f"{row_info['group_header']} > {txt}"
|
|
181
|
+
row_texts.append(f"table_row: {txt}")
|
|
182
|
+
row_map.append({
|
|
183
|
+
"section_idx": i,
|
|
184
|
+
"row_number": row_info["row_number"],
|
|
185
|
+
"row_text": row_info["row_text"],
|
|
186
|
+
"group_header": row_info["group_header"],
|
|
187
|
+
})
|
|
188
|
+
return row_texts, row_map
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _expand_all_keys(user_keys, vocab):
|
|
192
|
+
expansions = [_expand_key(key, vocab) for key in user_keys]
|
|
193
|
+
expanded = [pair[0] for pair in expansions]
|
|
194
|
+
originals = [pair[1] for pair in expansions]
|
|
195
|
+
return expanded, originals
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _save_outputs(stem, out_dir, all_embs, key_embs, all_texts, expanded, originals, row_map,
|
|
199
|
+
num_sections):
|
|
200
|
+
"""Write the embedding + text artifacts for this stem."""
|
|
201
|
+
np.save(str(out_dir / f"{stem}_section_embeddings.npy"), all_embs)
|
|
202
|
+
np.save(str(out_dir / f"{stem}_key_embeddings.npy"), key_embs)
|
|
203
|
+
write_json(out_dir / f"{stem}_section_texts.json", all_texts)
|
|
204
|
+
write_json(out_dir / f"{stem}_key_texts.json", expanded)
|
|
205
|
+
write_json(out_dir / f"{stem}_original_keys.json", originals)
|
|
206
|
+
write_json(out_dir / f"{stem}_row_map.json", {"num_sections": num_sections, "rows": row_map})
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def embed_sections_and_keys(stem, user_keys=None, model_name=None):
|
|
210
|
+
out_dir = Path(config.INTERMEDIATE_DIR)
|
|
211
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
|
|
213
|
+
sec_names = ("section_embeddings.npy", "section_texts.json", "row_map.json")
|
|
214
|
+
key_names = ("key_embeddings.npy", "key_texts.json")
|
|
215
|
+
sec_cached = all(inter(stem, n).exists() for n in sec_names) and not config.FORCE
|
|
216
|
+
key_cached = all(inter(stem, n).exists() for n in key_names) and not config.FORCE
|
|
217
|
+
|
|
218
|
+
if sec_cached and key_cached:
|
|
219
|
+
logger.info(f"Skipping embeddings — already cached for {stem}")
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
sections = read_json(inter(stem, "sections.json"))
|
|
223
|
+
tables = read_json(inter(stem, "tables.json")) if inter(stem, "tables.json").exists() else []
|
|
224
|
+
vocab = read_json(inter(stem, "vocab.json")) if inter(stem, "vocab.json").exists() else []
|
|
225
|
+
|
|
226
|
+
if user_keys is None:
|
|
227
|
+
user_keys = []
|
|
228
|
+
|
|
229
|
+
model = _load_model(model_name)
|
|
230
|
+
|
|
231
|
+
if sec_cached:
|
|
232
|
+
logger.info(f"Reusing cached section embeddings for {stem}")
|
|
233
|
+
all_embs = np.load(str(inter(stem, "section_embeddings.npy")))
|
|
234
|
+
all_texts = read_json(inter(stem, "section_texts.json"))
|
|
235
|
+
rm = read_json(inter(stem, "row_map.json"))
|
|
236
|
+
num_sections = rm.get("num_sections", len(sections))
|
|
237
|
+
row_map = rm.get("rows", [])
|
|
238
|
+
else:
|
|
239
|
+
sec_texts, table_by_idx = _build_section_texts(sections, tables)
|
|
240
|
+
row_texts, row_map = _parse_all_rows(sections, table_by_idx)
|
|
241
|
+
logger.info(f"Parsed {len(row_map)} table rows")
|
|
242
|
+
all_texts = sec_texts + row_texts
|
|
243
|
+
num_sections = len(sec_texts)
|
|
244
|
+
logger.info(f"Embedding {len(sec_texts)} sections + {len(row_texts)} rows...")
|
|
245
|
+
all_embs = model.encode([f"search_document: {t}" for t in all_texts],
|
|
246
|
+
show_progress_bar=False, batch_size=EMBED_BATCH_SIZE)
|
|
247
|
+
|
|
248
|
+
expanded, originals = _expand_all_keys(user_keys, vocab)
|
|
249
|
+
logger.info(f"Embedding {len(expanded)} keys...")
|
|
250
|
+
key_embs = (model.encode([f"search_query: {k}" for k in expanded],
|
|
251
|
+
show_progress_bar=False, batch_size=EMBED_BATCH_SIZE)
|
|
252
|
+
if expanded else np.array([]))
|
|
253
|
+
|
|
254
|
+
_save_outputs(stem, out_dir, all_embs, key_embs, all_texts, expanded, originals,
|
|
255
|
+
row_map, num_sections)
|
|
256
|
+
logger.info(f"Saved embeddings: {all_embs.shape[0]} content ({num_sections} sections + {len(row_map)} rows), {len(expanded)} keys")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def main():
|
|
260
|
+
parser = argparse.ArgumentParser(description="Embed sections and keys")
|
|
261
|
+
parser.add_argument("--stem", required=True)
|
|
262
|
+
parser.add_argument("--keys", nargs="+")
|
|
263
|
+
parser.add_argument("--model")
|
|
264
|
+
args = parser.parse_args()
|
|
265
|
+
embed_sections_and_keys(args.stem, user_keys=args.keys, model_name=args.model)
|
|
266
|
+
print("Done")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
if __name__ == "__main__":
|
|
270
|
+
main()
|