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/analyze.py ADDED
@@ -0,0 +1,908 @@
1
+ """Analysis: duplicate clustering, canonical recommendation, quality metrics."""
2
+ from __future__ import annotations
3
+
4
+ import difflib
5
+ import math
6
+ import os
7
+ import re
8
+ import zlib
9
+ from collections import Counter, defaultdict
10
+ from pathlib import Path
11
+
12
+ from . import fingerprint, pii, util
13
+ from .jobs import read_state, write_state
14
+
15
+ SHINGLE_K = 5
16
+ SHINGLE_CAP = 60_000
17
+ JACCARD_NEAR = 0.85
18
+ CONTAIN_SUPERSET = 0.90
19
+ STALE_DAYS = 730
20
+
21
+ # Data-confidence grades are defined on text coverage of binary files.
22
+ CONFIDENCE_A = 0.90
23
+ CONFIDENCE_B = 0.60
24
+
25
+ # Fixed weights (percent). Only components with usable data are scored;
26
+ # the remaining weight is reported as "weight coverage", never faked with 0/0.5.
27
+ HEALTH_COMPONENTS = (
28
+ ("duplicate", "重复", 30),
29
+ ("stale", "过时", 20),
30
+ ("tagging", "标签", 15),
31
+ ("structure", "结构", 15),
32
+ ("retrieval", "自检索", 20),
33
+ )
34
+ HEALTH_WEIGHT_TOTAL = sum(w for _, _, w in HEALTH_COMPONENTS)
35
+
36
+
37
+ def _confidence(text_coverage: float | None) -> str:
38
+ if text_coverage is None:
39
+ return "A"
40
+ if text_coverage >= CONFIDENCE_A:
41
+ return "A"
42
+ if text_coverage >= CONFIDENCE_B:
43
+ return "B"
44
+ return "C"
45
+
46
+
47
+ def _shingles(text: str) -> frozenset[int]:
48
+ t = "".join((text or "")[:SHINGLE_CAP].split())
49
+ if len(t) < SHINGLE_K:
50
+ return frozenset()
51
+ return frozenset(zlib.crc32(t[i:i + SHINGLE_K].encode("utf-8")) for i in range(len(t) - SHINGLE_K + 1))
52
+
53
+
54
+ class _UF:
55
+ def __init__(self, n: int):
56
+ self.p = list(range(n))
57
+
58
+ def find(self, x: int) -> int:
59
+ while self.p[x] != x:
60
+ self.p[x] = self.p[self.p[x]]
61
+ x = self.p[x]
62
+ return x
63
+
64
+ def union(self, a: int, b: int) -> None:
65
+ ra, rb = self.find(a), self.find(b)
66
+ if ra != rb:
67
+ self.p[rb] = ra
68
+
69
+
70
+ def _near_pairs(records: list[dict]) -> list[tuple[int, int, float, float]]:
71
+ """Candidate pairs via shared rare shingles; return (i, j, jaccard, containment)."""
72
+ sets: dict[int, frozenset[int]] = {}
73
+ for i, r in enumerate(records):
74
+ s = _shingles(r.get("_text", ""))
75
+ if s:
76
+ sets[i] = s
77
+ if len(sets) < 2:
78
+ return []
79
+
80
+ index: dict[int, list[int]] = defaultdict(list)
81
+ for i, s in sets.items():
82
+ for sh in s:
83
+ index[sh].append(i)
84
+
85
+ counts: dict[tuple[int, int], int] = defaultdict(int)
86
+ for sh, docs in index.items():
87
+ if 2 <= len(docs) <= 50:
88
+ for a in range(len(docs)):
89
+ for b in range(a + 1, len(docs)):
90
+ x, y = docs[a], docs[b]
91
+ counts[(x, y) if x < y else (y, x)] += 1
92
+
93
+ pairs = []
94
+ for (i, j), shared in counts.items():
95
+ if shared < 5:
96
+ continue
97
+ si, sj = sets[i], sets[j]
98
+ if not si or not sj:
99
+ continue
100
+ ratio = len(si) / len(sj)
101
+ # Draft/final versions can differ a lot in length; containment below still decides.
102
+ if ratio < 0.2 or ratio > 5.0:
103
+ continue
104
+ inter = len(si & sj)
105
+ union = len(si | sj)
106
+ if not union:
107
+ continue
108
+ jac = inter / union
109
+ contain = inter / min(len(si), len(sj))
110
+ if jac >= JACCARD_NEAR or contain >= CONTAIN_SUPERSET:
111
+ pairs.append((i, j, jac, contain))
112
+ return pairs
113
+
114
+
115
+ PAGE_MIN_SHARED = 8
116
+ PAGE_MAX_PAGES = 200
117
+ PAGE_MAX_CANDIDATES = 20000
118
+
119
+
120
+ def _page_sets(records: list[dict]) -> dict[int, list[frozenset[int]]]:
121
+ pages: dict[int, list[frozenset[int]]] = {}
122
+ for i, r in enumerate(records):
123
+ text = r.get("_text", "")
124
+ if "\f" not in text:
125
+ continue
126
+ sets = [_shingles(p) for p in text.split("\f") if p.strip()]
127
+ sets = [s for s in sets if s][:PAGE_MAX_PAGES]
128
+ if len(sets) >= 2:
129
+ pages[i] = sets
130
+ return pages
131
+
132
+
133
+ def _page_overlaps(records: list[dict], clusters: list[dict]) -> tuple[list[dict], int]:
134
+ """Find cross-document page-level overlaps; never auto-merges whole documents."""
135
+ pages = _page_sets(records)
136
+ if len(pages) < 2:
137
+ return [], 0
138
+ index: dict[int, list[tuple[int, int]]] = defaultdict(list)
139
+ for i, sets in pages.items():
140
+ for pi, s in enumerate(sets):
141
+ for sh in s:
142
+ index[sh].append((i, pi))
143
+
144
+ counts: dict[tuple[int, int], int] = defaultdict(int)
145
+ full = False
146
+ for postings in index.values():
147
+ if not (2 <= len(postings) <= 50):
148
+ continue
149
+ for a in range(len(postings)):
150
+ i, _ = postings[a]
151
+ for b in range(a + 1, len(postings)):
152
+ j, _ = postings[b]
153
+ if i == j:
154
+ continue
155
+ counts[(i, j) if i < j else (j, i)] += 1
156
+ if len(counts) > PAGE_MAX_CANDIDATES:
157
+ full = True
158
+ break
159
+ if full:
160
+ break
161
+ if full:
162
+ break
163
+
164
+ cluster_of: dict[str, str] = {}
165
+ for c in clusters:
166
+ for m in c["members"]:
167
+ cluster_of[m["p"]] = c["id"]
168
+
169
+ overlaps = []
170
+ for (i, j), shared in counts.items():
171
+ if shared < PAGE_MIN_SHARED:
172
+ continue
173
+ if records[i].get("sha256") and records[i].get("sha256") == records[j].get("sha256"):
174
+ continue
175
+ cid = cluster_of.get(records[i]["p"])
176
+ if cid and cid == cluster_of.get(records[j]["p"]):
177
+ continue
178
+ best = None
179
+ for pi, si in enumerate(pages[i]):
180
+ for pj, sj in enumerate(pages[j]):
181
+ inter = len(si & sj)
182
+ if not inter:
183
+ continue
184
+ union = len(si | sj)
185
+ jac = inter / union if union else 0.0
186
+ contain = inter / min(len(si), len(sj))
187
+ if jac >= JACCARD_NEAR or contain >= CONTAIN_SUPERSET:
188
+ cand = (jac, contain, pi, pj)
189
+ if best is None or (cand[0], cand[1]) > (best[0], best[1]):
190
+ best = cand
191
+ if best:
192
+ jac, contain, pi, pj = best
193
+ overlaps.append({
194
+ "a": records[i]["p"], "a_name": records[i].get("name", ""),
195
+ "a_page": pi + 1, "pages_a": len(pages[i]),
196
+ "b": records[j]["p"], "b_name": records[j].get("name", ""),
197
+ "b_page": pj + 1, "pages_b": len(pages[j]),
198
+ "page_jaccard": round(jac, 3),
199
+ "page_containment": round(contain, 3),
200
+ })
201
+ overlaps.sort(key=lambda o: (-o["page_jaccard"], -o["page_containment"]))
202
+ return overlaps, len(pages)
203
+
204
+
205
+ DIFF_CAP = 20_000
206
+
207
+
208
+ def _content_delta(keep: dict, other: dict) -> dict:
209
+ """Summary-only text diff; no excerpts are stored."""
210
+ keep_text = keep.get("_text", "") or ""
211
+ other_text = other.get("_text", "") or ""
212
+ a = re.sub(r"\s+", " ", keep_text[:DIFF_CAP]).strip()
213
+ b = re.sub(r"\s+", " ", other_text[:DIFF_CAP]).strip()
214
+ added = removed = 0
215
+ similarity = None
216
+ if a and b:
217
+ matcher = difflib.SequenceMatcher(None, a, b, autojunk=False)
218
+ similarity = round(matcher.ratio(), 4)
219
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
220
+ if tag == "insert":
221
+ added += j2 - j1
222
+ elif tag == "delete":
223
+ removed += i2 - i1
224
+ elif tag == "replace":
225
+ added += j2 - j1
226
+ removed += i2 - i1
227
+ return {
228
+ "p": other.get("p", ""),
229
+ "name": other.get("name", ""),
230
+ "version_stage": other.get("version_stage", "normal"),
231
+ "chars": len(other_text),
232
+ "delta_chars": len(other_text) - len(keep_text),
233
+ "added_chars": added,
234
+ "removed_chars": removed,
235
+ "similarity": similarity,
236
+ }
237
+
238
+
239
+ def _attach_content_diff(cluster: dict, records_by_p: dict[str, dict]) -> None:
240
+ members = [records_by_p.get(m["p"]) for m in cluster.get("members", [])]
241
+ members = [m for m in members if m and m.get("_text")]
242
+ if len(members) < 2:
243
+ return
244
+ keep_p = cluster.get("recommend", {}).get("keep") or ""
245
+ keep = records_by_p.get(keep_p) if keep_p else None
246
+ if not keep or not keep.get("_text"):
247
+ keep = max(members, key=lambda m: len(m.get("_text", "")))
248
+ others = [m for m in members if m is not keep]
249
+ cluster["content_diff"] = {
250
+ "keep": {
251
+ "p": keep.get("p", ""), "name": keep.get("name", ""),
252
+ "chars": len(keep.get("_text", "")),
253
+ "version_stage": keep.get("version_stage", "normal"),
254
+ },
255
+ "compared": [_content_delta(keep, m) for m in others[:5]],
256
+ "truncated": len(others) > 5,
257
+ }
258
+
259
+
260
+ STAGE_RANK = {"final": 0, "normal": 1, "draft": 2}
261
+
262
+
263
+ def _score_keep(r: dict) -> tuple:
264
+ stage = STAGE_RANK.get(r.get("version_stage"), 1)
265
+ return (stage, r.get("depth", 0), -float(r.get("mtime", 0) or 0),
266
+ len(r.get("name", "")), r.get("p", ""))
267
+
268
+
269
+ def _keep_near(members: list[dict]) -> dict:
270
+ finals = [m for m in members if m.get("version_stage") == "final"]
271
+ pool = finals or [m for m in members if m.get("version_stage") != "draft"] or members
272
+ return max(pool, key=lambda m: (len(m.get("_text", "")), -m.get("depth", 0),
273
+ float(m.get("mtime", 0) or 0), m.get("p", "")))
274
+
275
+
276
+ def _length_evidence(members: list[dict]) -> list[str]:
277
+ lengths = [len(m.get("_text", "")) for m in members if m.get("_text")]
278
+ if len(lengths) > 1 and max(lengths) != min(lengths):
279
+ return [f"正文字符 {min(lengths)}–{max(lengths)}(差 {max(lengths) - min(lengths)})"]
280
+ return []
281
+
282
+
283
+ def _stage_evidence(members: list[dict], keep: dict) -> list[str]:
284
+ evidence = []
285
+ if keep.get("version_stage") == "final":
286
+ evidence.append("文件名/路径含终稿标记")
287
+ if any(m.get("version_stage") == "draft" for m in members):
288
+ evidence.append("组内含底稿/草稿标记,优先保留终稿")
289
+ return evidence
290
+
291
+
292
+ def _build_cluster(cid: str, idxs: list[int], records: list[dict], jac: float | None,
293
+ contain: float | None = None) -> dict:
294
+ members = [records[i] for i in idxs]
295
+ hashes = {m.get("sha256") for m in members if m.get("sha256")}
296
+ ctype = "exact" if len(hashes) == 1 and hashes else "near"
297
+ if ctype == "exact":
298
+ keep = min(members, key=_score_keep)
299
+ folds = [m for m in members if m is not keep]
300
+ rec = {"keep": keep["p"], "fold": [m["p"] for m in folds], "merge": [],
301
+ "evidence": ["内容完全一致(sha256)", "路径更短/更新时间更近"]
302
+ + _stage_evidence(members, keep), "confidence": 1.0}
303
+ else:
304
+ keep = _keep_near(members)
305
+ others = [m for m in members if m is not keep]
306
+ stage_ev = _stage_evidence(members, keep)
307
+ if contain is not None and contain >= CONTAIN_SUPERSET:
308
+ rec = {"keep": keep["p"], "fold": [m["p"] for m in others], "merge": [],
309
+ "evidence": [f"内容为包含关系(覆盖率≈{contain:.2f})", "保留内容更完整的一版"]
310
+ + stage_ev,
311
+ "confidence": round(max(contain, jac or 0), 2)}
312
+ elif jac is not None and jac >= 0.95:
313
+ rec = {"keep": keep["p"], "fold": [m["p"] for m in others], "merge": [],
314
+ "evidence": [f"内容高度重合(Jaccard≈{jac:.2f})"] + stage_ev,
315
+ "confidence": round(jac, 2)}
316
+ else:
317
+ rec = {"keep": keep["p"], "fold": [], "merge": [m["p"] for m in others],
318
+ "evidence": [f"内容部分重合(Jaccard≈{(jac or 0):.2f})", "建议人工合并"]
319
+ + stage_ev,
320
+ "confidence": round(jac or 0.8, 2)}
321
+ rec["evidence"] = rec.get("evidence", []) + _length_evidence(members)
322
+ return {
323
+ "id": cid, "type": ctype,
324
+ "members": [{"p": m["p"], "name": m.get("name", ""), "size": m.get("size", 0),
325
+ "mtime": m.get("mtime", 0),
326
+ "depth": m.get("depth", 0),
327
+ "version_stage": m.get("version_stage", "normal"),
328
+ "version_number": m.get("version_number", "")} for m in members],
329
+ "similarity": round(jac, 3) if jac is not None else 1.0,
330
+ "recommend": rec,
331
+ }
332
+
333
+
334
+ def _self_retrieval(records: list[dict], max_docs: int = 50) -> tuple[float | None, int, str]:
335
+ docs = [r for r in records if r.get("has_text") and len(r.get("title", "")) >= 4
336
+ and r.get("_text") and r.get("title_source") in ("frontmatter", "heading")]
337
+ mode = "title"
338
+ if len(docs) < 5:
339
+ docs = [r for r in records if r.get("has_text") and len(r.get("title", "")) >= 4 and r.get("_text")]
340
+ mode = "fallback"
341
+ if len(docs) < 5:
342
+ return None, 0, mode
343
+ index: dict[int, list[tuple[int, int]]] = defaultdict(list)
344
+ for i, r in enumerate(docs):
345
+ text = "".join((r["_text"] or "")[:30_000].split())
346
+ tf = Counter(zlib.crc32(text[j:j + 2].encode("utf-8")) for j in range(len(text) - 1))
347
+ for bg, n in tf.items():
348
+ index[bg].append((i, n))
349
+ n = len(docs)
350
+ step = max(1, len(docs) // max_docs)
351
+ sample = list(enumerate(docs))[::step][:max_docs]
352
+ hits = 0
353
+ for src_i, src in sample:
354
+ q = "".join(src["title"].split())[:60]
355
+ if len(q) < 2:
356
+ continue
357
+ scores: dict[int, float] = defaultdict(float)
358
+ for j in range(len(q) - 1):
359
+ bg = zlib.crc32(q[j:j + 2].encode("utf-8"))
360
+ post = index.get(bg)
361
+ if not post:
362
+ continue
363
+ idf = math.log(1 + n / len(post))
364
+ for doc_i, tf in post:
365
+ scores[doc_i] += idf * tf
366
+ top = sorted(scores.items(), key=lambda kv: -kv[1])[:5]
367
+ if src_i in [i for i, _ in top]:
368
+ hits += 1
369
+ return (hits / len(sample) if sample else None), len(sample), mode
370
+
371
+
372
+ def analyze(job: Path, pack: str = "generic") -> dict:
373
+ job = Path(job)
374
+ fp = util.read_json(job / "fp.json")
375
+ if not fp:
376
+ raise FileNotFoundError("fp.json not found; run `kba audit start` first")
377
+ records = fp["files"]
378
+ for r in records:
379
+ r["_text"] = ""
380
+
381
+ params = fp.get("params") or {}
382
+ extraction_enabled = bool(params.get("extract_enabled", params.get("extract_dir")))
383
+ if "extract_enabled" not in params and not extraction_enabled:
384
+ # Older fingerprints have no extract params; infer from recorded statuses.
385
+ extraction_enabled = any(r.get("text_status") in ("extracted", "no_text", "failed")
386
+ for r in records)
387
+ for r in records:
388
+ r["_status"] = util.text_status_of(r, extraction_enabled)
389
+
390
+ # attach locally cached text (job/text, never uploaded)
391
+ root_path = (read_state(job).get("root") or "").strip()
392
+ extract_dir = Path(params["extract_dir"]) if params.get("extract_dir") else None
393
+ text_dir = job / "text"
394
+ text_recovered = 0
395
+ for r in records:
396
+ tp = text_dir / (util.safe_name(r["p"]) + ".txt")
397
+ if text_dir.exists() and tp.exists():
398
+ r["_text"] = util.read_text(tp)
399
+ continue
400
+ # Cache expired/absent: read the source once more in memory, never re-cache.
401
+ if not r.get("has_text"):
402
+ continue
403
+ rel = r.get("rel") or ""
404
+ if not rel:
405
+ continue
406
+ try:
407
+ if util.ext_kind(r.get("ext")) == "text" and root_path:
408
+ src = Path(root_path) / rel
409
+ if src.is_file():
410
+ text = util.read_text(src)
411
+ if text.strip():
412
+ r["_text"] = text
413
+ text_recovered += 1
414
+ elif util.ext_kind(r.get("ext")) == "extractable" and extract_dir:
415
+ ep = Path(str(Path(extract_dir) / rel) + ".txt")
416
+ if ep.is_file():
417
+ text = util.read_text(ep)
418
+ if text.strip():
419
+ r["_text"] = text
420
+ text_recovered += 1
421
+ except OSError:
422
+ continue
423
+
424
+ # Retention: analysis uses body text in memory, then expired cache is removed.
425
+ ttl = params.get("text_ttl_days")
426
+ text_cache_purged = fingerprint.purge_text(
427
+ job, older_than_days=int(ttl) if ttl is not None else util.text_ttl_days()
428
+ )
429
+ content_analyzed = sum(1 for r in records if r.get("has_text") and r.get("_text"))
430
+ text_missing = sum(1 for r in records if r.get("has_text") and not r.get("_text"))
431
+
432
+ total = len(records)
433
+ uf = _UF(total)
434
+ exact: dict[str, list[int]] = defaultdict(list)
435
+ for i, r in enumerate(records):
436
+ h = r.get("sha256")
437
+ if h:
438
+ exact[h].append(i)
439
+ for idxs in exact.values():
440
+ for i in idxs[1:]:
441
+ uf.union(idxs[0], i)
442
+
443
+ near = _near_pairs(records)
444
+ for i, j, _, _ in near:
445
+ uf.union(i, j)
446
+
447
+ comps: dict[int, list[int]] = defaultdict(list)
448
+ for i in range(total):
449
+ comps[uf.find(i)].append(i)
450
+
451
+ clusters = []
452
+ cid = 0
453
+ for idxs in comps.values():
454
+ if len(idxs) < 2:
455
+ continue
456
+ cid += 1
457
+ jac = None
458
+ contain = None
459
+ if len({records[i].get("sha256") for i in idxs}) > 1:
460
+ cand = [p for p in near if p[0] in idxs and p[1] in idxs]
461
+ jac = max((p[2] for p in cand), default=0.86)
462
+ contain = max((p[3] for p in cand), default=0.0)
463
+ clusters.append(_build_cluster(f"c{cid:03d}", idxs, records, jac, contain))
464
+
465
+ in_cluster = {m["p"] for c in clusters for m in c["members"]}
466
+ by_name: dict[str, list[int]] = defaultdict(list)
467
+ for i, r in enumerate(records):
468
+ by_name[util.normalize_name(r.get("name", ""))].append(i)
469
+ for name, idxs in by_name.items():
470
+ if len(idxs) < 2:
471
+ continue
472
+ if any(records[i]["p"] in in_cluster for i in idxs):
473
+ continue
474
+ if len({records[i].get("sha256") for i in idxs}) == 1:
475
+ continue
476
+ cid += 1
477
+ clusters.append(_build_cluster(f"c{cid:03d}", idxs, records, None, None))
478
+ clusters[-1]["type"] = "same-name"
479
+ clusters[-1]["similarity"] = 0.0
480
+ clusters[-1]["recommend"] = {
481
+ "keep": "", "fold": [], "merge": [],
482
+ "evidence": ["文件名相同但内容不同(同名异内容)", "建议人工判定,全部保留并标记 dis"],
483
+ "confidence": 0.6,
484
+ }
485
+
486
+ records_by_p = {r["p"]: r for r in records}
487
+ for c in clusters:
488
+ _attach_content_diff(c, records_by_p)
489
+
490
+ redundant = sum(max(0, len(c["members"]) - 1) for c in clusters if c["type"] in ("exact", "near"))
491
+ dup_rate = redundant / total if total else 0.0
492
+ same_name_clusters = [c for c in clusters if c["type"] == "same-name"]
493
+
494
+ final_files = sum(1 for r in records if r.get("version_stage") == "final")
495
+ draft_files = sum(1 for r in records if r.get("version_stage") == "draft")
496
+ version_conflicts = []
497
+ for c in clusters:
498
+ finals = [m["name"] for m in c["members"] if m.get("version_stage") == "final"]
499
+ drafts = [m["name"] for m in c["members"] if m.get("version_stage") == "draft"]
500
+ if finals and drafts:
501
+ keep = next((m["name"] for m in c["members"] if m["p"] == c["recommend"].get("keep")), "")
502
+ version_conflicts.append({"id": c["id"], "keep": keep, "final": finals, "draft": drafts})
503
+
504
+ page_overlaps, page_docs = _page_overlaps(records, clusters)
505
+
506
+ clusters_by_id = {c["id"]: c for c in clusters}
507
+ manual_queue = []
508
+ for c in same_name_clusters:
509
+ manual_queue.append({
510
+ "type": "same-name", "id": c["id"],
511
+ "files": [m["name"] for m in c["members"]],
512
+ "action": "人工判定,全部保留并标记 dis",
513
+ "diff": "",
514
+ })
515
+ for v in version_conflicts:
516
+ c = clusters_by_id.get(v["id"]) or {}
517
+ diff = c.get("content_diff") or {}
518
+ compared = (diff.get("compared") or [{}])[0]
519
+ keep_name = (v.get("keep") or (diff.get("keep") or {}).get("name")
520
+ or (v["final"][0] if v.get("final") else ""))
521
+ note = ""
522
+ if compared and keep_name:
523
+ sim = compared.get("similarity")
524
+ note = (f"{keep_name} vs {compared.get('name', '')}:"
525
+ f"{int(compared.get('delta_chars') or 0):+d} 字"
526
+ + (f",相似度 {sim:.2f}" if isinstance(sim, (int, float)) else ""))
527
+ manual_queue.append({
528
+ "type": "version", "id": v["id"],
529
+ "files": list(v["final"]) + list(v["draft"]),
530
+ "action": "保留终稿;确认正文差异后再处置",
531
+ "diff": note,
532
+ })
533
+ for o in page_overlaps:
534
+ manual_queue.append({
535
+ "type": "page-overlap", "id": "",
536
+ "files": [o["a_name"], o["b_name"]],
537
+ "action": f"人工确认共享页(页 Jaccard {o['page_jaccard']:.2f})",
538
+ "diff": "",
539
+ })
540
+
541
+ pii_counts: Counter = Counter()
542
+ pii_affected = 0
543
+ pii_examples = []
544
+ for r in records:
545
+ hits = pii.scan_text(r.get("_text", ""))
546
+ if not hits:
547
+ continue
548
+ pii_affected += 1
549
+ for kind in hits:
550
+ pii_counts[kind] += 1
551
+ if len(pii_examples) < 50:
552
+ pii_examples.append({"p": r.get("p", ""), "name": r.get("name", ""),
553
+ "types": sorted(hits)})
554
+
555
+ # Excel models are versioned by structure (sheets/formulas/defined names), not text alone.
556
+ xlsx_with_fp = [r for r in records
557
+ if isinstance(r.get("xlsx_fp"), dict) and r["xlsx_fp"].get("sha256")]
558
+ xlsx_groups: dict[str, list[dict]] = defaultdict(list)
559
+ for r in xlsx_with_fp:
560
+ xlsx_groups[r["xlsx_fp"]["sha256"]].append(r)
561
+ xlsx_model_groups = []
562
+ for fp_hash, members in xlsx_groups.items():
563
+ distinct = {m.get("sha256") for m in members if m.get("sha256")}
564
+ if len(members) < 2 or len(distinct) < 2:
565
+ continue
566
+ first_fp = members[0]["xlsx_fp"]
567
+ # Plain value workbooks with identical sheet names are not "model versions".
568
+ if not (first_fp.get("formula_cells") or first_fp.get("defined_names")):
569
+ continue
570
+ xlsx_model_groups.append({
571
+ "fingerprint": fp_hash,
572
+ "sheets": first_fp.get("sheets") or [],
573
+ "formula_cells": int(first_fp.get("formula_cells") or 0),
574
+ "defined_names": first_fp.get("defined_names") or [],
575
+ "versions": len(distinct),
576
+ "members": [{"p": m["p"], "name": m.get("name", ""), "size": m.get("size", 0),
577
+ "mtime": m.get("mtime", 0), "sha256": m.get("sha256", "")}
578
+ for m in members],
579
+ })
580
+ xlsx_model_groups.sort(key=lambda g: (-g["versions"], -len(g["members"]), g["fingerprint"]))
581
+
582
+ stale = [r for r in records if util.mtime_days_ago(r.get("mtime", 0)) > STALE_DAYS]
583
+ stale_rate = len(stale) / total if total else 0.0
584
+ recent = sum(1 for r in records if util.mtime_days_ago(r.get("mtime", 0)) <= 30)
585
+ stale_reliable = not (total and recent / total > 0.9)
586
+
587
+ tagged = [r for r in records if r.get("tags")]
588
+ valid = [r for r in records if len(r.get("tags") or []) >= 3]
589
+ fm_tagged = [r for r in records if r.get("tags_frontmatter")]
590
+ inferred_tagged = [r for r in records if r.get("tags_inferred")]
591
+ tag_coverage = len(tagged) / total if total else 0.0
592
+ valid_tag_coverage = len(valid) / total if total else 0.0
593
+ frontmatter_tag_coverage = len(fm_tagged) / total if total else 0.0
594
+ inferred_tag_coverage = len(inferred_tagged) / total if total else 0.0
595
+ owner_files = sum(1 for r in records if r.get("owner"))
596
+ broken_link_files = sum(1 for r in records if r.get("broken_links"))
597
+ broken_links_total = sum(int(r.get("broken_links") or 0) for r in records)
598
+ text_files = [r for r in records if util.ext_kind(r.get("ext")) == "text"]
599
+ text_content_analyzed = sum(1 for r in text_files if r.get("has_text") and r.get("_text"))
600
+ text_tagged = [r for r in text_files if r.get("tags")]
601
+ text_valid = [r for r in text_files if len(r.get("tags") or []) >= 3]
602
+ text_tag_coverage = len(text_tagged) / len(text_files) if text_files else 0.0
603
+ text_valid_tag_coverage = len(text_valid) / len(text_files) if text_files else 0.0
604
+ text_unreadable = sum(1 for r in text_files
605
+ if not r.get("has_text") and (r.get("size") or 0) > 0)
606
+ dom_c = Counter(r.get("domain") for r in records if r.get("domain"))
607
+
608
+ extractable = [r for r in records if util.ext_kind(r.get("ext")) == "extractable"]
609
+ unsupported_files = [r for r in records if util.ext_kind(r.get("ext")) == "unsupported"]
610
+ binaries = extractable + unsupported_files
611
+ # Coverage must reflect text this run can actually read, not just the fingerprint.
612
+ binary_with_text = [r for r in binaries if r.get("has_text") and r.get("_text")]
613
+ text_coverage = (len(binary_with_text) / len(binaries)) if binaries else None
614
+ confidence = "C" if total == 0 else _confidence(text_coverage)
615
+ # Fingerprint coverage cannot exceed what this run could actually read.
616
+ if content_analyzed == 0 and total:
617
+ confidence = "C"
618
+
619
+ status_counts = Counter(r["_status"] for r in records)
620
+ unsupported_by_ext = Counter(r.get("ext") for r in unsupported_files)
621
+ extract_ok = sum(1 for r in extractable if r["_status"] == "extracted")
622
+ extract_no_text = sum(1 for r in extractable if r["_status"] == "no_text")
623
+ extract_failed = sum(1 for r in extractable if r["_status"] == "failed")
624
+ extract_not_enabled = sum(1 for r in extractable if r["_status"] == "not_enabled")
625
+ extract_attempted = extract_ok + extract_no_text + extract_failed
626
+ extract_fail = round(extract_failed / extract_attempted, 4) if extract_attempted else None
627
+ extract_no_text_rate = round(extract_no_text / extract_attempted, 4) if extract_attempted else None
628
+
629
+ depth_bad = [r for r in records if r.get("depth", 0) > 4]
630
+ root_files = [r for r in records if r.get("depth", 0) == 0]
631
+ naming_bad = [r for r in records if re.search(r"无标题|untitled|^\d{4}[-_]?\d{2}|__dup\d+", r.get("name", ""), re.I)]
632
+ empty_dirs = []
633
+ if root_path and Path(root_path).exists():
634
+ for dirpath, dirnames, filenames in os.walk(root_path):
635
+ if not dirnames and not filenames:
636
+ empty_dirs.append(dirpath)
637
+ structure_files = {r["p"] for r in depth_bad + naming_bad}
638
+ structure_rate = (len(structure_files) + len(root_files)) / total if total else 0.0
639
+ structure_rate = min(1.0, structure_rate)
640
+
641
+ top5_hit, top5_n, top5_mode = _self_retrieval(records)
642
+
643
+ def _clamp(v) -> float:
644
+ return max(0.0, min(1.0, float(v)))
645
+
646
+ health_components: list[dict] = []
647
+
648
+ def _component(key: str, label: str, weight: int, value, badness, available: bool, note: str):
649
+ health_components.append({
650
+ "key": key, "label": label, "weight": weight,
651
+ "available": bool(available),
652
+ "value": round(value, 4) if value is not None else None,
653
+ "badness": round(_clamp(badness), 4) if (available and badness is not None) else None,
654
+ "note": note,
655
+ })
656
+
657
+ _component("duplicate", "重复", 30, dup_rate, dup_rate, True,
658
+ "SHA256 精确分组 + 文本近重复")
659
+ if stale_reliable:
660
+ _component("stale", "过时", 20, stale_rate, stale_rate, True, "24 个月未更新占比")
661
+ else:
662
+ _component("stale", "过时", 20, None, None, False,
663
+ "mtime 可能被复制/迁移重置,不计分")
664
+ if total:
665
+ _component("tagging", "标签", 15, valid_tag_coverage,
666
+ 1 - valid_tag_coverage, True,
667
+ f"frontmatter+路径/内容推断(推断 {inferred_tag_coverage:.0%})")
668
+ else:
669
+ _component("tagging", "标签", 15, None, None, False, "无文件,标签不可评估")
670
+ _component("structure", "结构", 15, structure_rate, structure_rate, True,
671
+ "层级/根目录散文件/命名")
672
+ if top5_hit is not None:
673
+ _component("retrieval", "自检索", 20, top5_hit, 1 - top5_hit, True,
674
+ "标题反查代理指标")
675
+ else:
676
+ _component("retrieval", "自检索", 20, None, None, False,
677
+ "文本样本不足,不以 0.5 顶替")
678
+
679
+ available_components = [c for c in health_components if c["available"]]
680
+ weight_sum = sum(c["weight"] for c in available_components)
681
+ weight_coverage = round(weight_sum / HEALTH_WEIGHT_TOTAL, 4) if HEALTH_WEIGHT_TOTAL else 0.0
682
+ if weight_sum:
683
+ penalty = sum(c["badness"] * c["weight"] for c in available_components) / weight_sum * 100
684
+ health = max(0, min(100, round(100 - penalty)))
685
+ else:
686
+ health = None
687
+ # C-level reports must never publish a composite score; keep the raw value
688
+ # for internal benchmarking only (reports/summaries do not read it).
689
+ published_score = None if confidence == "C" else health
690
+ health_info = {
691
+ "score": published_score,
692
+ "score_raw": health,
693
+ "weight_coverage": weight_coverage,
694
+ "weights": {c["key"]: c["weight"] for c in health_components},
695
+ "components": health_components,
696
+ "excluded": [c["key"] for c in health_components if not c["available"]],
697
+ }
698
+
699
+ data_coverage = {
700
+ "confidence": confidence,
701
+ "confidence_thresholds": {"A": CONFIDENCE_A, "B": CONFIDENCE_B},
702
+ "confidence_reason": ("empty" if total == 0
703
+ else "no_content" if content_analyzed == 0
704
+ else "text_coverage"),
705
+ "empty_corpus": total == 0,
706
+ "text_coverage": round(text_coverage, 4) if text_coverage is not None else None,
707
+ "extraction_enabled": extraction_enabled,
708
+ "binary_files": len(binaries),
709
+ "binary_with_text": len(binary_with_text),
710
+ "content_analyzed": content_analyzed,
711
+ "text_missing": text_missing,
712
+ "text_files": len(text_files),
713
+ "text_content_analyzed": text_content_analyzed,
714
+ "text_unreadable": text_unreadable,
715
+ "extractable_files": len(extractable),
716
+ "extracted": extract_ok,
717
+ "no_text": extract_no_text,
718
+ "failed": extract_failed,
719
+ "not_enabled": extract_not_enabled,
720
+ "extract_attempted": extract_attempted,
721
+ "unsupported": len(unsupported_files),
722
+ "unsupported_by_ext": dict(unsupported_by_ext.most_common()),
723
+ "text_status": dict(status_counts),
724
+ "text_ttl_days": params.get("text_ttl_days", util.text_ttl_days()),
725
+ "text_cache_purged": text_cache_purged,
726
+ "text_recovered": text_recovered,
727
+ }
728
+
729
+ findings = []
730
+ if redundant:
731
+ findings.append({"type": "duplicate", "count": redundant, "severity": "high",
732
+ "title": f"{len([c for c in clusters if c['type'] in ('exact','near')])} 组重复/近重复,冗余 {redundant} 个",
733
+ "examples": [c["members"][0]["name"] for c in clusters[:5]]})
734
+ if same_name_clusters:
735
+ findings.append({"type": "same-name", "count": len(same_name_clusters), "severity": "medium",
736
+ "title": f"{len(same_name_clusters)} 组同名异内容,需人工判定(标记 dis,不自动处置)",
737
+ "examples": [c["members"][0]["name"] for c in same_name_clusters[:5]]})
738
+ if stale:
739
+ findings.append({"type": "stale", "count": len(stale), "severity": "medium",
740
+ "title": f"{len(stale)} 个文件超过 24 个月未更新",
741
+ "examples": [r["name"] for r in stale[:5]]})
742
+ if total and valid_tag_coverage < 0.9:
743
+ findings.append({"type": "tagging", "count": total - len(valid), "severity": "medium",
744
+ "title": f"有效标签覆盖率 {valid_tag_coverage:.0%}(frontmatter {frontmatter_tag_coverage:.0%},推断 {inferred_tag_coverage:.0%})",
745
+ "examples": [r["name"] for r in records if len(r.get("tags") or []) < 3][:5]})
746
+ if broken_link_files:
747
+ findings.append({"type": "broken-link", "count": broken_links_total, "severity": "medium",
748
+ "title": f"{broken_link_files} 个文件有 {broken_links_total} 个断链(本地相对链接目标不存在)",
749
+ "examples": [r["name"] for r in records if r.get("broken_links")][:5]})
750
+ if text_unreadable:
751
+ findings.append({"type": "text-unreadable", "count": text_unreadable, "severity": "medium",
752
+ "title": f"{text_unreadable} 个文本文件无法解码/读取,未参与内容分析",
753
+ "examples": [r["name"] for r in text_files
754
+ if not r.get("has_text") and (r.get("size") or 0) > 0][:5]})
755
+ if text_missing:
756
+ findings.append({"type": "text-missing", "count": text_missing, "severity": "medium",
757
+ "title": f"{text_missing} 个文件的正文在分析时不可读(缓存过期且源文件/抽取结果不可用)",
758
+ "examples": [r["name"] for r in records
759
+ if r.get("has_text") and not r.get("_text")][:5]})
760
+ if extract_failed:
761
+ findings.append({"type": "extract", "count": extract_failed, "severity": "medium",
762
+ "title": f"{extract_failed} 个 pdf/docx 抽取失败(已尝试 {extract_attempted} 个)",
763
+ "examples": [r["name"] for r in extractable if r["_status"] == "failed"][:5]})
764
+ if extract_no_text:
765
+ findings.append({"type": "extract-no-text", "count": extract_no_text, "severity": "low",
766
+ "title": f"{extract_no_text} 个 pdf/docx 无可用文本(扫描件候选,需 OCR)",
767
+ "examples": [r["name"] for r in extractable if r["_status"] == "no_text"][:5]})
768
+ if unsupported_files:
769
+ exts = "、".join(ext for ext, _ in unsupported_by_ext.most_common())
770
+ findings.append({"type": "unsupported", "count": len(unsupported_files), "severity": "medium",
771
+ "title": f"{len(unsupported_files)} 个未支持格式({exts})仅计入盘点,未做内容分析",
772
+ "examples": [r["name"] for r in unsupported_files][:5]})
773
+ if xlsx_model_groups:
774
+ members = sum(len(g["members"]) for g in xlsx_model_groups)
775
+ findings.append({"type": "xlsx-model", "count": len(xlsx_model_groups), "severity": "medium",
776
+ "title": f"{len(xlsx_model_groups)} 组 Excel 同结构不同内容(模型版本候选,共 {members} 个文件)",
777
+ "examples": [g["members"][0]["name"] for g in xlsx_model_groups[:5]]})
778
+ if version_conflicts:
779
+ findings.append({"type": "version", "count": len(version_conflicts), "severity": "medium",
780
+ "title": f"{len(version_conflicts)} 组重复/近重复同时含终稿与底稿,建议保留终稿并人工确认",
781
+ "examples": [v["keep"] for v in version_conflicts[:5]]})
782
+ if page_overlaps:
783
+ findings.append({"type": "page-overlap", "count": len(page_overlaps), "severity": "medium",
784
+ "title": f"{len(page_overlaps)} 对文档存在页级高重合(可能共享页/转载),建议人工确认,不自动合并",
785
+ "examples": [f"{o['a_name']} ↔ {o['b_name']}" for o in page_overlaps[:3]]})
786
+ if pii_affected:
787
+ kinds = "、".join(pii.LABELS.get(k, k) for k in sorted(pii_counts))
788
+ findings.append({"type": "pii", "count": pii_affected, "severity": "high",
789
+ "title": f"{pii_affected} 个文件命中敏感信息候选({kinds});报告仅显示计数,建议人工核查",
790
+ "examples": [p["name"] for p in pii_examples[:5]]})
791
+ if structure_files or root_files or empty_dirs:
792
+ findings.append({"type": "structure", "count": len(structure_files) + len(root_files) + len(empty_dirs), "severity": "low",
793
+ "title": f"结构问题:{len(depth_bad)} 个层级过深、{len(root_files)} 个根目录散文件、{len(naming_bad)} 个命名问题、{len(empty_dirs)} 个空目录",
794
+ "examples": [r["name"] for r in (depth_bad + root_files)[:5]]})
795
+ if top5_hit is not None and top5_hit < 0.85:
796
+ findings.append({"type": "retrieval", "count": top5_n, "severity": "high",
797
+ "title": f"自检索 Top-5 命中率 {top5_hit:.0%}({top5_n} 个查询抽样)",
798
+ "examples": []})
799
+ if confidence == "C":
800
+ if content_analyzed == 0 and total:
801
+ coverage_title = "数据置信度 C:本次分析没有可读取的正文,本报告为元数据盘点版,不展示综合健康分"
802
+ else:
803
+ cov_pct = f"{text_coverage:.0%}" if text_coverage is not None else "—"
804
+ coverage_title = f"数据置信度 C:二进制文本覆盖率 {cov_pct},本报告为元数据盘点版,不展示综合健康分"
805
+ findings.insert(0, {
806
+ "type": "coverage", "count": len(binaries) - len(binary_with_text), "severity": "high",
807
+ "title": coverage_title,
808
+ "examples": [],
809
+ })
810
+
811
+ effort = min(60.0, round(
812
+ redundant * 0.05 + len([c for c in clusters if c["type"] == "near"]) * 0.1
813
+ + len(stale) * 0.005 + (total - len(tagged)) * 0.002
814
+ + (len(structure_files) + len(root_files)) * 0.01, 1)) or 1.0
815
+
816
+ plan = {
817
+ "effort_days": effort,
818
+ "weeks": [
819
+ {"week": 1, "tasks": ["确认分类标准与裁决规则", "处理完全重复(折叠,不删除)"], "owner": "客户+顾问"},
820
+ {"week": 2, "tasks": ["处理近重复与同名异内容", "修正目录层级与命名"], "owner": "客户"},
821
+ {"week": 3, "tasks": ["补齐标签与负责人", "处理扫描件/抽取失败"], "owner": "客户"},
822
+ {"week": 4, "tasks": ["重建索引", "抽检检索命中率", "出验收报告"], "owner": "顾问"},
823
+ ],
824
+ }
825
+
826
+ result = {
827
+ "schema": "kba.analysis/1",
828
+ "job_id": fp.get("job_id"),
829
+ "pack": pack,
830
+ "confidence": confidence,
831
+ "health_score": published_score,
832
+ "health": health_info,
833
+ "data_coverage": data_coverage,
834
+ "metrics": {
835
+ "files": total,
836
+ "dup_groups": len([c for c in clusters if c["type"] in ("exact", "near")]),
837
+ "dup_redundant": redundant,
838
+ "dup_rate": round(dup_rate, 4),
839
+ "same_name_groups": len(same_name_clusters),
840
+ "same_name_members": sum(len(c["members"]) for c in same_name_clusters),
841
+ "final_files": final_files,
842
+ "draft_files": draft_files,
843
+ "version_conflicts": len(version_conflicts),
844
+ "manual_queue": len(manual_queue),
845
+ "page_overlap_pairs": len(page_overlaps),
846
+ "page_overlap_files": len({o["a"] for o in page_overlaps} | {o["b"] for o in page_overlaps}),
847
+ "page_aware_docs": page_docs,
848
+ "pii_files": pii_affected,
849
+ "pii_types": len(pii_counts),
850
+ "stale_rate": round(stale_rate, 4),
851
+ "stale_reliable": stale_reliable,
852
+ "tag_coverage": round(tag_coverage, 4),
853
+ "valid_tag_coverage": round(valid_tag_coverage, 4),
854
+ "frontmatter_tag_coverage": round(frontmatter_tag_coverage, 4),
855
+ "inferred_tag_coverage": round(inferred_tag_coverage, 4),
856
+ "text_tag_coverage": round(text_tag_coverage, 4),
857
+ "text_valid_tag_coverage": round(text_valid_tag_coverage, 4),
858
+ "owner_coverage": round(owner_files / total, 4) if total else 0.0,
859
+ "broken_link_files": broken_link_files,
860
+ "broken_links": broken_links_total,
861
+ "by_domain": dict(dom_c.most_common(20)),
862
+ "confidence": confidence,
863
+ "text_coverage": round(text_coverage, 4) if text_coverage is not None else None,
864
+ "binary_files": len(binaries),
865
+ "text_files": len(text_files),
866
+ "text_unreadable": text_unreadable,
867
+ "content_analyzed": content_analyzed,
868
+ "text_missing": text_missing,
869
+ "text_content_analyzed": text_content_analyzed,
870
+ "extractable_files": len(extractable),
871
+ "unsupported_files": len(unsupported_files),
872
+ "unsupported_by_ext": dict(unsupported_by_ext.most_common()),
873
+ "xlsx_with_fp": len(xlsx_with_fp),
874
+ "xlsx_model_groups": len(xlsx_model_groups),
875
+ "xlsx_model_members": sum(len(g["members"]) for g in xlsx_model_groups),
876
+ "extract_status": {
877
+ "extracted": extract_ok,
878
+ "no_text": extract_no_text,
879
+ "failed": extract_failed,
880
+ "not_enabled": extract_not_enabled,
881
+ "unsupported": len(unsupported_files),
882
+ },
883
+ "extract_fail": extract_fail,
884
+ "extract_no_text": extract_no_text_rate,
885
+ "weight_coverage": weight_coverage,
886
+ "top5_hit": round(top5_hit, 4) if top5_hit is not None else None,
887
+ "top5_samples": top5_n,
888
+ "top5_mode": top5_mode,
889
+ "structure_rate": round(structure_rate, 4),
890
+ },
891
+ "clusters": clusters,
892
+ "xlsx_model_groups": xlsx_model_groups,
893
+ "version_conflicts": version_conflicts,
894
+ "manual_queue": manual_queue,
895
+ "page_overlaps": page_overlaps,
896
+ "pii": {
897
+ "files": pii_affected,
898
+ "types": dict(sorted(pii_counts.items())),
899
+ "examples": pii_examples,
900
+ },
901
+ "findings": findings,
902
+ "plan": plan,
903
+ "meta": {"created_at": util.now_iso(), "engine": "kba-analyze/1"},
904
+ }
905
+ util.write_json(job / "analysis.json", result)
906
+ write_state(job, status="done", stage="analyze", health_score=published_score,
907
+ confidence=confidence, weight_coverage=weight_coverage)
908
+ return result