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,8 @@
1
+ """
2
+ Loads environment variables from the nearest .env file on import so that
3
+ config.py and all other modules see the user's settings immediately.
4
+ """
5
+ from dotenv import load_dotenv, find_dotenv
6
+
7
+
8
+ load_dotenv(find_dotenv(usecwd=True))
@@ -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()