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/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
|