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/__init__.py +3 -0
- kba/__main__.py +6 -0
- kba/analyze.py +908 -0
- kba/cli.py +552 -0
- kba/doctor.py +73 -0
- kba/eval.py +332 -0
- kba/extract.py +511 -0
- kba/fingerprint.py +333 -0
- kba/jobs.py +69 -0
- kba/links.py +38 -0
- kba/pii.py +37 -0
- kba/report.py +565 -0
- kba/summary.py +55 -0
- kba/tags.py +108 -0
- kba/telemetry.py +78 -0
- kba/util.py +204 -0
- kba_cli-0.3.0rc4.dist-info/METADATA +135 -0
- kba_cli-0.3.0rc4.dist-info/RECORD +22 -0
- kba_cli-0.3.0rc4.dist-info/WHEEL +5 -0
- kba_cli-0.3.0rc4.dist-info/entry_points.txt +2 -0
- kba_cli-0.3.0rc4.dist-info/licenses/LICENSE +21 -0
- kba_cli-0.3.0rc4.dist-info/top_level.txt +1 -0
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)
|