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,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
|
|
Binary file
|
|
@@ -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())
|