kba-cli 0.3.0rc4__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.
kba/eval.py ADDED
@@ -0,0 +1,332 @@
1
+ """Labeled-corpus evaluation for extraction and analysis accuracy.
2
+
3
+ This harness measures behavior on a labeled corpus; synthetic fixtures only prove
4
+ regression/mechanics, not real-world accuracy. No data leaves the machine.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import itertools
9
+ import tempfile
10
+ from collections import defaultdict
11
+ from pathlib import Path
12
+
13
+ from . import analyze, fingerprint
14
+
15
+ SCHEMA = "kba.eval/1"
16
+
17
+
18
+ def _pair(a: str, b: str) -> tuple[str, str]:
19
+ return (a, b) if a < b else (b, a)
20
+
21
+
22
+ def _group_pairs(values) -> set[tuple[str, str]]:
23
+ items = [str(v) for v in (values or []) if str(v)]
24
+ return {_pair(a, b) for a, b in itertools.combinations(sorted(set(items)), 2)}
25
+
26
+
27
+ def _pr(tp: int, fp: int, fn: int) -> dict:
28
+ precision = tp / (tp + fp) if tp + fp else None
29
+ recall = tp / (tp + fn) if tp + fn else None
30
+ if precision is None or recall is None:
31
+ f1 = None
32
+ elif precision + recall == 0:
33
+ f1 = 0.0
34
+ else:
35
+ f1 = 2 * precision * recall / (precision + recall)
36
+ return {
37
+ "tp": tp, "fp": fp, "fn": fn,
38
+ "precision": round(precision, 4) if precision is not None else None,
39
+ "recall": round(recall, 4) if recall is not None else None,
40
+ "f1": round(f1, 4) if f1 is not None else None,
41
+ }
42
+
43
+
44
+ def _score_sets(pred: set, truth: set) -> dict:
45
+ return _pr(len(pred & truth), len(pred - truth), len(truth - pred))
46
+
47
+
48
+ class _Resolver:
49
+ def __init__(self, records: list[dict], warnings: list[str]):
50
+ self.by_rel = {r.get("rel"): r for r in records if r.get("rel")}
51
+ self.by_p = {r.get("p"): r for r in records if r.get("p")}
52
+ self.by_name: dict[str, list[dict]] = defaultdict(list)
53
+ for r in records:
54
+ self.by_name[r.get("name", "")].append(r)
55
+ self.warnings = warnings
56
+
57
+ def get(self, value, warn: bool = True) -> dict | None:
58
+ if not isinstance(value, str) or not value:
59
+ return None
60
+ if value in self.by_rel:
61
+ return self.by_rel[value]
62
+ if value in self.by_p:
63
+ return self.by_p[value]
64
+ matches = self.by_name.get(value) or []
65
+ if len(matches) == 1:
66
+ return matches[0]
67
+ if warn and len(matches) > 1:
68
+ self.warnings.append(f"label 文件名不唯一,需用相对路径:{value}")
69
+ elif warn:
70
+ self.warnings.append(f"label 未匹配到文件:{value}")
71
+ return None
72
+
73
+
74
+ def _record_pairs(records: list[dict], groups) -> tuple[set[tuple[str, str]], list[str]]:
75
+ warnings: list[str] = []
76
+ resolver = _Resolver(records, warnings)
77
+ pairs: set[tuple[str, str]] = set()
78
+ for group in groups or []:
79
+ if not isinstance(group, (list, tuple)) or len(group) < 2:
80
+ continue
81
+ keys = [resolver.get(v) for v in group]
82
+ if any(k is None for k in keys):
83
+ continue
84
+ pairs |= _group_pairs([k["p"] for k in keys])
85
+ return pairs, warnings
86
+
87
+
88
+ def _page_pairs(overlaps: list[dict]) -> set[tuple[str, str, int, int]]:
89
+ result = set()
90
+ for o in overlaps:
91
+ a, b = o.get("a", ""), o.get("b", "")
92
+ pa, pb = int(o.get("a_page", 0) or 0), int(o.get("b_page", 0) or 0)
93
+ if not a or not b:
94
+ continue
95
+ if a > b:
96
+ a, b, pa, pb = b, a, pb, pa
97
+ result.add((a, b, pa, pb))
98
+ return result
99
+
100
+
101
+ def _in_scope(p: str, scope: set[str]) -> bool:
102
+ return not scope or p in scope
103
+
104
+
105
+ def _eval_page(records: list[dict], analysis: dict, labels: dict,
106
+ warnings: list[str], scope: set[str]) -> dict:
107
+ resolver = _Resolver(records, warnings)
108
+ truth = set()
109
+ for item in labels.get("page_pairs") or []:
110
+ if not isinstance(item, dict):
111
+ continue
112
+ a = resolver.get(item.get("a"))
113
+ b = resolver.get(item.get("b"))
114
+ if not a or not b or not _in_scope(a["p"], scope) or not _in_scope(b["p"], scope):
115
+ continue
116
+ pa = int(item.get("a_page", 1) or 1)
117
+ pb = int(item.get("b_page", 1) or 1)
118
+ if a["p"] > b["p"]:
119
+ a, b, pa, pb = b, a, pb, pa
120
+ truth.add((a["p"], b["p"], pa, pb))
121
+ pred = {p for p in _page_pairs(analysis.get("page_overlaps") or [])
122
+ if _in_scope(p[0], scope) and _in_scope(p[1], scope)}
123
+ return _score_sets(pred, truth)
124
+
125
+
126
+ def _eval_version(records: list[dict], analysis: dict, labels: dict,
127
+ warnings: list[str], scope: set[str]) -> dict:
128
+ resolver = _Resolver(records, warnings)
129
+ truth = set()
130
+ for item in labels.get("version_pairs") or []:
131
+ if isinstance(item, dict):
132
+ f, d = resolver.get(item.get("final")), resolver.get(item.get("draft"))
133
+ elif isinstance(item, (list, tuple)) and len(item) >= 2:
134
+ f, d = resolver.get(item[0]), resolver.get(item[1])
135
+ else:
136
+ continue
137
+ if f and d and _in_scope(f["p"], scope) and _in_scope(d["p"], scope):
138
+ truth.add((f["p"], d["p"]))
139
+
140
+ pred = set()
141
+ keep_final = 0
142
+ for c in analysis.get("clusters") or []:
143
+ finals = [m["p"] for m in c.get("members", [])
144
+ if m.get("version_stage") == "final" and _in_scope(m["p"], scope)]
145
+ drafts = [m["p"] for m in c.get("members", [])
146
+ if m.get("version_stage") == "draft" and _in_scope(m["p"], scope)]
147
+ for f in finals:
148
+ for d in drafts:
149
+ pred.add((f, d))
150
+ if c.get("recommend", {}).get("keep") == f:
151
+ keep_final += 1
152
+ score = _score_sets(pred, truth)
153
+ score["keep_final_rate"] = round(keep_final / len(pred), 4) if pred else None
154
+ return score
155
+
156
+
157
+ def _eval_pii(records: list[dict], analysis: dict, labels: dict,
158
+ warnings: list[str], scope: set[str]) -> dict:
159
+ resolver = _Resolver(records, warnings)
160
+ truth = set()
161
+ for value in labels.get("pii_files") or []:
162
+ r = resolver.get(value)
163
+ if r and _in_scope(r["p"], scope):
164
+ truth.add(r["p"])
165
+ pii = analysis.get("pii") or {}
166
+ if pii.get("files", 0) > len(pii.get("examples") or []):
167
+ warnings.append("PII 文件数超过分析结果保留的示例上限(50),召回可能偏低")
168
+ pred = set()
169
+ for ex in pii.get("examples") or []:
170
+ r = resolver.get(ex.get("p") or ex.get("name", ""), warn=False)
171
+ if r and _in_scope(r["p"], scope):
172
+ pred.add(r["p"])
173
+ return _score_sets(pred, truth)
174
+
175
+
176
+ def _eval_tags(records: list[dict], labels: dict,
177
+ warnings: list[str], scope: set[str]) -> dict:
178
+ resolver = _Resolver(records, warnings)
179
+ label_tags = labels.get("tags") or {}
180
+ if not isinstance(label_tags, dict):
181
+ return {"files": 0, "exact": 0, "exact_rate": None,
182
+ "jaccard": None, "precision": None, "recall": None}
183
+ exact = 0
184
+ jaccards = []
185
+ tp = fp = fn = 0
186
+ for value, expected in label_tags.items():
187
+ r = resolver.get(value)
188
+ if not r or not _in_scope(r["p"], scope):
189
+ continue
190
+ expected_set = {str(t).strip() for t in (expected or []) if str(t).strip()}
191
+ predicted = {str(t).strip() for t in (r.get("tags") or []) if str(t).strip()}
192
+ if predicted == expected_set:
193
+ exact += 1
194
+ union = predicted | expected_set
195
+ jaccards.append(len(predicted & expected_set) / len(union) if union else 1.0)
196
+ tp += len(predicted & expected_set)
197
+ fp += len(predicted - expected_set)
198
+ fn += len(expected_set - predicted)
199
+ precision = tp / (tp + fp) if tp + fp else None
200
+ recall = tp / (tp + fn) if tp + fn else None
201
+ return {
202
+ "files": len(jaccards),
203
+ "exact": exact,
204
+ "exact_rate": round(exact / len(jaccards), 4) if jaccards else None,
205
+ "jaccard": round(sum(jaccards) / len(jaccards), 4) if jaccards else None,
206
+ "precision": round(precision, 4) if precision is not None else None,
207
+ "recall": round(recall, 4) if recall is not None else None,
208
+ }
209
+
210
+
211
+ def _eval_extraction(records: list[dict], expected: dict | None) -> dict:
212
+ by_ext: dict[str, dict] = {}
213
+ for r in records:
214
+ ext = r.get("ext", "")
215
+ if not ext:
216
+ continue
217
+ bucket = by_ext.setdefault(ext, {
218
+ "files": 0, "extracted": 0, "no_text": 0, "failed": 0,
219
+ "not_enabled": 0, "unsupported": 0, "other": 0,
220
+ })
221
+ bucket["files"] += 1
222
+ status = r.get("text_status") or "other"
223
+ bucket[status if status in bucket else "other"] += 1
224
+ attempted = sum(b["extracted"] + b["no_text"] + b["failed"] for b in by_ext.values())
225
+ extracted = sum(b["extracted"] for b in by_ext.values())
226
+ expected = expected or {}
227
+ match = all(int(expected.get(ext, -1)) == by_ext.get(ext, {}).get("files", 0)
228
+ for ext in expected)
229
+ return {
230
+ "by_ext": by_ext,
231
+ "expected": expected,
232
+ "match": match if expected else None,
233
+ "attempted": attempted,
234
+ "success_rate": round(extracted / attempted, 4) if attempted else None,
235
+ }
236
+
237
+
238
+ def eval_corpus(root: Path, labels: dict | None = None,
239
+ extract_dir: Path | None = None) -> dict:
240
+ root = Path(root).resolve()
241
+ if not root.is_dir():
242
+ raise NotADirectoryError(str(root))
243
+ labels = labels or {}
244
+ warnings: list[str] = []
245
+ with tempfile.TemporaryDirectory(prefix="kba-eval-job-") as td:
246
+ job = Path(td)
247
+ fp = fingerprint.scan(root, job, extract_dir=extract_dir)
248
+ result = analyze.analyze(job)
249
+ records = fp["files"]
250
+
251
+ scope: set[str] = set()
252
+ if labels.get("scope_files"):
253
+ scope_resolver = _Resolver(records, warnings)
254
+ for value in labels.get("scope_files") or []:
255
+ r = scope_resolver.get(value)
256
+ if r:
257
+ scope.add(r["p"])
258
+
259
+ near_truth, near_warn = _record_pairs(records, labels.get("near_pairs") or [])
260
+ warnings += near_warn
261
+ near_truth = {p for p in near_truth if _in_scope(p[0], scope) and _in_scope(p[1], scope)}
262
+ pred_near = set()
263
+ for c in result.get("clusters") or []:
264
+ if c.get("type") in ("exact", "near"):
265
+ members = [m["p"] for m in c.get("members", []) if _in_scope(m["p"], scope)]
266
+ pred_near |= {_pair(a, b) for a, b in itertools.combinations(members, 2)}
267
+
268
+ same_truth, same_warn = _record_pairs(records, labels.get("same_name_groups") or [])
269
+ warnings += same_warn
270
+ same_truth = {p for p in same_truth if _in_scope(p[0], scope) and _in_scope(p[1], scope)}
271
+ pred_same = set()
272
+ for c in result.get("clusters") or []:
273
+ if c.get("type") == "same-name":
274
+ members = [m["p"] for m in c.get("members", []) if _in_scope(m["p"], scope)]
275
+ pred_same |= {_pair(a, b) for a, b in itertools.combinations(members, 2)}
276
+
277
+ metrics = {
278
+ "near": _score_sets(pred_near, near_truth),
279
+ "same_name": _score_sets(pred_same, same_truth),
280
+ "page": _eval_page(records, result, labels, warnings, scope),
281
+ "version": _eval_version(records, result, labels, warnings, scope),
282
+ "pii": _eval_pii(records, result, labels, warnings, scope),
283
+ "tags": _eval_tags(records, labels, warnings, scope),
284
+ }
285
+
286
+ return {
287
+ "schema": SCHEMA,
288
+ "root": {"name": root.name, "files": len(records)},
289
+ "labels": {
290
+ "near_pairs": len(near_truth),
291
+ "same_name_pairs": len(same_truth),
292
+ "page_pairs": len(labels.get("page_pairs") or []),
293
+ "version_pairs": len(labels.get("version_pairs") or []),
294
+ "pii_files": len(labels.get("pii_files") or []),
295
+ "tag_files": len(labels.get("tags") or {}),
296
+ "scope_files": len(scope) if labels.get("scope_files") else None,
297
+ },
298
+ "metrics": metrics,
299
+ "extraction": _eval_extraction(records, labels.get("extract_expected")),
300
+ "warnings": warnings,
301
+ }
302
+
303
+
304
+ def format_text(result: dict) -> str:
305
+ lines = [f"KBA eval: {result['root']['files']} files / schema {result['schema']}"]
306
+ names = {
307
+ "near": "近重复", "same_name": "同名异内容", "page": "页级重合",
308
+ "version": "终稿/底稿", "pii": "PII 文件",
309
+ }
310
+ for key, label in names.items():
311
+ m = result["metrics"][key]
312
+ lines.append(
313
+ f"{label:<10} P={m['precision']} R={m['recall']} F1={m['f1']} "
314
+ f"(tp={m['tp']} fp={m['fp']} fn={m['fn']})"
315
+ )
316
+ tags = result["metrics"]["tags"]
317
+ lines.append(
318
+ f"{'标签':<10} exact={tags['exact_rate']} jaccard={tags['jaccard']} "
319
+ f"P={tags['precision']} R={tags['recall']} (files={tags['files']})"
320
+ )
321
+ ex = result["extraction"]
322
+ lines.append(
323
+ f"抽取 success={ex['success_rate']} expected_match={ex['match']} "
324
+ f"attempted={ex['attempted']}"
325
+ )
326
+ for ext, b in sorted(ex["by_ext"].items()):
327
+ lines.append(f" {ext}: files={b['files']} extracted={b['extracted']} "
328
+ f"no_text={b['no_text']} failed={b['failed']} "
329
+ f"not_enabled={b['not_enabled']} unsupported={b['unsupported']}")
330
+ for w in result.get("warnings") or []:
331
+ lines.append(f"WARN {w}")
332
+ return "\n".join(lines)