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,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()