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,133 @@
1
+ """Filter extracted records by consensus confidence tier.
2
+
3
+ After multi-model extraction and rating, each record carries a confidence score
4
+ (fraction of models that agreed on it). This module lets the user keep only the
5
+ tiers they trust — e.g. only records all models agreed on (1.0), or everything
6
+ above majority (>= 0.67).
7
+
8
+ Usable both as a CLI tool (structured-filter-records) and programmatically from
9
+ the pipeline's end-of-run step. When no --levels are given, an interactive menu
10
+ lets the user pick tiers; with a single extraction model every record is
11
+ confidence 1.0, so the filter is a no-op.
12
+ """
13
+ import argparse
14
+ import json
15
+ from collections import Counter
16
+ from pathlib import Path
17
+ from . import config
18
+
19
+ RECORDS = Path(config.OUTPUT_DIR) / "records.json"
20
+ _META = {"type", "generated_at", "provenance"} # envelope keys that aren't extracted fields
21
+
22
+
23
+ def load(path=RECORDS):
24
+ return json.loads(Path(path).read_text(encoding="utf-8"))
25
+
26
+
27
+ def _records(data):
28
+ """Yield every extracted record across all documents (skipping envelope meta)."""
29
+ for env in data.values():
30
+ for key, recs in env.items():
31
+ if key not in _META and isinstance(recs, list):
32
+ yield from recs
33
+
34
+
35
+ def counts(data):
36
+ """{confidence tier: record count}, highest tier first."""
37
+ tally = Counter(round(r["confidence"], 2) for r in _records(data)
38
+ if r.get("confidence") is not None)
39
+ return dict(sorted(tally.items(), reverse=True))
40
+
41
+
42
+ def tier_summary(data):
43
+ """One line per tier: the cumulative '>= tier' count (and its share of the total), plus the
44
+ count exactly at the tier. Returned as lines so a caller can print (CLI) or log (pipeline)."""
45
+ tiers = counts(data)
46
+ total = sum(tiers.values()) or 1
47
+ lines = []
48
+ for tier, at in tiers.items():
49
+ kept = sum(n for t, n in tiers.items() if t >= tier)
50
+ lines.append(f" >= {tier:<4} {kept:>4} kept ({kept / total:.0%}) {at:>4} at this tier")
51
+ return lines
52
+
53
+
54
+ def keep(data, tiers):
55
+ """Return a subset with the same envelope shape, only records whose confidence is in `tiers`.
56
+ A field left with no records is dropped; a document left with no fields is omitted."""
57
+ chosen = {round(t, 2) for t in tiers}
58
+ out = {}
59
+ for stem, env in data.items():
60
+ kept = {}
61
+ for key, val in env.items():
62
+ if key in _META:
63
+ kept[key] = val
64
+ elif isinstance(val, list):
65
+ recs = [r for r in val
66
+ if r.get("confidence") is not None and round(r["confidence"], 2) in chosen]
67
+ if recs:
68
+ kept[key] = recs
69
+ if any(k not in _META for k in kept):
70
+ out[stem] = kept
71
+ return out
72
+
73
+
74
+ def _pick(tiers):
75
+ """Interactive menu: choose which confidence tiers to keep."""
76
+ for i, tier in enumerate(tiers, 1):
77
+ print(f" {i}. {tier}")
78
+ print(" a. all")
79
+ try:
80
+ raw = input("Select tiers (e.g. '1,3' or 'a'): ").strip().lower()
81
+ except EOFError:
82
+ return tiers # no one on stdin (piped/non-interactive) → keep all rather than crash
83
+ if raw in ("", "a", "all"):
84
+ return tiers
85
+ return [tiers[int(x) - 1] for x in raw.replace(" ", "").split(",")
86
+ if x.isdigit() and 1 <= int(x) <= len(tiers)] or tiers
87
+
88
+
89
+ def run(path=RECORDS, levels=None, json_out=None):
90
+ """Filter the records at `path` by confidence and print (or save) the subset.
91
+
92
+ Shared by the CLI and the pipeline's end-of-run step.
93
+ levels: tiers to keep (e.g. [1.0, 0.75]) or ["all"]; None => interactive menu.
94
+ """
95
+ data = load(path)
96
+ tiers = list(counts(data)) # highest first
97
+ if not tiers:
98
+ print(f"No scored records in {path}")
99
+ return
100
+ print("Confidence tiers (of all records):")
101
+ print("\n".join(tier_summary(data)))
102
+
103
+ if len(config.EXTRACTION_MODELS) < 2:
104
+ print("Only one extraction model — every record is confidence 1.0, nothing to filter.")
105
+ chosen = tiers
106
+ elif levels:
107
+ chosen = tiers if "all" in levels else [float(x) for x in levels]
108
+ else:
109
+ chosen = _pick(tiers)
110
+
111
+ out = keep(data, chosen)
112
+ payload = json.dumps(out, indent=2, ensure_ascii=False)
113
+ n = sum(1 for _ in _records(out))
114
+ if json_out:
115
+ Path(json_out).write_text(payload, encoding="utf-8")
116
+ print(f"wrote {n} record(s) across {len(out)} document(s) to {json_out}")
117
+ else:
118
+ print(payload)
119
+ print(f"\n{n} record(s) across {len(out)} document(s)")
120
+
121
+
122
+ def main():
123
+ ap = argparse.ArgumentParser(description="Filter records by consensus confidence")
124
+ ap.add_argument("--levels", nargs="+", metavar="LVL",
125
+ help="tiers to keep, e.g. 1.0 0.75 (or 'all'); omit for an interactive menu")
126
+ ap.add_argument("--file", default=str(RECORDS), help="records.json to read")
127
+ ap.add_argument("--json", help="write the kept records here instead of printing")
128
+ args = ap.parse_args()
129
+ run(args.file, args.levels, args.json)
130
+
131
+
132
+ if __name__ == "__main__":
133
+ main()
@@ -0,0 +1,329 @@
1
+ """Pipeline orchestrator : drives the full extraction workflow end to end.
2
+
3
+ Processes one PDF or a directory of PDFs through every stage in order:
4
+ convert → vocab → classify → tables → embed → search → extract → rate → records
5
+
6
+ Each stage writes its output to intermediate/ or output/ and is skipped on rerun
7
+ if the artefact already exists (unless --force is set). Key-dependent caches
8
+ (embeddings, matches, extractions) are automatically invalidated when the user
9
+ changes their key list between runs.
10
+
11
+ After extraction, finalize_outputs() fuses the per-model results via rater.py,
12
+ writes provenance records, and optionally filters by confidence tier.
13
+ """
14
+ import argparse
15
+ import json
16
+ import logging
17
+ import sys
18
+ import time
19
+ from contextlib import contextmanager
20
+ from importlib import resources
21
+ from pathlib import Path
22
+ from . import config, records, filter_records, matching, rater
23
+ from .paths import inter, read_json
24
+ from .convert import convert_pdf
25
+ from .vocab import extract_vocab
26
+ from .classify import classify_sections
27
+ from .tables import process_tables, refine_matched_tables, refine_symbol_only_tables
28
+ from .embed import embed_sections_and_keys
29
+ from .search import search
30
+ from .extract import extract_parallel
31
+
32
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
33
+ logger = logging.getLogger("main")
34
+
35
+
36
+ def _invalidate_stale_key_caches(stem, user_keys):
37
+ keys_path = inter(stem, "original_keys.json")
38
+ if not keys_path.exists():
39
+ return
40
+ if sorted(user_keys) == sorted(read_json(keys_path)):
41
+ return
42
+ logger.info(f"Keys changed; invalidating key-dependent caches for {stem}")
43
+ for name in ("key_embeddings.npy", "key_texts.json", "original_keys.json",
44
+ "matches.json", "schema.json", "run.json"):
45
+ p = inter(stem, name)
46
+ if p.exists():
47
+ p.unlink()
48
+ for p in Path(config.OUTPUT_DIR).glob(f"{stem}_extraction_*.json"):
49
+ p.unlink()
50
+
51
+
52
+ def process_single_pdf(pdf_path, user_keys):
53
+ """Run the full extraction pipeline on one PDF.
54
+
55
+ Executes every stage in order: convert → vocab → classify → tables →
56
+ embed → search → extract, writing intermediate artefacts to
57
+ INTERMEDIATE_DIR and per-model extractions to OUTPUT_DIR. Each stage is
58
+ skipped when its output already exists on disk (unless --force is set).
59
+ If the user's key list has changed since the last run, key-dependent
60
+ caches are automatically invalidated.
61
+
62
+ Args:
63
+ pdf_path: Path to the input PDF (str or Path).
64
+ user_keys: List of extraction keys, e.g. ["gain bandwidth", "PSRR"].
65
+ Cleaned and validated internally (whitespace, quotes, and
66
+ duplicates are handled; raises ValueError on unusable input).
67
+
68
+ Returns:
69
+ A list of dicts on success — one dict per extraction model. Each dict
70
+ maps a normalised field name to a list of extracted records::
71
+
72
+ [
73
+ { # model 1
74
+ "gain_bandwidth": [
75
+ {"qualifier": "...", "condition": "...",
76
+ "value": 4.0, "unit": "MHz",
77
+ "metadata": {"source": "...", "section_type": "table",
78
+ "specified": True, "document_type": "...",
79
+ "model": "gemma4:31b-cloud"}},
80
+ ...
81
+ ],
82
+ ...
83
+ },
84
+ { ... }, # model 2
85
+ ]
86
+
87
+ Returns None if the PDF cannot be found or conversion fails.
88
+
89
+ These per-model dicts are the raw, un-fused extractions. To get the
90
+ final confidence-scored output, call finalize_outputs().
91
+ """
92
+ pdf_path = Path(pdf_path)
93
+ if not pdf_path.exists():
94
+ logger.error(f"PDF not found: {pdf_path}")
95
+ return None
96
+
97
+ user_keys = matching.validate_keys(user_keys)
98
+ stem = pdf_path.stem
99
+ int_dir = Path(config.INTERMEDIATE_DIR)
100
+ sections_path = int_dir / f"{stem}_sections.json"
101
+ tables_file = int_dir / f"{stem}_tables.json"
102
+ logger.info(f"=== Processing {pdf_path.name} ===")
103
+
104
+ timings = {}
105
+
106
+ @contextmanager
107
+ def timed(label):
108
+ t0 = time.perf_counter()
109
+ try:
110
+ yield
111
+ finally:
112
+ timings[label] = timings.get(label, 0.0) + time.perf_counter() - t0
113
+
114
+ with timed("convert"):
115
+ md_path = convert_pdf(str(pdf_path))
116
+ if md_path is None:
117
+ logger.error(f"Conversion failed for {pdf_path.name}")
118
+ return None
119
+
120
+ with timed("vocab"):
121
+ extract_vocab(md_path)
122
+ with timed("classify"):
123
+ classify_sections(stem)
124
+ with timed("tables"):
125
+ process_tables(str(sections_path))
126
+
127
+ # tables Docling stripped to bare symbols can't be retrieved, so they'd never reach the
128
+ # lazy-vision step below; de-garble them now so their recovered text gets embedded
129
+ if tables_file.exists():
130
+ with timed("pre-refine"):
131
+ refine_symbol_only_tables(str(tables_file), pdf_path=str(pdf_path))
132
+
133
+ _invalidate_stale_key_caches(stem, user_keys)
134
+ with timed("embed"):
135
+ embed_sections_and_keys(stem, user_keys=user_keys)
136
+
137
+ with timed("search"):
138
+ matches = search(stem, original_keys=user_keys)
139
+
140
+ # refine matched complex tables with vision model
141
+ matched_idxs = {
142
+ match.get("section_idx", 0)
143
+ for key_matches in matches.values()
144
+ for match in key_matches
145
+ }
146
+
147
+ if matched_idxs and tables_file.exists():
148
+ logger.info("Refining matched complex tables...")
149
+ with timed("refine"):
150
+ refine_matched_tables(str(tables_file), matched_idxs, pdf_path=str(pdf_path))
151
+
152
+ with timed("extract"):
153
+ results = extract_parallel(stem, user_keys)
154
+
155
+ _log_run_summary(stem, timings, results, matches)
156
+ logger.info(f"=== Done with {pdf_path.name} ===")
157
+ return results
158
+
159
+
160
+ def _log_run_summary(stem, timings, results, matches):
161
+ """End-of-run legibility: one line per stage with its wall-clock time, then a record count
162
+ broken down by the section type each record came from (table/text/list)."""
163
+ by_source, total = {}, 0
164
+ for res in results:
165
+ for records in res.values():
166
+ for record in records:
167
+ total += 1
168
+ src = (record.get("metadata") or {}).get("section_type") or "unknown"
169
+ by_source[src] = by_source.get(src, 0) + 1
170
+ sources = ", ".join(f"{k}={v}" for k, v in sorted(by_source.items())) or "none"
171
+ matched = sum(1 for hits in matches.values() if hits)
172
+ stages = "".join(f"\n {label:<11}{secs:6.1f}s" for label, secs in timings.items())
173
+ logger.info(
174
+ f"=== Run summary: {stem} ==={stages}"
175
+ f"\n {'TOTAL':<11}{sum(timings.values()):6.1f}s"
176
+ f"\n records: {total} ({sources}) from {len(results)} model(s); "
177
+ f"keys matched: {matched}/{len(matches)}"
178
+ )
179
+
180
+
181
+ def finalize_outputs(levels=None):
182
+ """Fuse per-model extractions into the final deliverables.
183
+
184
+ Reads every output/<stem>_extraction_<model>.json written by extract_parallel,
185
+ clusters matching records across models into facts, and writes three files to
186
+ OUTPUT_DIR:
187
+
188
+ fused.json
189
+ The primary output. Structure: {stem: {field: [fact, ...]}}.
190
+ Each fact is a dict with the extracted fields (value, unit, qualifier,
191
+ condition, ...) plus:
192
+ - "confidence": float in (0, 1] — fraction of configured models that
193
+ independently produced this fact (e.g. 2 of 3 → 0.67). With a single
194
+ model every fact is 1.0.
195
+ - "models": list[str] — which models agreed on this fact.
196
+ - "metadata": {"section_type": str, "specified": bool}.
197
+ Facts are sorted highest-confidence first within each field.
198
+
199
+ rating_report.json
200
+ Inter-model agreement statistics: total fact count, full-agreement count,
201
+ pairwise Cohen's kappa and raw agreement for each model pair, and the list
202
+ of facts where models disagreed. Useful for auditing model consistency.
203
+
204
+ records.json
205
+ Provenance-stamped envelopes wrapping fused.json: one entry per document,
206
+ adding source_file, document_type, the models used, and the user's
207
+ original keys. This is the archival output.
208
+
209
+ After writing, if multiple extraction models are configured, the user is
210
+ offered (or given, via `levels`) a confidence filter to keep only trusted
211
+ tiers.
212
+
213
+ Args:
214
+ levels: Confidence tiers to keep (e.g. [1.0, 0.75]) or ["all"].
215
+ None triggers an interactive prompt (multi-model only).
216
+ """
217
+ by_file = rater.load_outputs()
218
+ if not by_file:
219
+ logger.warning("No extraction outputs found — skipping rating + records step.")
220
+ return
221
+ rater.rate(by_file)
222
+ out_path, n = records.write_records(config.OUTPUT_DIR)
223
+ logger.info(f"Wrote records for {n} document(s) → {out_path}")
224
+ _offer_filter(out_path, levels)
225
+
226
+
227
+ def _offer_filter(records_path, levels):
228
+ """user filter records by confidence at the end of a run. If confidence came in as an argument
229
+ (--levels) uses them, otherwise offers the interactive menu. One model means every record
230
+ is confidence 1.0, so there's nothing to filter and we bail."""
231
+ if len(config.EXTRACTION_MODELS) < 2:
232
+ return
233
+ filter_records.run(records_path, levels=levels)
234
+
235
+
236
+ def process_batch(pdf_dir, user_keys):
237
+ pdf_dir = Path(pdf_dir)
238
+ if not pdf_dir.exists():
239
+ logger.error(f"Directory not found: {pdf_dir}")
240
+ return []
241
+
242
+ pdfs = sorted(pdf_dir.glob("*.pdf"))
243
+ if not pdfs:
244
+ logger.error(f"No PDFs found in {pdf_dir}")
245
+ return []
246
+
247
+ logger.info(f"Found {len(pdfs)} PDFs")
248
+ results = []
249
+ for pdf in pdfs:
250
+ try:
251
+ result = process_single_pdf(str(pdf), user_keys)
252
+ if result:
253
+ results.append(result)
254
+ except Exception as e:
255
+ logger.error(f"Failed {pdf.name}: {e}")
256
+
257
+ logger.info(f"Processed {len(results)}/{len(pdfs)} files")
258
+ return results
259
+
260
+
261
+ def _dump_templates(dest):
262
+ """Copy the packaged prompt templates to `dest` for editing, then the user points
263
+ TEMPLATES_DIR at that folder to use them. Only the built-in .j2 defaults are written;
264
+ read_text/write_text keeps this working even if the package is imported from a zip."""
265
+ src = resources.files("structured_data_extraction") / "templates"
266
+ dest = Path(dest)
267
+ dest.mkdir(parents=True, exist_ok=True)
268
+ for t in src.iterdir():
269
+ if t.name.endswith(".j2"):
270
+ (dest / t.name).write_text(t.read_text(encoding="utf-8"), encoding="utf-8")
271
+ return dest
272
+
273
+
274
+ def main():
275
+ parser = argparse.ArgumentParser(description="Document extraction pipeline")
276
+ parser.add_argument("path", nargs="?", help="Path to a PDF file or a directory of PDFs")
277
+ parser.add_argument("--keys", nargs="+", help="Keys to extract")
278
+ parser.add_argument("--output", default=config.OUTPUT_DIR)
279
+ parser.add_argument("--force", action="store_true", help="Recompute every step, ignoring cached files")
280
+ parser.add_argument("--levels", nargs="+", metavar="LVL",
281
+ help="confidence tiers to keep (e.g. 1.0 0.75, or 'all'); "
282
+ "omit to be prompted after extraction")
283
+ parser.add_argument("--dump-templates", metavar="DIR",
284
+ help="Write the built-in prompt templates to DIR for editing, then exit; "
285
+ "point TEMPLATES_DIR at that folder to use your edits")
286
+ args = parser.parse_args()
287
+
288
+ if args.dump_templates:
289
+ dest = _dump_templates(args.dump_templates)
290
+ print(f"Wrote templates to {dest}\nSet TEMPLATES_DIR={dest} to use your edits.")
291
+ return
292
+ if not args.path:
293
+ parser.error("a PDF file or directory is required (unless using --dump-templates)")
294
+
295
+ config.FORCE = args.force
296
+ user_keys = args.keys
297
+ if not user_keys:
298
+ print("Enter keys to extract (comma-separated):")
299
+ raw = input("> ").strip()
300
+ user_keys = [k.strip() for k in raw.split(",") if k.strip()]
301
+
302
+ try:
303
+ user_keys = matching.validate_keys(user_keys or [])
304
+ except ValueError as e:
305
+ print(f"Invalid keys: {e}")
306
+ sys.exit(1)
307
+
308
+ config.OUTPUT_DIR = args.output
309
+
310
+ if Path(args.path).is_dir():
311
+ results = process_batch(args.path, user_keys)
312
+ if results:
313
+ finalize_outputs(args.levels)
314
+ print(f"Extracted from {len(results)} files → {config.OUTPUT_DIR}")
315
+ if not results:
316
+ print("No documents produced any extraction.")
317
+ sys.exit(1)
318
+ else:
319
+ result = process_single_pdf(args.path, user_keys)
320
+ if result:
321
+ finalize_outputs(args.levels)
322
+ print(json.dumps(result, indent=2, ensure_ascii=False)) #document them well
323
+ else:
324
+ print("Extraction failed.")
325
+ sys.exit(1)
326
+
327
+
328
+ if __name__ == "__main__":
329
+ main()
@@ -0,0 +1,153 @@
1
+ """Same-fact equivalence and field comparison.
2
+
3
+ Decides whether two extracted records describe the same real-world fact so that
4
+ rater.py can cluster them for consensus scoring. The rule: two records match
5
+ when every non-metadata field either agrees or is empty on at least one side
6
+ (empty = wildcard, so a sparse record still matches a fuller one).
7
+
8
+ Numeric fields must match exactly (after float parsing). Unit fields match after
9
+ Unicode/dash normalisation and synonym expansion (e.g. "millimeters" == "mm").
10
+ Descriptor text (conditions, qualifiers) uses a rapidfuzz token-set similarity
11
+ threshold of 85 — fuzzy because models phrase conditions differently
12
+ ("TA = −40°C" vs "T_A = -40°C"), but exact on the number so distinct specs are
13
+ never merged.
14
+
15
+ Also provides normalize_key (user key → valid Pydantic field name) and
16
+ validate_keys (trust-boundary cleaning of user input).
17
+ """
18
+ import logging
19
+ import re
20
+ import unicodedata
21
+ from rapidfuzz import fuzz
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ FUZZ_THRESHOLD = 85
26
+
27
+ MATCH, MISMATCH, GAP = "match", "mismatch", "gap"
28
+ _DASHES = str.maketrans("–—−‐‑‒", "------")
29
+
30
+
31
+ def _norm(value):
32
+ return unicodedata.normalize("NFKC", str(value)).translate(_DASHES).strip().casefold()
33
+
34
+
35
+ def _empty(value):
36
+ return value is None or _norm(value) == ""
37
+
38
+
39
+ def _as_number(value):
40
+ if isinstance(value, bool):
41
+ return None
42
+ if isinstance(value, (int, float)):
43
+ return float(value)
44
+ try:
45
+ return float(str(value).strip())
46
+ except ValueError:
47
+ return None
48
+
49
+
50
+ _LEADING_NUM_RE = re.compile(r"^-?\d+(?:[.\-]\d+)*\s*")
51
+ _UNIT_SYNONYMS = {"millimeters": "mm", "millimeter": "mm", "inches": "in", "inch": "in"}
52
+
53
+
54
+ def _norm_unit(value):
55
+ # drop a leading value number so a baked-in "5 µM" matches a standalone unit "µM"
56
+ u = _LEADING_NUM_RE.sub("", _norm(value)).replace(" ", "").replace("μ", "u")
57
+ return _UNIT_SYNONYMS.get(u, u)
58
+
59
+
60
+ _NUM_RE = re.compile(r"-?\d+(?:\.\d+)?")
61
+ _NONALNUM_RE = re.compile(r"[^0-9a-z]+")
62
+
63
+
64
+ def _numbers(value):
65
+ return frozenset(round(float(x), 6) for x in _NUM_RE.findall(str(value).translate(_DASHES)))
66
+
67
+ _SYNONYMS = {"typical": "typ", "minimum": "min", "maximum": "max"}
68
+
69
+
70
+ def _norm_text(value):
71
+ # strip underscores first so subscripts collapse ("R_L" == "RL"), then tokenise
72
+ s = unicodedata.normalize("NFKC", str(value)).translate(_DASHES).casefold().replace("_", "")
73
+ return " ".join(_SYNONYMS.get(t, t) for t in _NONALNUM_RE.sub(" ", s).split())
74
+
75
+
76
+ def normalize_key(key):
77
+ # strip("_") so edge punctuation (a quote, a leading "+") can't produce a leading/trailing
78
+ # underscore — Pydantic rejects field names that start with "_".
79
+ return re.sub(r"[^a-z0-9_]", "_", unicodedata.normalize("NFKC", key).lower()).strip("_")
80
+
81
+
82
+ def validate_keys(keys):
83
+ """Clean + validate user keys at the trust boundary, returning the cleaned key list.
84
+
85
+ Repairs the common cases (strips surrounding quotes/whitespace, drops blanks and exact
86
+ duplicates) and rejects what can't become a valid, unique Pydantic field name:
87
+ a key with no alphanumerics (empty field name) or two distinct keys that normalize to the
88
+ same field name (a silent collision that would otherwise drop one key's data). Raises
89
+ ValueError with a user-facing message on unrepairable input.
90
+ """
91
+ cleaned = []
92
+ for k in keys:
93
+ c = k.strip().strip("\"'").strip()
94
+ if not c:
95
+ if k.strip():
96
+ logger.info(f"Dropped key {k!r} (empty after cleaning)")
97
+ continue
98
+ if c != k:
99
+ logger.info(f"Cleaned key {k!r} -> {c!r}")
100
+ cleaned.append(c)
101
+
102
+ cleaned = list(dict.fromkeys(cleaned)) # drop exact duplicates, keep order
103
+ if not cleaned:
104
+ raise ValueError("no usable keys provided")
105
+
106
+ by_field = {}
107
+ for k in cleaned:
108
+ field = normalize_key(k)
109
+ if not field:
110
+ raise ValueError(f"key {k!r} has no letters or digits to form a field name — rename it")
111
+ if field in by_field:
112
+ raise ValueError(f"keys {by_field[field]!r} and {k!r} both map to field name "
113
+ f"{field!r} — make them distinct")
114
+ by_field[field] = k
115
+ return cleaned
116
+
117
+
118
+ def model_slug(model_name):
119
+ return re.sub(r"[^a-z0-9]+", "_", model_name.lower()).strip("_")
120
+
121
+
122
+ def compare(name, a, b):
123
+ if _empty(a) or _empty(b):
124
+ return GAP
125
+
126
+ na, nb = _as_number(a), _as_number(b)
127
+ if na is not None and nb is not None:
128
+ return MATCH if na == nb else MISMATCH
129
+
130
+ if name == "unit" or name.endswith("_unit"):
131
+ return MATCH if _norm_unit(a) == _norm_unit(b) else MISMATCH
132
+
133
+ # one side a number, the other a string carrying exactly that number ("10 mL" vs 10)
134
+ if na is not None and _numbers(b) == {na}:
135
+ return MATCH
136
+ if nb is not None and _numbers(a) == {nb}:
137
+ return MATCH
138
+
139
+ numa, numb = _numbers(a), _numbers(b)
140
+ if not (numa <= numb or numb <= numa):
141
+ return MISMATCH
142
+ return MATCH if fuzz.token_set_ratio(_norm_text(a), _norm_text(b)) >= FUZZ_THRESHOLD else MISMATCH
143
+
144
+
145
+ _META_FIELDS = {"metadata", "confidence", "models"}
146
+
147
+
148
+ def same_fact(a, b):
149
+ fields = (set(a) | set(b)) - _META_FIELDS
150
+ for f in fields:
151
+ if compare(f, a.get(f), b.get(f)) == MISMATCH:
152
+ return False
153
+ return True
@@ -0,0 +1,34 @@
1
+ """Path helpers and a simple JSON cache.
2
+
3
+ """
4
+ import json
5
+ from pathlib import Path
6
+ from . import config
7
+
8
+
9
+ def inter(stem, name):
10
+ return Path(config.INTERMEDIATE_DIR) / f"{stem}_{name}"
11
+
12
+
13
+ def out(stem, name):
14
+ return Path(config.OUTPUT_DIR) / f"{stem}_{name}"
15
+
16
+
17
+ def read_json(path):
18
+ return json.loads(Path(path).read_text(encoding="utf-8"))
19
+
20
+
21
+ def write_json(path, obj):
22
+ path = Path(path)
23
+ path.parent.mkdir(parents=True, exist_ok=True)
24
+ path.write_text(json.dumps(obj, indent=2, ensure_ascii=False), encoding="utf-8")
25
+ return obj
26
+
27
+
28
+ def cached(path, build):
29
+ """Return the JSON at `path` if it exists, else `build()` then write + return it.
30
+ Recomputes regardless of an existing file when config.FORCE is set."""
31
+ path = Path(path)
32
+ if path.exists() and not config.FORCE:
33
+ return read_json(path)
34
+ return write_json(path, build())