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,406 @@
1
+ """Table complexity scoring and vision-based refinement.
2
+
3
+ Docling's table parser sometimes garbles complex tables: merged cells get lost,
4
+ columns misalign, and symbol-heavy rows (units, ±, subscripts) collapse into
5
+ nonsense. This module scores each table's structural complexity and, for tables
6
+ above VISION_COMPLEXITY_THRESHOLD, sends the original PDF crop to a vision-
7
+ capable LLM to reconstruct clean HTML.
8
+
9
+ Two refinement passes exist:
10
+ 1. Pre-embed (refine_symbol_only_tables) — tables whose cells are almost
11
+ entirely symbols (word-cell ratio < 10%) are refined *before* embedding.
12
+ Without real words their embeddings would be meaningless and search could
13
+ never retrieve them.
14
+ 2. Post-search (refine_matched_tables) — tables that search actually matched
15
+ are refined lazily, so the LLM cost is paid only for tables the user's
16
+ keys care about.
17
+
18
+ The complexity score combines sparsity, ragged column counts, vertical merges,
19
+ hierarchy (group-header rows), table width, and HTML keywords. Vision refinement
20
+ renders the PDF bounding box via PyMuPDF, sends the image to the vision model,
21
+ and expands any remaining rowspan/colspan into flat repeated cells so downstream
22
+ extraction sees a regular grid.
23
+ """
24
+ import base64
25
+ import concurrent.futures
26
+ import json
27
+ import logging
28
+ import os
29
+ import re
30
+ from collections import Counter
31
+ from html.parser import HTMLParser
32
+ from pathlib import Path
33
+ import fitz
34
+ from openai import OpenAI
35
+ from . import config
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ def _ramp(x, lo, hi):
41
+ """Return a value between 0.0 and 1.0, ramping linearly from lo to hi."""
42
+ if x <= lo:
43
+ return 0.0
44
+ if x >= hi:
45
+ return 1.0
46
+ return (x - lo) / (hi - lo)
47
+
48
+
49
+ def _keyword_score(text):
50
+ """Return a score between 0.0 and 1.0 based on the presence of keywords that indicate complexity."""
51
+ keywords = ("colspan", "rowspan", "<figure", "<img", "nested")
52
+ return sum(0.15 for kw in keywords if kw in text)
53
+
54
+
55
+ def _pipe_stats(content):
56
+ """Return (ragged, sparsity, hierarchy, ncols, merges) for a pipe-delimited table,
57
+ or None if the content has no pipe rows."""
58
+ pipe_rows = [line for line in content.split("\n") if "|" in line]
59
+ if not pipe_rows:
60
+ return None
61
+
62
+ delims = [row.count("|") for row in pipe_rows]
63
+ mode = Counter(delims).most_common(1)[0][0]
64
+ ragged = sum(1 for d in delims if d != mode) / len(delims)
65
+
66
+ # sparsity + hierarchy over data rows (separators skipped)
67
+ data = []
68
+ for row in pipe_rows:
69
+ inner = [c.strip() for c in row.split("|")[1:-1]]
70
+ if inner and all(set(c) <= {"-", ":", " ", ""} for c in inner):
71
+ continue
72
+ data.append(inner)
73
+
74
+ cells = sum(len(r) for r in data)
75
+ empty = sum(1 for r in data for c in r if not c)
76
+ sparsity = empty / cells if cells else 0.0
77
+ group_rows = sum(1 for r in data if r and r[0] and not any(r[1:]))
78
+ hierarchy = group_rows / len(data) if data else 0.0
79
+ ncols = max(mode - 1, 1)
80
+
81
+ # vertical merges: empty cells below a filled cell in the same column
82
+ spanned = 0
83
+ width = max((len(r) for r in data), default=0)
84
+ for col in range(width):
85
+ filled_above = False
86
+ for r in data:
87
+ cell = r[col] if col < len(r) else ""
88
+ if cell:
89
+ filled_above = True
90
+ elif filled_above:
91
+ spanned += 1
92
+ merges = spanned / cells if cells else 0.0
93
+
94
+ return ragged, sparsity, hierarchy, ncols, merges
95
+
96
+
97
+ def score_table_complexity(content):
98
+ text = content.lower()
99
+ score = _keyword_score(text)
100
+
101
+ stats = _pipe_stats(content)
102
+ if stats is not None:
103
+ ragged, sparsity, hierarchy, ncols, merges = stats
104
+ score += _ramp(sparsity, 0.15, 0.60) * 0.40 # sparse grid
105
+ score += ragged * 0.30 # broken rows
106
+ score += min(hierarchy / 0.30, 1.0) * 0.20 # nested groups
107
+ score += _ramp(ncols, 4, 12) * 0.10 # wide tables
108
+ score += _ramp(merges, 0.05, 0.35) * 0.35 # vertically merged cells
109
+
110
+ return min(score, 1.0)
111
+
112
+
113
+ def _render(pdf_path, page_number, bbox=None, dpi=200):
114
+ """Render a page (or a bounding box on a page) of a PDF to a base64-encoded PNG."""
115
+ doc = fitz.open(pdf_path)
116
+ idx = max(0, min(page_number - 1, len(doc) - 1))
117
+ page = doc[idx]
118
+
119
+ clip = None
120
+ if bbox:
121
+ h = page.rect.height
122
+ # docling uses BOTTOMLEFT origin, PyMuPDF uses top-left
123
+ origin = bbox.get("coord_origin", "BOTTOMLEFT")
124
+ if "BOTTOM" in origin:
125
+ clip = fitz.Rect(bbox["l"], h - bbox["t"], bbox["r"], h - bbox["b"])
126
+ else:
127
+ clip = fitz.Rect(bbox["l"], bbox["t"], bbox["r"], bbox["b"])
128
+
129
+ pix = page.get_pixmap(dpi=dpi, clip=clip)
130
+ b64 = base64.b64encode(pix.tobytes("png")).decode("utf-8")
131
+ doc.close()
132
+ return b64
133
+
134
+
135
+ TABLE_VISION_PROMPT = (
136
+ "You are a table-to-HTML conversion expert. Look at the table in the provided image "
137
+ "and convert it to clean, semantic HTML. Use proper <thead>, <tbody>, <th>, <td> tags. "
138
+ "If a cell is merged/spanned in the original, REPEAT its value in every row and column it "
139
+ "covers — do NOT use rowspan or colspan; every <td> must be filled. "
140
+ "Preserve ALL values exactly as shown — do not omit or invent data. "
141
+ "Do NOT add any explanation, just output the HTML table.\n"
142
+ )
143
+
144
+ TABLE_TEXT_PROMPT = (
145
+ "The following is a table extracted from a PDF by an automated tool, so it may be "
146
+ "garbled: columns misaligned, cells split or merged wrongly, headers out of place. "
147
+ "Reconstruct it as clean, semantic HTML. Use proper <thead>, <tbody>, <th>, <td> tags. "
148
+ "If a cell is merged/spanned, REPEAT its value in every row and column it covers — "
149
+ "do NOT use rowspan or colspan; every <td> must be filled. "
150
+ "Preserve ALL values exactly as shown — do not omit or invent data. "
151
+ "Do NOT add any explanation, just output the HTML table.\n\n"
152
+ )
153
+
154
+
155
+ def strip_code_fence(text):
156
+ """Strip a code fence from the start and end of a string, if present."""
157
+ text = re.sub(r"^```[a-zA-Z]*\n?", "", text)
158
+ text = re.sub(r"\n?```$", "", text)
159
+ return text
160
+
161
+
162
+ _ollama_client = None
163
+
164
+
165
+ def _client():
166
+ global _ollama_client
167
+ if _ollama_client is None:
168
+ _ollama_client = OpenAI(base_url=os.environ["LLM_BASE_URL"], api_key=os.environ["LLM_API_KEY"])
169
+ return _ollama_client
170
+
171
+
172
+ def _call_model(model, messages):
173
+ resp = _client().chat.completions.create(
174
+ model=model,
175
+ messages=messages,
176
+ temperature=0.0,
177
+ )
178
+ return strip_code_fence(resp.choices[0].message.content.strip())
179
+
180
+
181
+ def _call_vision_model(b64_img):
182
+ messages = [{
183
+ "role": "user",
184
+ "content": [
185
+ {"type": "text", "text": TABLE_VISION_PROMPT},
186
+ {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64_img}"}},
187
+ ],
188
+ }]
189
+ return _call_model(os.environ["VISION_MODEL"], messages)
190
+
191
+
192
+ def _call_text_model(table_md):
193
+ messages = [{"role": "user", "content": TABLE_TEXT_PROMPT + table_md}]
194
+ # single-model task → use the primary (first) extraction model
195
+ return _call_model(config.EXTRACTION_MODELS[0], messages)
196
+
197
+
198
+ class _TableParser(HTMLParser):
199
+ """Parse an HTML table into a list of rows, each row being a list of (tag, text, colspan, rowspan)."""
200
+ def __init__(self):
201
+ super().__init__()
202
+ self.rows = []
203
+ self._row = None
204
+ self._cell = None
205
+ self._attrs = {}
206
+
207
+ def handle_starttag(self, tag, attrs):
208
+ if tag == "tr":
209
+ self._row = []
210
+ elif tag in ("td", "th") and self._row is not None:
211
+ self._attrs = dict(attrs)
212
+ self._cell = []
213
+
214
+ def handle_data(self, data):
215
+ if self._cell is not None:
216
+ self._cell.append(data)
217
+
218
+ def handle_endtag(self, tag):
219
+ if tag in ("td", "th") and self._cell is not None:
220
+ self._row.append((tag, "".join(self._cell).strip(),
221
+ int(self._attrs.get("colspan") or 1),
222
+ int(self._attrs.get("rowspan") or 1)))
223
+ self._cell = None
224
+ elif tag == "tr" and self._row is not None:
225
+ self.rows.append(self._row)
226
+ self._row = None
227
+
228
+
229
+ def _expand_spans(html):
230
+ """Expand an HTML table so that every <td> is filled, repeating values for merged cells."""
231
+ parser = _TableParser()
232
+ parser.feed(html)
233
+ if not parser.rows:
234
+ return html
235
+ grid = {}
236
+ for r, row in enumerate(parser.rows):
237
+ c = 0
238
+ for tag, text, colspan, rowspan in row:
239
+ while (r, c) in grid:
240
+ c += 1
241
+ for dr in range(rowspan):
242
+ for dc in range(colspan):
243
+ grid[(r + dr, c + dc)] = (tag, text)
244
+ c += colspan
245
+ width = max((c for _, c in grid), default=-1) + 1
246
+ lines = ["<table>"]
247
+ for r in range(len(parser.rows)):
248
+ lines.append(" <tr>")
249
+ for c in range(width):
250
+ tag, text = grid.get((r, c), ("td", ""))
251
+ lines.append(f" <{tag}>{text}</{tag}>")
252
+ lines.append(" </tr>")
253
+ lines.append("</table>")
254
+ return "\n".join(lines)
255
+
256
+
257
+ def process_tables(sections_path):
258
+ """Process table sections from a JSON file, scoring their complexity and saving the results."""
259
+ sections_path = Path(sections_path)
260
+ if not sections_path.exists():
261
+ raise FileNotFoundError(f"Sections not found: {sections_path}")
262
+
263
+ stem = sections_path.stem.replace("_sections", "")
264
+ out_path = Path(config.INTERMEDIATE_DIR) / f"{stem}_tables.json"
265
+ out_path.parent.mkdir(parents=True, exist_ok=True)
266
+
267
+ if out_path.exists() and not config.FORCE:
268
+ logger.info(f"Skipping tables — already exists")
269
+ return json.loads(out_path.read_text(encoding="utf-8"))
270
+
271
+ sections = json.loads(sections_path.read_text(encoding="utf-8"))
272
+ table_sections = [(i, s) for i, s in enumerate(sections) if s["type"] == "table"]
273
+
274
+ if not table_sections:
275
+ logger.info("No tables found")
276
+ out_path.write_text("[]", encoding="utf-8")
277
+ return []
278
+
279
+ logger.info(f"Scoring {len(table_sections)} tables...")
280
+ results = []
281
+
282
+ for i, (section_idx, section) in enumerate(table_sections):
283
+ content = section["content"]
284
+ complexity = score_table_complexity(content)
285
+ page = section.get("page") or 1
286
+
287
+ logger.info(f" Table {i}: complexity={complexity:.2f}")
288
+
289
+ results.append({
290
+ "section_idx": section_idx,
291
+ "complexity_score": round(complexity, 4),
292
+ "page": page,
293
+ "content": content,
294
+ "bbox": section.get("bbox"),
295
+ })
296
+
297
+ out_path.write_text(json.dumps(results, indent=2, ensure_ascii=False), encoding="utf-8")
298
+ logger.info(f"Scored {len(results)} tables → {out_path}")
299
+ return results
300
+
301
+
302
+ def _refine_one_table(table, pdf_path):
303
+ """Refine a single table section using the vision model, returning the updated HTML."""
304
+ idx = table["section_idx"]
305
+ page = table.get("page", 1)
306
+ content = table.get("content", "")
307
+ bbox = table.get("bbox")
308
+
309
+ html = None
310
+ used_vision = False
311
+ if pdf_path and Path(pdf_path).exists():
312
+ try:
313
+ image_b64 = _render(pdf_path, page, bbox)
314
+ html = _call_vision_model(image_b64)
315
+ used_vision = True
316
+ except Exception as e:
317
+ logger.warning(f" Section {idx}: vision failed ({e}), falling back to text LLM")
318
+
319
+ if html is None:
320
+ try:
321
+ html = _call_text_model(content)
322
+ except Exception as e:
323
+ logger.error(f" Section {idx}: failed — {e}")
324
+ return idx, None, False
325
+
326
+ return idx, _expand_spans(html), used_vision
327
+
328
+
329
+ _WORD_RE = re.compile(r"[A-Za-z]{4,}")
330
+
331
+
332
+ def _word_cell_ratio(content):
333
+ cells = [c.strip() for line in content.splitlines() for c in line.split("|") # skip empty lines and separator rows
334
+ if c.strip() and set(c.strip()) != {"-"}]
335
+ if len(cells) < 12:
336
+ return 1.0
337
+ return sum(1 for c in cells if _WORD_RE.search(c)) / len(cells) # fraction of cells containing at least one word of 4+ letters
338
+
339
+
340
+ def refine_symbol_only_tables(tables_path, pdf_path=None):
341
+ threshold = config.VISION_COMPLEXITY_THRESHOLD
342
+ tables = json.loads(Path(tables_path).read_text(encoding="utf-8"))
343
+ idxs = {t["section_idx"] for t in tables
344
+ if t.get("complexity_score", 0) >= threshold
345
+ and "|" in t.get("content", "") and _word_cell_ratio(t["content"]) < 0.10}
346
+ if idxs:
347
+ logger.info(f"Pre-refining {len(idxs)} symbol-only table(s) before embedding: {sorted(idxs)}")
348
+ refine_matched_tables(tables_path, idxs, pdf_path=pdf_path)
349
+
350
+
351
+ def refine_matched_tables(tables_path, matched_section_indices, pdf_path=None):
352
+ """Refine matched tables using the vision model, updating the JSON file in place.
353
+ Arguments:
354
+ tables_path: Path to the JSON file containing table sections.
355
+ matched_section_indices: A set of section indices to refine.
356
+ pdf_path: Optional path to the PDF file for rendering images.
357
+ Returns:
358
+ The updated list of table sections with refined HTML content.
359
+ """
360
+ tables_path = Path(tables_path)
361
+ if not tables_path.exists():
362
+ return []
363
+
364
+ tables = json.loads(tables_path.read_text(encoding="utf-8"))
365
+
366
+ if not pdf_path:
367
+ candidate = Path(config.PDF_DIR) / f"{tables_path.stem.replace('_tables', '')}.pdf"
368
+ pdf_path = str(candidate) if candidate.exists() else None
369
+
370
+ threshold = config.VISION_COMPLEXITY_THRESHOLD
371
+ matched = [t for t in tables if t.get("section_idx") in matched_section_indices]
372
+ to_refine = [t for t in matched
373
+ if t.get("complexity_score", 0) >= threshold
374
+ and not t.get("refined")]
375
+
376
+ skipped = len(matched) - len(to_refine)
377
+ if skipped:
378
+ logger.info(f" Refining {len(to_refine)} matched tables above complexity "
379
+ f"{threshold}; {skipped} simpler ones left as markdown")
380
+
381
+ if not to_refine:
382
+ return tables
383
+
384
+ workers = max(1, min(config.VISION_PARALLELISM, len(to_refine)))
385
+ logger.info(f" Refining {len(to_refine)} tables ({workers} workers in parallel)...")
386
+
387
+ by_idx = {table["section_idx"]: table for table in tables}
388
+ vision_count = 0
389
+
390
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool: # use threads for tasks (PDF rendering, LLM calls)
391
+ futures = [pool.submit(_refine_one_table, table, pdf_path) for table in to_refine]
392
+ for fut in concurrent.futures.as_completed(futures):
393
+ idx, html, used_vision = fut.result()
394
+ if html is None:
395
+ continue
396
+ by_idx[idx]["original_content"] = by_idx[idx].get("content", "")
397
+ by_idx[idx]["content"] = html
398
+ by_idx[idx]["refined"] = True
399
+ by_idx[idx]["refined_format"] = "html"
400
+ if used_vision:
401
+ vision_count += 1
402
+ logger.info(f" Section {idx}: refined ({len(html)} chars, vision={used_vision})")
403
+
404
+ tables_path.write_text(json.dumps(tables, indent=2, ensure_ascii=False), encoding="utf-8")
405
+ logger.info(f"Refined {vision_count} tables via vision model (of {len(to_refine)} attempted)")
406
+ return tables
@@ -0,0 +1,51 @@
1
+ You are a document extraction specialist. Extract ALL value variants for each requested key from the document sections below.
2
+
3
+ ## Document Type
4
+ {{ document_type }}
5
+
6
+ ## Source File
7
+ {{ source_file }}
8
+
9
+ ## Domain Vocabulary
10
+ {{ vocab_terms }}
11
+
12
+ ## Extraction Schema
13
+ {{ schema }}
14
+
15
+ ## Requested Keys
16
+ {{ user_keys }}
17
+
18
+ ## Relevant Document Sections
19
+
20
+ {{ section_content }}
21
+
22
+ ---
23
+
24
+ ## Instructions
25
+
26
+ For each field in the schema above, extract ALL value variants found in the document sections. Produce a JSON object where each schema field name maps to a list of records.
27
+
28
+ The structure of each record is defined by the **fields** property in the schema for that field. Follow that structure exactly — do NOT add extra sub-fields or omit required ones.
29
+
30
+ When a schema sub-field has a **unit** property, a companion `{field_name}_unit` sub-field will be present in the output structure. Fill it with the unit value from the schema (or from the document if it differs). For example, if the schema defines `smw: {type: "number", unit: "cm³ cm⁻³"}`, the output record will have both `smw` (the value) and `smw_unit` (the unit string).
31
+
32
+ Every record must include a `metadata` object with:
33
+ - **source**: "{{ source_file }}"
34
+ - **section_type**: type of section (e.g., "table", "list", "text")
35
+ - **specified**: true if explicitly stated, false if inferred
36
+
37
+ ## Rules
38
+ 1. Extract EVERY occurrence — a key may appear in multiple tables under different categories, conditions, or columns. Each variant gets its own record.
39
+ 2. Match synonyms using the domain vocabulary and schema descriptions.
40
+ 3. **One record per category.** If a value applies to multiple distinct categories (e.g., different grades, models, segments, periods), create a SEPARATE record for each. NEVER combine distinct categories into a single field (e.g., do not write "GradeA, GradeB" — create two records).
41
+ 4. Use exact values from the document. Do not infer or fabricate.
42
+ 5. If a key is not found, produce an empty list for that field.
43
+ 6. When a value comes from a labeled table column (e.g., [Min], [Typ], [Max]), use that label in the appropriate sub-field for that field's structure.
44
+ 7. **Row-label match required, plus sub-rows that refine it**: Only extract values from rows whose parameter name or column header directly matches the schema field (or a true synonym). Additionally, rows that *immediately follow* a matched parameter row and describe a refinement of it (e.g., a row labeled "Over Temperature" or "Average TC" sitting right under "Input Bias Current") DO belong to that matched parameter — extract them as separate records. For such a sub-row, set `condition` to the sub-row label combined with the Test Conditions/Comments cell when both are present (e.g., `condition = "Over Temperature, T_A = -40°C to +85°C"`); keep `qualifier` as the column label (Min/Typ/Max); keep `category` as the grade/group from the column header. Stop as soon as the next row names a different parameter ("Input X" vs "Output X" are separate fields — never cross that boundary).
45
+ 8. **Use the surrounding section heading for category when the table lost it**: If a table is prefixed with a "Surrounding section heading: …" line and the column headers no longer carry a grade/category label, use that heading as the `category` for rows extracted from that table. Do this only when the column header is missing the label — never override an explicit column label.
46
+ 9. **Variant/differentiator columns**: When the schema defines a field that identifies the column-group variant (e.g. named "differentiator", "variant", "series", or "grade"), set it to the header of the column group the value sits under — a part number (e.g. "LT1167AI/LT1167AI-1"), product grade, fiscal period, or segment. Emit a SEPARATE record for every variant group, even when the parameter, condition, and qualifier are identical across groups (only the value and the differentiator change).
47
+ 10. **Value fields must contain only the bare measured value.** Strip all surrounding words, labels, and narration — no units, no method/location qualifiers (e.g. `"7"` not `"7mL/well"`, `"-20"` not `"-20C shared freezer"`). Units belong exclusively in the companion `*_unit` sub-field; contextual narration belongs in `condition` or nowhere.
48
+ 11. **Fill every defined sub-field that the source supports.** If the document provides information for a sub-field the schema defines, populate it — do not leave it blank. An empty string is only acceptable when the information is genuinely absent from the source text.
49
+ 12. **No placeholder records.** Only emit a record for an occurrence the document actually gives a value for. If an entity or parameter is merely named again without its own stated quantity (e.g. mentioned in a processing/dilution step that gives no number), do NOT create a record for it, and do NOT backfill the value sub-field with `0` or an empty string as a stand-in. Every record must carry at least one real value taken from the document — a genuine `0` read from the source is fine, but never fabricate `0`/empty just to satisfy the structure.
50
+
51
+ Respond with a JSON object matching the schema structure.
@@ -0,0 +1,91 @@
1
+ You are a document analysis expert. Your task is to discover the extraction schema for this document.
2
+
3
+ ## Context
4
+
5
+ The user wants to extract the following keys from this document:
6
+ {{ user_keys }}
7
+
8
+ ## Domain Vocabulary
9
+
10
+ These are the most relevant terms found in this document (use these to understand the domain and recognize synonyms/abbreviations):
11
+ {{ vocab_terms }}
12
+
13
+ ## Relevant Document Sections
14
+
15
+ The following sections were identified as potentially containing the desired values:
16
+
17
+ {{ section_content }}
18
+
19
+ ## Document Preview
20
+
21
+ {{ document_preview }}
22
+
23
+ ---
24
+
25
+ ## Instructions
26
+
27
+ For each user key, define a schema field. The field name MUST be the user's key converted to a valid Python identifier (lowercase, spaces replaced with underscores, special characters removed). For example:
28
+ - "Revenue" → "revenue"
29
+ - "Net Income" → "net_income"
30
+ - "Supply Current" → "supply_current"
31
+
32
+ Each field definition must include:
33
+ 1. **description**: Describe what the field represents, where to find it, and note any synonyms or abbreviations from the domain vocabulary. The description should help the extraction model locate the right values.
34
+ 2. **type**: Always "list" — each key may appear in multiple places (different tables, categories, conditions, columns).
35
+ 3. **fields**: An object defining the sub-fields for each record in this field's list. Tailor the sub-fields to what this specific field actually needs based on the document content. Every record MUST include a `metadata` field. Include a `value` field ONLY when the record has a single primary quantity (or identifying label) that the other sub-fields don't already capture — `value` then holds that one scalar/short token. Do NOT add `value` when the record's structured sub-fields already carry all of its information: a `value` that merely restates the other sub-fields as a prose sentence (e.g. "300 xg for 3 minutes" alongside separate speed/duration sub-fields) is wrong and must be omitted. Beyond that, only add sub-fields that are meaningful for this field. Common patterns:
36
+ - Column-labeled specs: "qualifier" (column label like Min/Typ/Max), "condition" (operating condition), "value", "metadata"
37
+ - Parallel variant columns: the SAME parameter repeated across several side-by-side column groups — each group a distinct product variant, part-number family, grade, or reporting period sitting above its own value or Min/Typ/Max sub-columns. Add a "differentiator" sub-field that records which group a value came from (the group's header text, e.g. a part number like "LT1167AI/LT1167AI-1", a grade, or "FY2024"), alongside "qualifier"/"condition". Name this sub-field after what the groups represent when they carry an explicit label (e.g. headers reading "AR Grade"/"BR Grade" → "grade"); otherwise call it "differentiator".
38
+ - Categorical comparisons: "category" (the row label or group), "value", "metadata"
39
+ - Simple single-value lookups: just "value", "metadata"
40
+ - Multi-column rows: each numeric column becomes its own sub-field (e.g., `smw`, `smfcf`)
41
+ Each sub-field needs a **name**, **type** ("string", "number", or "any"), **description**, and optionally **unit** (the unit of measurement for this sub-field). Always include the unit for any numeric sub-field that carries a physical quantity.
42
+
43
+ **Important**: Only list synonyms that refer to the EXACT same measured quantity. Terms like "Discrepancy" vs "Difference from observed" may sound similar but can refer to different calculations in different tables. Only include a synonym if the value would be interchangeable with the user's key in the same context.
44
+
45
+ In addition to the per-key schema fields, include a top-level `_document_type` key with a short free-form label describing what kind of document this is (e.g. "op-amp datasheet", "quarterly earnings report", "experimental physics paper", "soil survey"). Derive it from the vocabulary and document preview — do not pick from a fixed list.
46
+
47
+ Respond with ONLY a JSON object. No explanation, no markdown fences. Example:
48
+
49
+ {
50
+ "_document_type": "instrumentation amplifier datasheet",
51
+ "temperature": {
52
+ "type": "list",
53
+ "description": "Temperature readings from the measurement table. May appear as 'Temp', 'Temperature', 'T'.",
54
+ "fields": {
55
+ "qualifier": {"type": "string", "description": "Column label: 'minimum', 'typical', or 'maximum'"},
56
+ "condition": {"type": "string", "description": "Condition or location (e.g., 'outdoor', 'indoor')"},
57
+ "value": {"type": "any", "description": "Temperature value", "unit": "°C"},
58
+ "metadata": {"type": "object", "description": "Source and provenance"}
59
+ }
60
+ },
61
+ "common_mode_rejection": {
62
+ "type": "list",
63
+ "description": "CMRR spec repeated across several part-number column groups in the characteristics table.",
64
+ "fields": {
65
+ "differentiator": {"type": "string", "description": "Which variant column group the value sits under — the group's header, e.g. a part number 'LT1167AI/LT1167AI-1', a grade, or a reporting period"},
66
+ "qualifier": {"type": "string", "description": "Column label: 'min', 'typ', or 'max'"},
67
+ "condition": {"type": "string", "description": "Operating condition (e.g., 'G = 10')"},
68
+ "value": {"type": "any", "description": "CMRR value", "unit": "dB"},
69
+ "metadata": {"type": "object", "description": "Source and provenance"}
70
+ }
71
+ },
72
+ "survival_rate": {
73
+ "type": "list",
74
+ "description": "Survival rate comparison across categories from the results table.",
75
+ "fields": {
76
+ "category": {"type": "string", "description": "Group or model name (e.g., 'Control', 'Treatment A')"},
77
+ "value": {"type": "any", "description": "Survival rate", "unit": "%"},
78
+ "metadata": {"type": "object", "description": "Source and provenance"}
79
+ }
80
+ },
81
+ "soil_parameters": {
82
+ "type": "list",
83
+ "description": "Soil classification parameters from the properties table. Each row is a soil type.",
84
+ "fields": {
85
+ "value": {"type": "string", "description": "Soil type name (e.g., 'Medium loam')"},
86
+ "smw": {"type": "number", "description": "Wilting point", "unit": "cm³ cm⁻³"},
87
+ "smfcf": {"type": "number", "description": "Field capacity", "unit": "cm³ cm⁻³"},
88
+ "metadata": {"type": "object", "description": "Source and provenance"}
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,47 @@
1
+ """Domain vocabulary extraction via YAKE.
2
+
3
+ Runs unsupervised keyword extraction on the document's Markdown text to surface
4
+ domain-specific terms (e.g. "supply voltage", "output impedance"). The resulting
5
+ vocab list is used in embed.py to expand user keys with related terms before
6
+ embedding, improving recall when the user's key wording doesn't exactly match
7
+ the document's phrasing.
8
+
9
+ YAKE was chosen because it is unsupervised (no training corpus needed), fast,
10
+ and works well on technical English. The top-N and max n-gram size are
11
+ configurable via VOCAB_TOP_N and VOCAB_MAX_NGRAM in .env.
12
+ """
13
+ import argparse
14
+ import logging
15
+ from pathlib import Path
16
+ import yake
17
+ from . import config
18
+ from .paths import inter, cached
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def extract_vocab(md_path):
24
+ md_path = Path(md_path)
25
+
26
+ def build():
27
+ logger.info(f"Extracting vocabulary from {md_path.name}...")
28
+ kw = yake.KeywordExtractor(lan="en", n=config.VOCAB_MAX_NGRAM,
29
+ dedupLim=0.7, top=config.VOCAB_TOP_N)
30
+ # lower score = more important in YAKE
31
+ return [{"term": term, "score": round(score, 4)}
32
+ for term, score in kw.extract_keywords(md_path.read_text(encoding="utf-8"))]
33
+
34
+ return cached(inter(md_path.stem, "vocab.json"), build)
35
+
36
+
37
+ def main():
38
+ parser = argparse.ArgumentParser(description="Extract domain vocabulary")
39
+ parser.add_argument("--input", required=True)
40
+ args = parser.parse_args()
41
+
42
+ result = extract_vocab(args.input)
43
+ print(f"Extracted {len(result)} keywords")
44
+
45
+
46
+ if __name__ == "__main__":
47
+ main()