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/report.py
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""HTML / Markdown report rendering (no external template engine)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import html
|
|
5
|
+
from collections import Counter, defaultdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import pii, util
|
|
9
|
+
|
|
10
|
+
CSS = """
|
|
11
|
+
:root{--ink:#1f2328;--muted:#57606a;--line:#d0d7de;--bg:#ffffff;--soft:#f6f8fa;--accent:#0b6bcb;--warn:#bf8700;--bad:#cf222e;--good:#1a7f37}
|
|
12
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.65 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif}
|
|
13
|
+
.wrap{max-width:1040px;margin:0 auto;padding:40px 28px 64px}
|
|
14
|
+
header{border-bottom:3px solid var(--ink);padding-bottom:18px;margin-bottom:28px}
|
|
15
|
+
h1{font-size:30px;margin:0 0 6px}h2{font-size:21px;margin:38px 0 12px;border-left:4px solid var(--accent);padding-left:10px}
|
|
16
|
+
h3{font-size:16px;margin:22px 0 8px}.meta{color:var(--muted);font-size:13px}
|
|
17
|
+
.score{display:flex;align-items:center;gap:22px;background:var(--soft);border:1px solid var(--line);border-radius:12px;padding:22px;margin:18px 0}
|
|
18
|
+
.score .num{font-size:56px;font-weight:800;line-height:1}.score .num small{font-size:18px;color:var(--muted);font-weight:500}
|
|
19
|
+
.score .txt{flex:1}.score .txt b{font-size:17px}
|
|
20
|
+
table{width:100%;border-collapse:collapse;margin:10px 0 18px;font-size:13.5px}
|
|
21
|
+
th,td{border:1px solid var(--line);padding:7px 9px;text-align:left;vertical-align:top}
|
|
22
|
+
th{background:var(--soft);font-weight:600}tr:nth-child(even) td{background:#fcfdff}
|
|
23
|
+
.tag{display:inline-block;padding:1px 8px;border-radius:999px;font-size:12px;border:1px solid var(--line);margin-right:4px}
|
|
24
|
+
.high{color:var(--bad);font-weight:600}.medium{color:var(--warn);font-weight:600}.low{color:var(--muted)}
|
|
25
|
+
.callout{border:1px solid var(--line);border-left:4px solid var(--accent);background:var(--soft);padding:14px 16px;border-radius:8px;margin:16px 0}
|
|
26
|
+
.muted{color:var(--muted)}.small{font-size:12.5px}
|
|
27
|
+
footer{margin-top:48px;border-top:1px solid var(--line);padding-top:18px;color:var(--muted);font-size:13px}
|
|
28
|
+
.cta{border:2px dashed var(--accent);border-radius:12px;padding:20px;margin:26px 0;background:#f5f9ff}
|
|
29
|
+
.cta b{font-size:17px}code{background:var(--soft);padding:1px 5px;border-radius:4px}
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
STATUS_LABEL = {
|
|
33
|
+
"text": "已读正文",
|
|
34
|
+
"extracted": "已抽取",
|
|
35
|
+
"no_text": "无文本",
|
|
36
|
+
"failed": "抽取失败",
|
|
37
|
+
"not_enabled": "未启用抽取",
|
|
38
|
+
"unsupported": "未支持",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _e(v) -> str:
|
|
43
|
+
return html.escape(str(v if v is not None else ""))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _pct(v) -> str:
|
|
47
|
+
return "—" if v is None else f"{v * 100:.1f}%"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _bytes(n) -> str:
|
|
51
|
+
return util.human_bytes(float(n or 0))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _table(headers, rows) -> str:
|
|
55
|
+
th = "".join(f"<th>{_e(h)}</th>" for h in headers)
|
|
56
|
+
trs = []
|
|
57
|
+
for row in rows:
|
|
58
|
+
trs.append("<tr>" + "".join(f"<td>{c}</td>" for c in row) + "</tr>")
|
|
59
|
+
return f"<table><thead><tr>{th}</tr></thead><tbody>{''.join(trs)}</tbody></table>"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _coverage(analysis: dict) -> dict:
|
|
63
|
+
dc = analysis.get("data_coverage")
|
|
64
|
+
if isinstance(dc, dict) and dc:
|
|
65
|
+
return dc
|
|
66
|
+
# Analysis produced before P0 has no coverage evidence: report the honest worst case.
|
|
67
|
+
return {"confidence": "C", "text_coverage": None, "legacy": True}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _confidence_text(dc: dict) -> str:
|
|
71
|
+
grade = dc.get("confidence") or "C"
|
|
72
|
+
cov = dc.get("text_coverage")
|
|
73
|
+
if dc.get("legacy"):
|
|
74
|
+
return "C(旧版分析缺少覆盖数据,按 C 级处理)"
|
|
75
|
+
if dc.get("empty_corpus"):
|
|
76
|
+
return "C(语料为空,无可分析文件)"
|
|
77
|
+
if grade == "C" and dc.get("content_analyzed") == 0:
|
|
78
|
+
return "C(本次分析没有可读取的正文)"
|
|
79
|
+
if cov is None:
|
|
80
|
+
return f"{grade}(无可抽取二进制文件,纯文本语料)"
|
|
81
|
+
if grade == "A":
|
|
82
|
+
return f"A(高:文本覆盖率 {_pct(cov)})"
|
|
83
|
+
if grade == "B":
|
|
84
|
+
return f"B(中:文本覆盖率 {_pct(cov)})"
|
|
85
|
+
return f"C(低:文本覆盖率 {_pct(cov)})"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _coverage_summary(dc: dict) -> str:
|
|
89
|
+
if dc.get("legacy"):
|
|
90
|
+
return "旧版分析缺少覆盖数据,按 C 级处理"
|
|
91
|
+
if dc.get("empty_corpus"):
|
|
92
|
+
return "语料为空,无可分析文件"
|
|
93
|
+
if dc.get("content_analyzed") == 0:
|
|
94
|
+
return "本次分析没有可读取的正文"
|
|
95
|
+
if dc.get("text_coverage") is None:
|
|
96
|
+
return "无可抽取二进制文件"
|
|
97
|
+
return f"文本覆盖率 {_pct(dc.get('text_coverage'))}(低于 60%)"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _weight_coverage(analysis: dict) -> float | None:
|
|
101
|
+
health = analysis.get("health") or {}
|
|
102
|
+
return health.get("weight_coverage")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def render_html(analysis: dict, fp: dict, brand: dict | None = None) -> str:
|
|
106
|
+
brand = brand or {}
|
|
107
|
+
m = analysis["metrics"]
|
|
108
|
+
dc = _coverage(analysis)
|
|
109
|
+
grade = dc.get("confidence") or "C"
|
|
110
|
+
text_coverage = dc.get("text_coverage")
|
|
111
|
+
is_metadata = grade == "C"
|
|
112
|
+
health = analysis.get("health") or {}
|
|
113
|
+
weight_coverage = _weight_coverage(analysis)
|
|
114
|
+
findings = analysis.get("findings", [])
|
|
115
|
+
clusters = analysis.get("clusters", [])
|
|
116
|
+
plan = analysis.get("plan", {})
|
|
117
|
+
files = fp.get("summary", {})
|
|
118
|
+
root_name = fp.get("root", {}).get("name", "(root)")
|
|
119
|
+
report_title = "知识库体检报告(元数据盘点版)" if is_metadata else "知识库体检报告"
|
|
120
|
+
|
|
121
|
+
top3 = findings[:3]
|
|
122
|
+
conclusion = ";".join(f["title"] for f in top3[:2]) if top3 else "未发现明显问题"
|
|
123
|
+
|
|
124
|
+
extraction_enabled = bool(dc.get("extraction_enabled"))
|
|
125
|
+
status_by_ext: dict[str, Counter] = defaultdict(Counter)
|
|
126
|
+
for r in fp.get("files", []):
|
|
127
|
+
status_by_ext[r.get("ext", "")][util.text_status_of(r, extraction_enabled)] += 1
|
|
128
|
+
|
|
129
|
+
total_files = files.get("files") or 0
|
|
130
|
+
comp_rows = []
|
|
131
|
+
for ext, n in (files.get("by_ext") or {}).items():
|
|
132
|
+
kind = util.ext_kind(ext)
|
|
133
|
+
counts = status_by_ext.get(ext, Counter())
|
|
134
|
+
if kind == "text":
|
|
135
|
+
text_ok = n - counts.get("failed", 0) - counts.get("no_text", 0)
|
|
136
|
+
what = f"已读正文 {text_ok}/{n}"
|
|
137
|
+
elif kind == "extractable":
|
|
138
|
+
if not extraction_enabled:
|
|
139
|
+
what = "未抽取(元数据模式)"
|
|
140
|
+
else:
|
|
141
|
+
parts = [f"{STATUS_LABEL[s]} {counts[s]}" for s in ("extracted", "no_text", "failed")
|
|
142
|
+
if counts.get(s)]
|
|
143
|
+
what = "|".join(parts) or "未抽取"
|
|
144
|
+
elif kind == "unsupported":
|
|
145
|
+
what = "未支持(仅计数)"
|
|
146
|
+
else:
|
|
147
|
+
what = "—"
|
|
148
|
+
comp_rows.append([_e(ext), _e(n), _pct(n / total_files if total_files else 0), _e(what)])
|
|
149
|
+
|
|
150
|
+
find_rows = []
|
|
151
|
+
for f in findings:
|
|
152
|
+
find_rows.append([
|
|
153
|
+
f"<span class='{_e(f.get('severity','low'))}'>{_e(f.get('severity',''))}</span>",
|
|
154
|
+
_e(f.get("title", "")), _e(f.get("count", "")),
|
|
155
|
+
_e("、".join(f.get("examples", [])[:3])),
|
|
156
|
+
])
|
|
157
|
+
|
|
158
|
+
dup_rows = []
|
|
159
|
+
for c in clusters[:30]:
|
|
160
|
+
rec = c.get("recommend", {})
|
|
161
|
+
keep = next((x["name"] for x in c["members"] if x["p"] == rec.get("keep")), "—")
|
|
162
|
+
folds = rec.get("fold") or []
|
|
163
|
+
dup_rows.append([
|
|
164
|
+
_e(c["id"]), _e(c["type"]), _e(len(c["members"])),
|
|
165
|
+
_e(f"{c.get('similarity', 0):.2f}"), _e(keep),
|
|
166
|
+
_e(f"{len(folds)} 个" if folds else ("人工合并" if rec.get("merge") else "全部保留")),
|
|
167
|
+
_e(";".join(rec.get("evidence", []))),
|
|
168
|
+
_e(rec.get("confidence", "")),
|
|
169
|
+
])
|
|
170
|
+
|
|
171
|
+
week_rows = [[_e(w.get("week")), _e("、".join(w.get("tasks", []))), _e(w.get("owner", ""))]
|
|
172
|
+
for w in plan.get("weeks", [])]
|
|
173
|
+
|
|
174
|
+
version_conflicts = analysis.get("version_conflicts", []) or []
|
|
175
|
+
version_rows = []
|
|
176
|
+
for v in version_conflicts[:20]:
|
|
177
|
+
version_rows.append([
|
|
178
|
+
_e(v.get("id")), _e(v.get("keep")),
|
|
179
|
+
_e("、".join(v.get("final") or [])),
|
|
180
|
+
_e("、".join(v.get("draft") or [])),
|
|
181
|
+
])
|
|
182
|
+
if version_conflicts:
|
|
183
|
+
version_html = (
|
|
184
|
+
"<h3>版本信号(终稿/底稿)</h3>"
|
|
185
|
+
f"<p>终稿 {_e(m.get('final_files'))} 个,底稿/草稿 {_e(m.get('draft_files'))} 个;"
|
|
186
|
+
f"<b>{len(version_conflicts)}</b> 组重复/近重复同时包含两者,建议保留终稿并人工确认。</p>"
|
|
187
|
+
+ _table(["组", "建议保留", "终稿", "底稿/草稿"], version_rows)
|
|
188
|
+
)
|
|
189
|
+
elif m.get("final_files") or m.get("draft_files"):
|
|
190
|
+
version_html = (
|
|
191
|
+
"<h3>版本信号(终稿/底稿)</h3>"
|
|
192
|
+
f"<p class='small muted'>终稿 {_e(m.get('final_files'))} 个,底稿/草稿 "
|
|
193
|
+
f"{_e(m.get('draft_files'))} 个;未发现同一组内同时存在终稿与底稿。</p>"
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
version_html = ""
|
|
197
|
+
|
|
198
|
+
page_overlaps = analysis.get("page_overlaps", []) or []
|
|
199
|
+
page_rows = []
|
|
200
|
+
for o in page_overlaps[:20]:
|
|
201
|
+
page_rows.append([
|
|
202
|
+
_e(o.get("a_name")), _e(o.get("a_page")),
|
|
203
|
+
_e(o.get("b_name")), _e(o.get("b_page")),
|
|
204
|
+
_e(f"{o.get('page_jaccard', 0):.2f}"),
|
|
205
|
+
_e(f"{o.get('pages_a')}/{o.get('pages_b')}"),
|
|
206
|
+
])
|
|
207
|
+
page_html = (
|
|
208
|
+
"<h3>页级重合(跨文档)</h3>"
|
|
209
|
+
f"<p>检测到 <b>{len(page_overlaps)}</b> 对文档存在页级高重合;"
|
|
210
|
+
"可能共享页、转载或模板,建议人工确认,<b>不自动合并</b>。</p>"
|
|
211
|
+
+ _table(["文件 A", "页", "文件 B", "页", "页 Jaccard", "总页数 A/B"], page_rows)
|
|
212
|
+
+ "<p class='small muted'>页级重合只表示单页高度相似,不代表整份文档重复;不进入重复簇的折叠建议。</p>"
|
|
213
|
+
) if page_overlaps else ""
|
|
214
|
+
|
|
215
|
+
pii_data = analysis.get("pii") or {}
|
|
216
|
+
pii_type_examples: dict[str, list[str]] = {}
|
|
217
|
+
for ex in pii_data.get("examples", []):
|
|
218
|
+
for kind in ex.get("types", []):
|
|
219
|
+
pii_type_examples.setdefault(kind, []).append(ex.get("name", ""))
|
|
220
|
+
pii_rows = []
|
|
221
|
+
for kind, count in (pii_data.get("types") or {}).items():
|
|
222
|
+
pii_rows.append([
|
|
223
|
+
_e(pii.LABELS.get(kind, kind)), _e(count),
|
|
224
|
+
_e("、".join(pii_type_examples.get(kind, [])[:3])),
|
|
225
|
+
])
|
|
226
|
+
pii_html = (
|
|
227
|
+
"<h3>敏感信息候选(PII,仅计数)</h3>"
|
|
228
|
+
f"<p>检测到 <b>{_e(pii_data.get('files', 0))}</b> 个文件命中常见敏感信息模式;"
|
|
229
|
+
"报告只显示类型与文件数,不展示原文。</p>"
|
|
230
|
+
+ _table(["类型", "命中文件数", "示例文件"], pii_rows)
|
|
231
|
+
+ "<p class='small muted'>这是正则初筛(邮箱/手机号/身份证/银行卡/密钥/合同编号),只覆盖已读取正文的文件,"
|
|
232
|
+
"可能有误报/漏报;不替代合规审查,也不输出匹配值。</p>"
|
|
233
|
+
) if pii_rows else ""
|
|
234
|
+
|
|
235
|
+
queue_labels = {"same-name": "同名异内容", "version": "终稿/底稿",
|
|
236
|
+
"page-overlap": "页级重合"}
|
|
237
|
+
queue_rows = []
|
|
238
|
+
for q in (analysis.get("manual_queue") or [])[:20]:
|
|
239
|
+
queue_rows.append([
|
|
240
|
+
_e(queue_labels.get(q.get("type"), q.get("type"))),
|
|
241
|
+
_e(q.get("id") or "—"),
|
|
242
|
+
_e("、".join((q.get("files") or [])[:4])),
|
|
243
|
+
_e(q.get("action", "")),
|
|
244
|
+
_e(q.get("diff", "")),
|
|
245
|
+
])
|
|
246
|
+
queue_html = (
|
|
247
|
+
"<h3>人工裁决队列</h3>"
|
|
248
|
+
f"<p>共 <b>{len(analysis.get('manual_queue') or [])}</b> 项需要人工确认;"
|
|
249
|
+
"本工具只给证据与建议,不修改文件。</p>"
|
|
250
|
+
+ _table(["类型", "组", "文件", "建议", "版本差异"], queue_rows)
|
|
251
|
+
) if queue_rows else ""
|
|
252
|
+
|
|
253
|
+
xlsx_groups = analysis.get("xlsx_model_groups", []) or []
|
|
254
|
+
xlsx_rows = []
|
|
255
|
+
for g in xlsx_groups[:20]:
|
|
256
|
+
xlsx_rows.append([
|
|
257
|
+
_e(str(g.get("fingerprint", ""))[:12]),
|
|
258
|
+
_e(len(g.get("members", []))),
|
|
259
|
+
_e(g.get("versions", 0)),
|
|
260
|
+
_e("、".join(g.get("sheets") or []) or "—"),
|
|
261
|
+
_e(g.get("formula_cells", 0)),
|
|
262
|
+
_e("、".join(mm.get("name", "") for mm in (g.get("members") or [])[:5])),
|
|
263
|
+
])
|
|
264
|
+
xlsx_files = (files.get("by_ext") or {}).get(".xlsx", 0)
|
|
265
|
+
if xlsx_groups:
|
|
266
|
+
xlsx_html = (
|
|
267
|
+
"<h3>Excel 同结构模型(版本候选)</h3>"
|
|
268
|
+
f"<p>检测到 <b>{len(xlsx_groups)}</b> 组 Excel 同结构不同内容,共 "
|
|
269
|
+
f"<b>{sum(len(g.get('members', [])) for g in xlsx_groups)}</b> 个文件;"
|
|
270
|
+
"通常代表同一财务/分析模型的多个版本,建议按修改时间与内容 diff 人工确认,不自动合并。</p>"
|
|
271
|
+
+ _table(["结构指纹", "文件数", "内容版本", "Sheet", "公式单元格", "成员"], xlsx_rows)
|
|
272
|
+
+ "<p class='small muted'>结构指纹 = sheet 名/尺寸 + 公式模式(单元格引用、数字、字符串归一化)+ named range 的哈希;"
|
|
273
|
+
"它与文件内容 SHA256 不同,用于识别“同一模型不同版本”。</p>"
|
|
274
|
+
)
|
|
275
|
+
elif m.get("xlsx_with_fp"):
|
|
276
|
+
xlsx_html = (
|
|
277
|
+
"<h3>Excel 同结构模型(版本候选)</h3>"
|
|
278
|
+
f"<p class='small muted'>已计算 {_e(m.get('xlsx_with_fp'))} 个 .xlsx 结构指纹,"
|
|
279
|
+
"未发现同结构不同内容的版本候选。</p>"
|
|
280
|
+
)
|
|
281
|
+
elif xlsx_files:
|
|
282
|
+
xlsx_html = (
|
|
283
|
+
"<h3>Excel 同结构模型(版本候选)</h3>"
|
|
284
|
+
f"<p class='small muted'>扫描到 {_e(xlsx_files)} 个 .xlsx,但未计算结构指纹"
|
|
285
|
+
"(未运行 <code>kba extract</code> 或缺少 openpyxl),Excel 版本关系不在本报告结论内。</p>"
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
xlsx_html = ""
|
|
289
|
+
|
|
290
|
+
# ---- section 2: coverage table and scope ----
|
|
291
|
+
cov_rows = []
|
|
292
|
+
text_n = dc.get("text_files", 0) or 0
|
|
293
|
+
if text_n:
|
|
294
|
+
text_ok = dc.get("text_content_analyzed",
|
|
295
|
+
max(0, text_n - (dc.get("text_unreadable", 0) or 0)))
|
|
296
|
+
cov_rows.append([
|
|
297
|
+
"纯文本(.md/.txt/.rst/.org)", _e(text_n),
|
|
298
|
+
f"已读正文 {text_ok}/{text_n}",
|
|
299
|
+
"近重复、标签、自检索结论在该范围内成立",
|
|
300
|
+
])
|
|
301
|
+
ext_n = dc.get("extractable_files", 0) or 0
|
|
302
|
+
if ext_n:
|
|
303
|
+
extract_note = (
|
|
304
|
+
f"已抽取 {dc.get('extracted', 0)} | 无文本 {dc.get('no_text', 0)} | "
|
|
305
|
+
f"失败 {dc.get('failed', 0)} | 未启用 {dc.get('not_enabled', 0)}"
|
|
306
|
+
)
|
|
307
|
+
cov_rows.append([
|
|
308
|
+
"PDF/Word/PPT/Excel(可抽取)", _e(ext_n), _e(extract_note),
|
|
309
|
+
"仅已抽取部分支持内容级结论;未抽取/无文本/失败部分只做元数据",
|
|
310
|
+
])
|
|
311
|
+
uns_n = dc.get("unsupported", 0) or 0
|
|
312
|
+
if uns_n:
|
|
313
|
+
exts = "、".join((dc.get("unsupported_by_ext") or {}).keys())
|
|
314
|
+
cov_rows.append([
|
|
315
|
+
"未支持格式(旧版 Office)", _e(uns_n),
|
|
316
|
+
_e(f"未支持(仅计数){(':' + exts) if exts else ''}"),
|
|
317
|
+
"不计入内容分析;其近重复、版本、标签、检索结论不成立(精确重复仍按 SHA256 计入)",
|
|
318
|
+
])
|
|
319
|
+
text_unreadable_n = dc.get("text_unreadable", 0) or 0
|
|
320
|
+
text_missing_n = dc.get("text_missing", 0) or 0
|
|
321
|
+
analyzed_n = dc.get("content_analyzed")
|
|
322
|
+
if analyzed_n is None: # analysis produced before P0
|
|
323
|
+
analyzed_n = max(0, text_n - text_unreadable_n) + (dc.get("extracted", 0) or 0)
|
|
324
|
+
unanalyzed_n = max(0, total_files - analyzed_n)
|
|
325
|
+
|
|
326
|
+
weight_rows = []
|
|
327
|
+
for c in health.get("components", []):
|
|
328
|
+
available = bool(c.get("available"))
|
|
329
|
+
value = c.get("value")
|
|
330
|
+
weight_rows.append([
|
|
331
|
+
_e(c.get("label")), _e(f"{c.get('weight')}%"),
|
|
332
|
+
"有数据" if available else "无数据",
|
|
333
|
+
_pct(value) if (available and value is not None) else "—",
|
|
334
|
+
_e(c.get("note", "")),
|
|
335
|
+
])
|
|
336
|
+
|
|
337
|
+
missing_html = (
|
|
338
|
+
f"<p class='small muted'>另有 {text_missing_n} 个指纹记录显示有正文,但本次分析无法读取"
|
|
339
|
+
"(缓存过期且源文件/抽取结果不可用),未计入内容分析。</p>"
|
|
340
|
+
) if text_missing_n else ""
|
|
341
|
+
extract_hint_html = (
|
|
342
|
+
"<p class='small muted'>当前未启用正文抽取:先运行 <code>kba extract --root ... --out ...</code>,"
|
|
343
|
+
"再用 <code>kba audit start --extract-dir ...</code> 重扫,才能覆盖 PDF/Word/PPT/Excel 的内容级结论。</p>"
|
|
344
|
+
) if (not extraction_enabled and ext_n) else ""
|
|
345
|
+
|
|
346
|
+
fail_rows = []
|
|
347
|
+
broken_rows = []
|
|
348
|
+
for r in fp.get("files", []):
|
|
349
|
+
if r.get("text_status") == "failed":
|
|
350
|
+
fail_rows.append([_e(r.get("name", "")), _e(str(r.get("text_error") or "")[:160])])
|
|
351
|
+
if r.get("broken_links"):
|
|
352
|
+
broken_rows.append([
|
|
353
|
+
_e(r.get("name", "")),
|
|
354
|
+
_e("、".join((r.get("broken_examples") or [])[:3])),
|
|
355
|
+
])
|
|
356
|
+
fail_html = _table(["文件(失败示例)", "原因"], fail_rows[:10]) if fail_rows else ""
|
|
357
|
+
broken_html = _table(["文件(断链示例)", "失效目标"], broken_rows[:10]) if broken_rows else ""
|
|
358
|
+
|
|
359
|
+
if m.get("stale_reliable", True):
|
|
360
|
+
stale_html = (f"<p>超过 24 个月未更新:<b>{_e(_pct(m.get('stale_rate')))}</b> 的文件。"
|
|
361
|
+
"建议按“最近使用/最近更新”优先保留。</p>")
|
|
362
|
+
else:
|
|
363
|
+
stale_html = ("<p>时效指标不可用:超过 90% 的文件修改时间在近 30 天内,"
|
|
364
|
+
"无法可靠判断 24 个月未更新比例(本项不参与健康分)。</p>")
|
|
365
|
+
|
|
366
|
+
# ---- score block: C-level never shows a composite score ----
|
|
367
|
+
if is_metadata:
|
|
368
|
+
score_html = f"""<div class="score">
|
|
369
|
+
<div class="num">—<small>不适用</small></div>
|
|
370
|
+
<div class="txt"><b>元数据盘点版(数据置信度 C)</b>
|
|
371
|
+
<div class="muted small">{_e(_coverage_summary(dc))}。覆盖率不足时不展示综合健康分,避免用元数据结论冒充完整审阅;分项覆盖见第 2 节。</div></div>
|
|
372
|
+
</div>"""
|
|
373
|
+
else:
|
|
374
|
+
score_html = f"""<div class="score">
|
|
375
|
+
<div class="num">{_e(health.get('score') if health.get('score') is not None else analysis.get('health_score'))}<small>/100</small></div>
|
|
376
|
+
<div class="txt"><b>结论:{_e(conclusion)}</b>
|
|
377
|
+
<div class="muted small">归一化健康分:仅对有权重的指标计分,权重覆盖度 {_pct(weight_coverage)}。权重:重复 30% / 过时 20% / 标签 15% / 结构 15% / 自检索 20%;缺失指标不参与计分(不以 0 或 0.5 顶替)。</div></div>
|
|
378
|
+
</div>"""
|
|
379
|
+
|
|
380
|
+
cta_text = brand.get("cta_text") or "需要治理建议?在 WorkBuddy 内继续提问,或购买完整报告。"
|
|
381
|
+
contact = brand.get("contact") or ""
|
|
382
|
+
contact_html = f"<div class='muted'>{_e(contact)}</div>" if contact else ""
|
|
383
|
+
|
|
384
|
+
if is_metadata:
|
|
385
|
+
confidence_note = (
|
|
386
|
+
f"数据置信度 C:{_coverage_summary(dc)},本报告只做元数据盘点,不提供综合健康分。"
|
|
387
|
+
)
|
|
388
|
+
else:
|
|
389
|
+
confidence_note = (
|
|
390
|
+
f"数据置信度 {grade}:文本覆盖率 {_pct(text_coverage)};健康分权重覆盖度 "
|
|
391
|
+
f"{_pct(weight_coverage)}。"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
return f"""<!doctype html>
|
|
395
|
+
<html lang="zh-CN"><head><meta charset="utf-8">
|
|
396
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
397
|
+
<title>{_e(report_title)} · {_e(root_name)}</title><style>{CSS}</style></head>
|
|
398
|
+
<body><div class="wrap">
|
|
399
|
+
<header>
|
|
400
|
+
<h1>{_e(report_title)}</h1>
|
|
401
|
+
<div class="meta">对象:{_e(root_name)} | 生成:{_e(analysis.get('meta',{}).get('created_at',''))} | 工具:KBA {_e(__import__('kba').__version__)} | 任务:{_e(fp.get('job_id',''))} | 数据置信度:{_e(grade)}</div>
|
|
402
|
+
</header>
|
|
403
|
+
|
|
404
|
+
{score_html}
|
|
405
|
+
|
|
406
|
+
<h2>1. 执行摘要</h2>
|
|
407
|
+
{_table(["严重度", "问题", "数量", "示例"], find_rows) if find_rows else "<p class='muted'>未发现明显问题。</p>"}
|
|
408
|
+
<p class="small muted">扫描 {_e(m.get('files'))} 个文件(其中未支持格式 {_e(uns_n)} 个),可读正文 {_e(files.get('with_text'))} 个;{confidence_note} 数据仅在本机处理,未上传任何正文。</p>
|
|
409
|
+
|
|
410
|
+
<h2>2. 数据覆盖与结论适用范围</h2>
|
|
411
|
+
<p>数据置信度 <b>{_e(_confidence_text(dc))}</b>。分级口径:pdf/docx/Office 等二进制文件中“有可检索文本”的比例,A ≥90%,B 60%–90%,C <60%;C 级只出元数据盘点版,不展示综合健康分。</p>
|
|
412
|
+
{_table(["数据类别", "文件数", "内容分析状态", "对结论的影响"], cov_rows) if cov_rows else "<p class='muted'>没有可分析文件。</p>"}
|
|
413
|
+
{missing_html}
|
|
414
|
+
{extract_hint_html}
|
|
415
|
+
<h3>结论适用范围</h3>
|
|
416
|
+
<ul class="small">
|
|
417
|
+
<li><b>成立:</b>文件数量与类型构成、精确重复(SHA256)、同名异内容、目录结构与命名、未支持格式计数。</li>
|
|
418
|
+
<li><b>仅在 {_e(analyzed_n)} 个已读取正文的文件上成立:</b>近重复、标签覆盖、自检索命中率,以及重复簇的“保留/合并”建议。</li>
|
|
419
|
+
<li><b>不成立:</b>{_e(unanalyzed_n)} 个未做内容分析的文件(文本无法解码、正文不可读、PDF/Word 未抽取/无文本/抽取失败,以及未支持的 Office/旧版格式)的内容级结论;扫描件内容;Excel 公式/数字版本差异。</li>
|
|
420
|
+
<li><b>健康分:</b>{'C 级不展示综合分,只看第 2 节分项覆盖与第 4 节重复证据。' if is_metadata else f'按可用指标归一化,权重覆盖度 {_pct(weight_coverage)};跨语料比较前须确认权重覆盖度一致。'}</li>
|
|
421
|
+
</ul>
|
|
422
|
+
<h3>健康分权重覆盖度</h3>
|
|
423
|
+
{_table(["指标", "权重", "是否有数据", "取值", "说明"], weight_rows) if weight_rows else "<p class='muted'>无评分数据。</p>"}
|
|
424
|
+
<p class="small muted">缺失指标不参与计分;权重覆盖度 {_pct(weight_coverage)}。本报告不解锁未覆盖指标的结论,也不把缺失值当作 0 分或 0.5 分。</p>
|
|
425
|
+
|
|
426
|
+
<h2>3. 语料构成</h2>
|
|
427
|
+
{_table(["类型", "文件数", "占比", "内容分析"], comp_rows)}
|
|
428
|
+
<p class="small muted">总大小 {_e(_bytes(files.get('bytes')))} | 最大目录深度 {_e(files.get('max_depth'))} | 读取失败 {_e(files.get('errors'))} 个 | 未支持格式 {_e(uns_n)} 个。未支持格式只计数,不代表没有问题。</p>
|
|
429
|
+
|
|
430
|
+
<h2>4. 重复与版本(含裁决建议)</h2>
|
|
431
|
+
<p>检测到 <b>{_e(m.get('dup_groups'))}</b> 组重复/近重复,冗余文件 <b>{_e(m.get('dup_redundant'))}</b> 个,重复率 <b>{_e(_pct(m.get('dup_rate')))}</b>;另有 <b>{_e(m.get('same_name_groups'))}</b> 组同名异内容(共 {_e(m.get('same_name_members'))} 个文件,建议人工判定)。</p>
|
|
432
|
+
{_table(["组", "类型", "成员", "相似度", "建议保留", "处置", "依据", "置信度"], dup_rows) if dup_rows else "<p class='muted'>未发现重复。</p>"}
|
|
433
|
+
<p class="small muted">类型说明:exact = 内容哈希一致;near = 文本相似度 ≥ 0.85;same-name = 同名但内容不同(标 dis,全部保留)。处置仅为建议,本工具不修改任何文件。近重复只在已读取正文的文件之间计算;{('元数据模式下 pdf/docx/Office 未参与近重复比对(精确重复仍按 SHA256 计入)。' if is_metadata else '未支持格式不参与近重复比对(精确重复仍按 SHA256 计入)。')}</p>
|
|
434
|
+
{version_html}
|
|
435
|
+
{page_html}
|
|
436
|
+
{xlsx_html}
|
|
437
|
+
{queue_html}
|
|
438
|
+
|
|
439
|
+
<h2>5. 时效、无主与断链</h2>
|
|
440
|
+
{stale_html}
|
|
441
|
+
<p>负责人覆盖(frontmatter owner/负责人):<b>{_e(_pct(m.get('owner_coverage')))}</b>;
|
|
442
|
+
断链 <b>{_e(m.get('broken_links'))}</b> 个,涉及 <b>{_e(m.get('broken_link_files'))}</b> 个文件。</p>
|
|
443
|
+
{broken_html}
|
|
444
|
+
<p class="small muted">断链只检查 Markdown 本地相对链接,不检查外链(http/https/mailto)与仓库外目标;owner 只读取 frontmatter,未接 ACL/文档系统。</p>
|
|
445
|
+
|
|
446
|
+
<h2>6. 结构健康</h2>
|
|
447
|
+
<p>结构问题率 <b>{_e(_pct(m.get('structure_rate')))}</b>。建议把层级压到 4 层以内,根目录只保留入口文件。</p>
|
|
448
|
+
|
|
449
|
+
<h2>7. 标签、分类与敏感信息</h2>
|
|
450
|
+
<p>全量记录有效标签(≥3 个)覆盖 <b>{_e(_pct(m.get('valid_tag_coverage')))}</b>;其中 frontmatter 覆盖 <b>{_e(_pct(m.get('frontmatter_tag_coverage')))}</b>,路径/文件名/内容推断覆盖 <b>{_e(_pct(m.get('inferred_tag_coverage')))}</b>。</p>
|
|
451
|
+
<p>文本文件:有效标签 <b>{_e(_pct(m.get('text_valid_tag_coverage')))}</b>,有标签 <b>{_e(_pct(m.get('text_tag_coverage')))}</b>(基于 {_e(m.get('text_files'))} 个文本文件)。</p>
|
|
452
|
+
<p class="small muted">说明:PDF/Word/PPT/Excel 的标签来自路径/文件名/内容关键词推断,可能不如 frontmatter 精确;健康分按全量有效标签覆盖计分。未抽取/未支持文件仍可能无标签。</p>
|
|
453
|
+
<p class="small">领域分布(有 domain 字段的文档):{_e('、'.join(f'{k} {v}' for k, v in list((m.get('by_domain') or {}).items())[:6]) or '—')}</p>
|
|
454
|
+
{pii_html}
|
|
455
|
+
|
|
456
|
+
<h2>8. 可检索性</h2>
|
|
457
|
+
<p>自检索 Top-5 命中率 <b>{_e(_pct(m.get('top5_hit')))}</b>({_e(m.get('top5_samples'))} 个查询抽样,代理指标)。</p>
|
|
458
|
+
<p>PDF/Word/PPT/Excel 正文抽取:可抽取 <b>{_e(ext_n)}</b> 个,已抽取 {_e(dc.get('extracted', 0))},无文本 {_e(dc.get('no_text', 0))}(扫描件候选),抽取失败 {_e(dc.get('failed', 0))},未启用 {_e(dc.get('not_enabled', 0))};未支持旧版 Office {_e(uns_n)} 个。</p>
|
|
459
|
+
<p class="small muted">说明:命中率用“标题反查自身文档”做代理,不是真实用户查询;{_e('本报告仅统计标题来自 frontmatter/正文标题的文档。' if m.get('top5_mode') == 'title' else '标题来源不足,已回退为文件名标题,结果仅供参考。')}{' 未启用正文抽取时,本项只反映纯文本文件。' if not extraction_enabled else ''}</p>
|
|
460
|
+
{fail_html}
|
|
461
|
+
|
|
462
|
+
<h2>9. 工作量与 30 天计划</h2>
|
|
463
|
+
<p>预计治理工作量 <b>{_e(plan.get('effort_days'))} 人日</b>(不含客户确认与沟通时间)。</p>
|
|
464
|
+
{_table(["周", "任务", "责任人"], week_rows)}
|
|
465
|
+
|
|
466
|
+
<div class="cta">
|
|
467
|
+
<b>{_e(cta_text)}</b>
|
|
468
|
+
{contact_html}
|
|
469
|
+
<div class="small muted">报告编号:{_e(fp.get('job_id'))} | 本报告由 KBA 在本机生成,未上传任何文件内容;文件名仅用于内部定位。数据置信度:{_e(grade)}。</div>
|
|
470
|
+
</div>
|
|
471
|
+
|
|
472
|
+
<h2>10. 方法说明</h2>
|
|
473
|
+
<ul class="small">
|
|
474
|
+
<li>指纹:路径/大小/SHA256/修改时间/文本长度/标签,不含正文。</li>
|
|
475
|
+
<li>数据覆盖:统计 pdf/docx/Office 等二进制文件中有可检索文本的比例,分级 A/B/C;C 级不出综合健康分。</li>
|
|
476
|
+
<li>重复:SHA256 精确分组;字符 5-gram 稀疏索引召回候选,再算 Jaccard(≥0.85 判近重复)。仅对已读取正文的文件生效。</li>
|
|
477
|
+
<li>裁决建议:优先路径更短、更新时间更近、命名更规范;同名异内容一律保留并标记。</li>
|
|
478
|
+
<li>健康分:固定权重(重复 30/过时 20/标签 15/结构 15/自检索 20),只对有权重的指标归一化;缺失指标不顶替,报告权重覆盖度。</li>
|
|
479
|
+
<li>格式支持:PDF/Word/PPT/Excel 可用 <code>kba extract</code> 抽取正文;旧版 .ppt/.xls/.doc 只计入盘点,不做内容分析,不参与近重复、标签、检索与版本裁决。</li>
|
|
480
|
+
<li>隐私:正文只在本机;job/text 缓存按 {_e(dc.get('text_ttl_days', util.text_ttl_days()))} 天 TTL 在分析/下次扫描时清理,可用 <code>kba clean --purge-text</code> 立即清理;报告与日志不含正文。{'本次分析已清理 ' + str(dc.get('text_cache_purged')) + ' 个过期缓存文件。' if dc.get('text_cache_purged') else ''}{' 本次分析从源文件只读重读了 ' + str(dc.get('text_recovered')) + ' 个缺失的正文缓存(未重新落盘)。' if dc.get('text_recovered') else ''}</li>
|
|
481
|
+
<li>本报告是 L1 元数据盘点(含部分文本内容分析),不是 L2 内容审阅;不构成法律或合规意见。</li>
|
|
482
|
+
</ul>
|
|
483
|
+
|
|
484
|
+
<h2>11. 附录</h2>
|
|
485
|
+
<p class="small muted">任务 ID:{_e(fp.get('job_id'))} | 指纹文件数:{_e(files.get('files'))} | 数据置信度:{_e(grade)} | 文本覆盖率:{_e(_pct(text_coverage))} | 权重覆盖度:{_e(_pct(weight_coverage))} | 生成时间:{_e(analysis.get('meta',{}).get('created_at',''))}<br>
|
|
486
|
+
本报告不构成法律或合规意见;涉及个人信息与重要数据时,请按客户合规要求处理。</p>
|
|
487
|
+
|
|
488
|
+
<footer>KBA · 只读知识库体检 · 数据不出本机 · 报告编号 {_e(fp.get('job_id'))}{(' · ' + _e(contact)) if contact else ''}</footer>
|
|
489
|
+
</div></body></html>"""
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def render_share_md(analysis: dict, fp: dict) -> str:
|
|
493
|
+
m = analysis["metrics"]
|
|
494
|
+
dc = _coverage(analysis)
|
|
495
|
+
grade = dc.get("confidence") or "C"
|
|
496
|
+
is_metadata = grade == "C"
|
|
497
|
+
weight_coverage = _weight_coverage(analysis)
|
|
498
|
+
health_score = (analysis.get("health") or {}).get("score")
|
|
499
|
+
if health_score is None:
|
|
500
|
+
health_score = analysis.get("health_score")
|
|
501
|
+
coverage_text = ("无可读正文" if dc.get("content_analyzed") == 0
|
|
502
|
+
and not dc.get("empty_corpus")
|
|
503
|
+
else f"文本覆盖率 {_pct(dc.get('text_coverage'))}")
|
|
504
|
+
lines = [
|
|
505
|
+
f"# 知识库体检摘要({fp.get('root', {}).get('name', '')})",
|
|
506
|
+
"",
|
|
507
|
+
f"- 数据置信度:**{grade}**({coverage_text})"
|
|
508
|
+
+ ("|**元数据盘点版,不展示综合健康分**" if is_metadata else ""),
|
|
509
|
+
]
|
|
510
|
+
if is_metadata:
|
|
511
|
+
lines.append("- 健康分:不适用(C 级覆盖,元数据盘点版)")
|
|
512
|
+
else:
|
|
513
|
+
lines.append(f"- 健康分:**{health_score}/100**(权重覆盖度 {_pct(weight_coverage)},仅对可用指标归一化)")
|
|
514
|
+
lines += [
|
|
515
|
+
f"- 覆盖:可读正文 {dc.get('text_files', 0)} 个纯文本;"
|
|
516
|
+
f"pdf/docx 已抽取 {dc.get('extracted', 0)}、无文本 {dc.get('no_text', 0)}、"
|
|
517
|
+
f"失败 {dc.get('failed', 0)}、未启用 {dc.get('not_enabled', 0)};"
|
|
518
|
+
f"未支持 Office/旧版 {dc.get('unsupported', 0)} 个",
|
|
519
|
+
f"- 重复:{m.get('dup_groups')} 组,冗余 {m.get('dup_redundant')} 个({_pct(m.get('dup_rate'))})",
|
|
520
|
+
*([f"- 版本信号:终稿 {m.get('final_files')} 个,底稿 {m.get('draft_files')} 个,"
|
|
521
|
+
f"{m.get('version_conflicts')} 组冲突"]
|
|
522
|
+
if (m.get("final_files") or m.get("draft_files")) else []),
|
|
523
|
+
*([f"- 页级重合:{m.get('page_overlap_pairs')} 对文档(不自动合并)"]
|
|
524
|
+
if m.get("page_overlap_pairs") else []),
|
|
525
|
+
*([f"- 人工裁决:{m.get('manual_queue')} 项(同名/版本/页级)"]
|
|
526
|
+
if m.get("manual_queue") else []),
|
|
527
|
+
*([f"- 敏感信息:{m.get('pii_files')} 个文件命中常见 PII 模式(仅计数)"]
|
|
528
|
+
if m.get("pii_files") else []),
|
|
529
|
+
*([f"- Excel 同结构模型:{m.get('xlsx_model_groups')} 组,"
|
|
530
|
+
f"{m.get('xlsx_model_members')} 个文件(版本候选)"]
|
|
531
|
+
if m.get("xlsx_model_groups") else []),
|
|
532
|
+
(f"- 过时:{_pct(m.get('stale_rate'))} 超过 24 个月未更新"
|
|
533
|
+
if m.get("stale_reliable", True) else "- 过时:mtime 不可靠,本项未计分"),
|
|
534
|
+
f"- 标签:有效覆盖 {_pct(m.get('valid_tag_coverage'))}"
|
|
535
|
+
f"(frontmatter {_pct(m.get('frontmatter_tag_coverage'))},推断 {_pct(m.get('inferred_tag_coverage'))})",
|
|
536
|
+
f"- 负责人覆盖:{_pct(m.get('owner_coverage'))};断链 {m.get('broken_links')} 个"
|
|
537
|
+
f"({m.get('broken_link_files')} 个文件)",
|
|
538
|
+
f"- 检索:自检索 Top-5 命中 {_pct(m.get('top5_hit'))}(代理指标)",
|
|
539
|
+
f"- 工作量:约 {analysis.get('plan', {}).get('effort_days')} 人日",
|
|
540
|
+
"",
|
|
541
|
+
"## 三个最严重的问题",
|
|
542
|
+
]
|
|
543
|
+
for f in analysis.get("findings", [])[:3]:
|
|
544
|
+
lines.append(f"- {f.get('title')}")
|
|
545
|
+
lines += [
|
|
546
|
+
"",
|
|
547
|
+
"> L1 元数据盘点(含部分文本内容分析),不是 L2 内容审阅;数据仅在本机处理。",
|
|
548
|
+
"> 完整报告与治理方案请联系。",
|
|
549
|
+
]
|
|
550
|
+
return "\n".join(lines) + "\n"
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def write_report(job: Path, out: Path, brand: dict | None = None, fmt: str = "html") -> Path:
|
|
554
|
+
job = Path(job)
|
|
555
|
+
fp = util.read_json(job / "fp.json") or {}
|
|
556
|
+
analysis = util.read_json(job / "analysis.json") or {}
|
|
557
|
+
if not analysis:
|
|
558
|
+
raise FileNotFoundError("analysis.json not found; run `kba audit analyze` first")
|
|
559
|
+
out = Path(out)
|
|
560
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
561
|
+
if fmt == "md":
|
|
562
|
+
out.write_text(render_share_md(analysis, fp), encoding="utf-8")
|
|
563
|
+
else:
|
|
564
|
+
out.write_text(render_html(analysis, fp, brand), encoding="utf-8")
|
|
565
|
+
return out
|
kba/summary.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Build a sanitized audit summary for local connector / paid expert handoff."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from . import util
|
|
5
|
+
|
|
6
|
+
SCHEMA = "kba.summary/1"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_summary(fp: dict, analysis: dict) -> dict:
|
|
10
|
+
metrics = analysis.get("metrics") or {}
|
|
11
|
+
summary = {
|
|
12
|
+
"schema": SCHEMA,
|
|
13
|
+
"generated_at": util.now_iso(),
|
|
14
|
+
"root": {"name": (fp.get("root") or {}).get("name", "")},
|
|
15
|
+
"scan": {
|
|
16
|
+
"files": (fp.get("summary") or {}).get("files", 0),
|
|
17
|
+
"bytes": (fp.get("summary") or {}).get("bytes", 0),
|
|
18
|
+
},
|
|
19
|
+
"confidence": analysis.get("confidence"),
|
|
20
|
+
"health_score": analysis.get("health_score"),
|
|
21
|
+
"metrics": {
|
|
22
|
+
key: metrics.get(key) for key in (
|
|
23
|
+
"text_coverage", "valid_tag_coverage", "inferred_tag_coverage",
|
|
24
|
+
"dup_groups", "dup_rate", "stale_rate", "owner_coverage",
|
|
25
|
+
"broken_links", "broken_link_files", "pii_files",
|
|
26
|
+
"page_overlap_pairs", "xlsx_model_groups", "manual_queue",
|
|
27
|
+
"top5_hit", "extract_ok", "extract_no_text", "extract_failed",
|
|
28
|
+
"extract_not_enabled", "unsupported_files",
|
|
29
|
+
)
|
|
30
|
+
},
|
|
31
|
+
"findings": [
|
|
32
|
+
{"type": f.get("type"), "count": f.get("count"),
|
|
33
|
+
"severity": f.get("severity"), "title": f.get("title")}
|
|
34
|
+
for f in analysis.get("findings") or []
|
|
35
|
+
],
|
|
36
|
+
"duplicate_groups": [
|
|
37
|
+
{
|
|
38
|
+
"id": c.get("id"), "type": c.get("type"),
|
|
39
|
+
"similarity": c.get("similarity"),
|
|
40
|
+
"members": [m.get("name") for m in c.get("members", [])],
|
|
41
|
+
"recommend": c.get("recommend"),
|
|
42
|
+
"evidence": c.get("evidence"),
|
|
43
|
+
"content_diff": c.get("content_diff"),
|
|
44
|
+
}
|
|
45
|
+
for c in analysis.get("clusters") or []
|
|
46
|
+
],
|
|
47
|
+
"version_conflicts": analysis.get("version_conflicts") or [],
|
|
48
|
+
"page_overlaps": analysis.get("page_overlaps") or [],
|
|
49
|
+
"manual_queue": analysis.get("manual_queue") or [],
|
|
50
|
+
"pii": {
|
|
51
|
+
"files": (analysis.get("pii") or {}).get("files", 0),
|
|
52
|
+
"types": (analysis.get("pii") or {}).get("types", {}),
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
return summary
|