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,188 @@
|
|
|
1
|
+
"""When multiple extraction models run on the same document they produce
|
|
2
|
+
overlapping records. This module clusters those records into "facts" using the
|
|
3
|
+
same-fact equivalence from matching.py, then scores each fact's confidence as
|
|
4
|
+
the fraction of models that found it.
|
|
5
|
+
|
|
6
|
+
Outputs:
|
|
7
|
+
- fused.json: every fact, grouped by document and key, sorted by confidence.
|
|
8
|
+
This is the single complete deliverable that downstream consumers read.
|
|
9
|
+
- rating_report.json: inter-rater agreement stats (Cohen's kappa and raw
|
|
10
|
+
agreement percentage for each model pair).
|
|
11
|
+
"""
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from sklearn.metrics import cohen_kappa_score
|
|
15
|
+
from . import config
|
|
16
|
+
from .paths import read_json, write_json
|
|
17
|
+
from .matching import same_fact, _empty, _META_FIELDS, model_slug
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _filled_fields(rec):
|
|
21
|
+
return sum(1 for k, v in rec.items() if k not in _META_FIELDS and not _empty(v))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fuse(records_by_model):
|
|
25
|
+
pool = [(m, r) for m, recs in records_by_model.items() for r in recs]
|
|
26
|
+
used = set()
|
|
27
|
+
clusters = []
|
|
28
|
+
for i, (model, rec) in enumerate(pool):
|
|
29
|
+
if i in used:
|
|
30
|
+
continue
|
|
31
|
+
used.add(i)
|
|
32
|
+
cluster = {model: rec}
|
|
33
|
+
for j in range(i + 1, len(pool)):
|
|
34
|
+
mj, rj = pool[j]
|
|
35
|
+
if j in used or mj in cluster:
|
|
36
|
+
continue
|
|
37
|
+
if same_fact(rec, rj):
|
|
38
|
+
used.add(j)
|
|
39
|
+
cluster[mj] = rj
|
|
40
|
+
clusters.append(cluster)
|
|
41
|
+
return clusters
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _best_section(records: list[dict]) -> str:
|
|
45
|
+
"""
|
|
46
|
+
Return the highest-priority section_type found across the records.
|
|
47
|
+
Priority is table > text > list; an unknown/missing type loses to all of them.
|
|
48
|
+
|
|
49
|
+
Arguments:
|
|
50
|
+
records: A list of records, each a dict that may hold a "metadata" dict
|
|
51
|
+
with a "section_type" key.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The winning section_type as a string ("table", "text", "list", or "unknown").
|
|
55
|
+
"""
|
|
56
|
+
types = [r.get("metadata", {}).get("section_type", "unknown") for r in records]
|
|
57
|
+
return min(types, key=lambda t: config.SECTION_TYPE_PRIORITY.get(t, 99))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _fused_metadata(records: list[dict]) -> dict:
|
|
61
|
+
"""
|
|
62
|
+
Build the metadata for a fused record.
|
|
63
|
+
|
|
64
|
+
Arguments
|
|
65
|
+
records: The records in one cluster, each a dict that may hold a "metadata" dict.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
A metadata dict with "section_type" and "specified" (defaulting to True).
|
|
69
|
+
"""
|
|
70
|
+
rep = max(records, key=_filled_fields)
|
|
71
|
+
return {"section_type": _best_section(records),
|
|
72
|
+
"specified": rep.get("metadata", {}).get("specified", True)}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def consensus(cluster, n_models):
|
|
76
|
+
"""Collapse a cluster (model -> record) into one fact: field values taken from the
|
|
77
|
+
most-filled record, confidence = agreeing models / n_models, the sorted list of agreeing
|
|
78
|
+
models, and the fused metadata."""
|
|
79
|
+
recs = list(cluster.values())
|
|
80
|
+
rep = max(recs, key=_filled_fields)
|
|
81
|
+
fields = {k: v for k, v in rep.items() if k != "metadata"}
|
|
82
|
+
return {"confidence": round(len(cluster) / n_models, 2), "models": sorted(cluster),
|
|
83
|
+
**fields, "metadata": _fused_metadata(recs)}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_outputs():
|
|
87
|
+
by_file = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
|
|
88
|
+
slug_to_model = {model_slug(m): m for m in config.EXTRACTION_MODELS}
|
|
89
|
+
for path in sorted(Path(config.OUTPUT_DIR).glob("*_extraction_*.json")):
|
|
90
|
+
stem, _, slug = path.stem.rpartition("_extraction_")
|
|
91
|
+
model = slug_to_model.get(slug)
|
|
92
|
+
if model is None:
|
|
93
|
+
continue
|
|
94
|
+
data = read_json(path)
|
|
95
|
+
|
|
96
|
+
for field, records in data.items():
|
|
97
|
+
if isinstance(records, list):
|
|
98
|
+
by_file[stem][field][model].extend(records)
|
|
99
|
+
return by_file
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def fuse_outputs(by_file, models):
|
|
103
|
+
n = len(models)
|
|
104
|
+
facts = []
|
|
105
|
+
for stem, fields in sorted(by_file.items()):
|
|
106
|
+
for field, recs_by_model in sorted(fields.items()):
|
|
107
|
+
clusters = fuse({m: recs_by_model.get(m, []) for m in models})
|
|
108
|
+
for c in clusters:
|
|
109
|
+
facts.append({"source_file": f"{stem}.pdf", "field": field, **consensus(c, n)})
|
|
110
|
+
return facts
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _group_by_doc(facts):
|
|
114
|
+
grouped = defaultdict(lambda: defaultdict(list))
|
|
115
|
+
for f in facts:
|
|
116
|
+
stem = Path(f["source_file"]).stem
|
|
117
|
+
grouped[stem][f["field"]].append({k: v for k, v in f.items()
|
|
118
|
+
if k not in ("source_file", "field")})
|
|
119
|
+
# highest-confidence facts first within each key; stable, so ties keep fuse order
|
|
120
|
+
for fields in grouped.values():
|
|
121
|
+
for recs in fields.values():
|
|
122
|
+
recs.sort(key=lambda r: r.get("confidence", 0), reverse=True)
|
|
123
|
+
return {stem: dict(fields) for stem, fields in grouped.items()}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def majority_cutoff(n):
|
|
127
|
+
return n // 2 + 1
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def rate(by_file, models=None):
|
|
131
|
+
models = config.EXTRACTION_MODELS if models is None else models
|
|
132
|
+
n = len(models)
|
|
133
|
+
Path(config.OUTPUT_DIR).mkdir(parents=True, exist_ok=True)
|
|
134
|
+
all_facts = fuse_outputs(by_file, models)
|
|
135
|
+
|
|
136
|
+
# fused.json = the end result
|
|
137
|
+
fused_path = Path(config.OUTPUT_DIR) / "fused.json"
|
|
138
|
+
write_json(fused_path, _group_by_doc(all_facts))
|
|
139
|
+
|
|
140
|
+
rows = agreement(all_facts, models)
|
|
141
|
+
report = {
|
|
142
|
+
"models": models,
|
|
143
|
+
"facts": len(all_facts),
|
|
144
|
+
"full_agreement": sum(1 for f in all_facts if len(f["models"]) == n),
|
|
145
|
+
"agreement": rows,
|
|
146
|
+
"partial_agreement": [f for f in all_facts if len(f["models"]) < n],
|
|
147
|
+
}
|
|
148
|
+
out = Path(config.OUTPUT_DIR) / "rating_report.json"
|
|
149
|
+
write_json(out, report)
|
|
150
|
+
_print_agreement(len(all_facts), rows)
|
|
151
|
+
print(f"\nwrote {out}\nwrote {fused_path}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def agreement(facts, models):
|
|
155
|
+
if not facts:
|
|
156
|
+
return []
|
|
157
|
+
present = {m: [1 if m in f["models"] else 0 for f in facts] for m in models}
|
|
158
|
+
rows = []
|
|
159
|
+
for i, a in enumerate(models):
|
|
160
|
+
for b in models[i + 1:]:
|
|
161
|
+
agree = sum(x == y for x, y in zip(present[a], present[b])) / len(facts)
|
|
162
|
+
if len(set(present[a] + present[b])) < 2:
|
|
163
|
+
# cohen_kappa_score requires at least two label classes; skip if all labels are identical
|
|
164
|
+
kappa = None
|
|
165
|
+
else:
|
|
166
|
+
kappa = round(cohen_kappa_score(present[a], present[b], labels=[0, 1]), 3)
|
|
167
|
+
rows.append({"raters": [a, b], "kappa": kappa, "agreement": round(agree, 4)})
|
|
168
|
+
return rows
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _print_agreement(n_facts, rows):
|
|
172
|
+
print(f"\ninter-rater agreement across {n_facts} facts (all files):")
|
|
173
|
+
for row in rows:
|
|
174
|
+
a, b = row["raters"]
|
|
175
|
+
kappa = "n/a" if row["kappa"] is None else row["kappa"]
|
|
176
|
+
print(f" {a} <-> {b}: kappa={kappa}, agreement={row['agreement']:.1%}")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main():
|
|
180
|
+
by_file = load_outputs()
|
|
181
|
+
if not by_file:
|
|
182
|
+
print(f"No extraction outputs found in {config.OUTPUT_DIR}")
|
|
183
|
+
return
|
|
184
|
+
rate(by_file)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
main()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Provenance-stamped record envelopes.
|
|
2
|
+
|
|
3
|
+
Wraps each document's fused facts (from fused.json) in a metadata envelope
|
|
4
|
+
that records the source file, document type, which models were used (schema,
|
|
5
|
+
vision, embedding), the user's original keys, and the normalised key mapping.
|
|
6
|
+
The result is records.json.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from . import config, matching
|
|
11
|
+
from .paths import inter, read_json, write_json
|
|
12
|
+
|
|
13
|
+
FUSED = Path(config.OUTPUT_DIR) / "fused.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _run(stem):
|
|
17
|
+
"""Load the run stamp extract.py wrote (extracted_at, user_keys) for the envelope's
|
|
18
|
+
provenance. Returns {} when it's missing so make_record falls back to its defaults."""
|
|
19
|
+
path = inter(stem, "run.json")
|
|
20
|
+
return read_json(path) if path.exists() else {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _document_type(stem):
|
|
24
|
+
path = inter(stem, "schema.json")
|
|
25
|
+
return read_json(path).get("document_type", "unknown") if path.exists() else "unknown"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_record(stem, by_key):
|
|
29
|
+
"""Wrap one document's fused facts in a metadata envelope: a type tag, a timestamp, and
|
|
30
|
+
provenance (source file, document type, the models used, and the user's keys)."""
|
|
31
|
+
run = _run(stem)
|
|
32
|
+
return {
|
|
33
|
+
"type": "ExtractionResult",
|
|
34
|
+
"generated_at": run.get("extracted_at"),
|
|
35
|
+
"provenance": {
|
|
36
|
+
"source_file": f"{stem}.pdf",
|
|
37
|
+
"document_type": _document_type(stem),
|
|
38
|
+
"models": {
|
|
39
|
+
"schema_discovery": os.environ["SCHEMA_MODEL"],
|
|
40
|
+
"vision": os.environ["VISION_MODEL"],
|
|
41
|
+
"embedding": os.environ["EMBEDDING_MODEL"],
|
|
42
|
+
},
|
|
43
|
+
"user_keys": run.get("user_keys", []),
|
|
44
|
+
"key_mapping": {matching.normalize_key(k): k for k in run.get("user_keys", [])},
|
|
45
|
+
},
|
|
46
|
+
**by_key,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_records(path=FUSED):
|
|
51
|
+
report = read_json(path)
|
|
52
|
+
return {stem: make_record(stem, by_key) for stem, by_key in report.items()}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def write_records(output_dir):
|
|
56
|
+
"""Wrap every document's fused facts in a provenance envelope and write records.json.
|
|
57
|
+
Keeps all records; consumers filter by the `confidence` field (see filter_records.py)."""
|
|
58
|
+
output_dir = Path(output_dir)
|
|
59
|
+
envelopes = build_records(output_dir / "fused.json")
|
|
60
|
+
out_path = output_dir / "records.json"
|
|
61
|
+
write_json(out_path, envelopes)
|
|
62
|
+
return out_path, len(envelopes)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main():
|
|
66
|
+
out_path, n = write_records(config.OUTPUT_DIR)
|
|
67
|
+
print(f"wrote {out_path} ({n} documents)")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
main()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Semantic search : match user keys to document sections and table rows.
|
|
2
|
+
|
|
3
|
+
Computes cosine similarity between the key embeddings and the section/row
|
|
4
|
+
embeddings produced by embed.py, then returns the top-K hits above
|
|
5
|
+
SIMILARITY_THRESHOLD for each key.
|
|
6
|
+
|
|
7
|
+
The embedding matrix has sections first, then rows; a hit index
|
|
8
|
+
>= num_sections is a row-level match and carries the originating section_idx,
|
|
9
|
+
row_text, and group_header so downstream extraction knows which table and row
|
|
10
|
+
the hit came from.
|
|
11
|
+
|
|
12
|
+
Results are cached to matches.json and reused on reruns unless --force is set.
|
|
13
|
+
"""
|
|
14
|
+
import argparse
|
|
15
|
+
import logging
|
|
16
|
+
import numpy as np
|
|
17
|
+
from . import config
|
|
18
|
+
from .paths import inter, read_json, cached
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def cosine_similarity(a, b):
|
|
24
|
+
# floor the norm so a zero-norm vector stays zero instead of 0/0 = nan, which would
|
|
25
|
+
a_norm = a / np.maximum(np.linalg.norm(a, axis=1, keepdims=True), 1e-12)
|
|
26
|
+
b_norm = b / np.maximum(np.linalg.norm(b, axis=1, keepdims=True), 1e-12)
|
|
27
|
+
return a_norm @ b_norm.T
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _build_match_dict(idx, score, sec_texts, sections, num_secs, rows):
|
|
31
|
+
preview = sec_texts[idx][:150] + "..." if len(sec_texts[idx]) > 150 else sec_texts[idx]
|
|
32
|
+
|
|
33
|
+
is_row = idx >= num_secs and len(rows) > 0
|
|
34
|
+
if is_row:
|
|
35
|
+
row_idx = idx - num_secs
|
|
36
|
+
row_info = rows[row_idx] if row_idx < len(rows) else {}
|
|
37
|
+
sec_idx = row_info.get("section_idx", idx)
|
|
38
|
+
else:
|
|
39
|
+
sec_idx = idx
|
|
40
|
+
|
|
41
|
+
match = {
|
|
42
|
+
"section_idx": int(sec_idx),
|
|
43
|
+
"similarity": round(score, 4),
|
|
44
|
+
"content_preview": preview,
|
|
45
|
+
}
|
|
46
|
+
if is_row:
|
|
47
|
+
match.update({
|
|
48
|
+
"is_row": True,
|
|
49
|
+
"row_text": row_info.get("row_text", ""),
|
|
50
|
+
"row_number": row_info.get("row_number"),
|
|
51
|
+
"group_header": row_info.get("group_header", ""),
|
|
52
|
+
})
|
|
53
|
+
if sec_idx < len(sections):
|
|
54
|
+
match["type"] = sections[sec_idx].get("type", "unknown")
|
|
55
|
+
|
|
56
|
+
return match
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _select_hits_for_key(sims, sec_texts, sections, num_secs, rows, threshold):
|
|
60
|
+
key_matches = []
|
|
61
|
+
for idx in np.argsort(sims)[::-1]:
|
|
62
|
+
score = float(sims[idx])
|
|
63
|
+
if score < threshold or len(key_matches) >= config.SEARCH_TOP_K:
|
|
64
|
+
break
|
|
65
|
+
key_matches.append(_build_match_dict(idx, score, sec_texts, sections, num_secs, rows))
|
|
66
|
+
return key_matches
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def search(stem, original_keys=None, threshold=None):
|
|
70
|
+
threshold = config.SIMILARITY_THRESHOLD if threshold is None else threshold
|
|
71
|
+
|
|
72
|
+
def build():
|
|
73
|
+
key_embs = np.load(inter(stem, "key_embeddings.npy"))
|
|
74
|
+
if key_embs.size == 0:
|
|
75
|
+
logger.warning("No key embeddings. Nothing to search.")
|
|
76
|
+
return {}
|
|
77
|
+
sec_embs = np.load(inter(stem, "section_embeddings.npy"))
|
|
78
|
+
sec_texts = read_json(inter(stem, "section_texts.json"))
|
|
79
|
+
sections = read_json(inter(stem, "sections.json"))
|
|
80
|
+
key_texts = read_json(inter(stem, "key_texts.json"))
|
|
81
|
+
row_map = read_json(inter(stem, "row_map.json"))
|
|
82
|
+
# num_secs is the boundary: idx >= num_secs means a row hit
|
|
83
|
+
num_secs = row_map.get("num_sections", len(sections))
|
|
84
|
+
rows = row_map.get("rows", [])
|
|
85
|
+
keys = original_keys if original_keys is not None else read_json(inter(stem, "original_keys.json"))
|
|
86
|
+
|
|
87
|
+
sims = cosine_similarity(key_embs, sec_embs)
|
|
88
|
+
matches = {}
|
|
89
|
+
for i, key_text in enumerate(key_texts):
|
|
90
|
+
orig_key = keys[i] if i < len(keys) else key_text
|
|
91
|
+
matches[orig_key] = _select_hits_for_key(
|
|
92
|
+
sims[i], sec_texts, sections, num_secs, rows, threshold)
|
|
93
|
+
logger.info(f"Key '{orig_key}': {len(matches[orig_key])} matches above {threshold}")
|
|
94
|
+
return matches
|
|
95
|
+
|
|
96
|
+
return cached(inter(stem, "matches.json"), build)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main():
|
|
100
|
+
parser = argparse.ArgumentParser(description="Semantic search")
|
|
101
|
+
parser.add_argument("--stem", required=True)
|
|
102
|
+
parser.add_argument("--threshold", type=float)
|
|
103
|
+
parser.add_argument("--original-keys", nargs="+")
|
|
104
|
+
args = parser.parse_args()
|
|
105
|
+
result = search(args.stem, original_keys=args.original_keys, threshold=args.threshold)
|
|
106
|
+
print(f"Found matches for {len(result)} keys")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
main()
|