feat: integrate govdoc platform updates

This commit is contained in:
wren
2026-05-18 14:35:25 +08:00
parent a73826dc1d
commit 1bacfe41b7
10 changed files with 2151 additions and 92 deletions
@@ -1,76 +1,594 @@
"""把 AuditResult 渲染成单文件 HTML 报告。"""
from __future__ import annotations
from collections import Counter
from html import escape
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.result import AuditResult
_CSS = """
body { font-family: -apple-system, "PingFang SC", sans-serif; margin: 0; padding: 24px;
background: #f7f7f9; color: #1a1a1a; }
.header { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.score { width: 96px; height: 96px; border-radius: 50%;
background: conic-gradient(#22c55e var(--p), #e5e7eb var(--p));
display: grid; place-items: center; font-weight: 700; font-size: 22px; color: #111; }
.score-inner { background: white; width: 76px; height: 76px; border-radius: 50%;
display: grid; place-items: center; }
.tag { padding: 2px 8px; border-radius: 999px; font-size: 12px; }
.error { background: #fee2e2; color: #b91c1c; }
.warning { background: #fef9c3; color: #a16207; }
.info { background: #dbeafe; color: #1d4ed8; }
table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px;
overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
th { background: #f8fafc; font-size: 13px; }
td.msg { max-width: 480px; }
.context { color: #64748b; font-size: 12px; margin-top: 4px; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #f3f6f5;
color: #0f172a;
}
a { color: inherit; }
.page {
width: 100%;
padding: 20px 24px 32px;
}
.stack {
display: flex;
flex-direction: column;
gap: 20px;
}
.card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
overflow: hidden;
}
.card-head {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 20px;
border-bottom: 1px solid #e2e8f0;
background: #fcfdfd;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
.card-subtitle {
font-size: 12px;
color: #64748b;
}
.summary-grid {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 20px;
padding: 20px;
}
.score-box {
border: 1px solid #cfe4dc;
background: #f7fbf9;
border-radius: 10px;
padding: 20px;
}
.score-label {
font-size: 12px;
font-weight: 500;
color: #475569;
}
.score-value {
margin-top: 12px;
font-size: 42px;
line-height: 1;
font-weight: 600;
letter-spacing: -0.05em;
color: #0f172a;
}
.score-track {
margin-top: 16px;
height: 8px;
background: #dbe8e3;
border-radius: 999px;
overflow: hidden;
}
.score-fill {
height: 100%;
background: #00684a;
}
.score-note {
margin-top: 16px;
font-size: 12px;
line-height: 1.75;
color: #475569;
}
.summary-main {
min-width: 0;
}
.eyebrow {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 12px;
border: 1px solid #cfe4dc;
border-radius: 6px;
background: #e8f3ef;
color: #00684a;
font-size: 12px;
font-weight: 500;
}
.report-title {
margin: 12px 0 0;
font-size: 32px;
line-height: 1.25;
letter-spacing: -0.03em;
font-weight: 600;
color: #0f172a;
}
.report-meta {
margin-top: 8px;
font-size: 15px;
color: #475569;
}
.metrics {
margin-top: 20px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.metric {
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fcfdfd;
padding: 16px 20px;
}
.metric-label {
font-size: 13px;
font-weight: 500;
color: #64748b;
}
.metric-value {
margin-top: 12px;
display: flex;
align-items: baseline;
gap: 8px;
}
.metric-value strong {
font-size: 30px;
line-height: 1;
letter-spacing: -0.04em;
font-weight: 600;
color: #0f172a;
}
.metric-value span {
font-size: 13px;
color: #64748b;
}
.chips {
margin-top: 20px;
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.chip,
.severity-tag {
display: inline-flex;
align-items: center;
border: 1px solid transparent;
border-radius: 6px;
font-weight: 600;
}
.chip {
height: 32px;
padding: 0 12px;
font-size: 12px;
}
.severity-tag {
height: 32px;
padding: 0 12px;
font-size: 12px;
text-transform: uppercase;
}
.error {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.warning {
border-color: #fde68a;
background: #fffbeb;
color: #b45309;
}
.info {
border-color: #bfdbfe;
background: #eff6ff;
color: #1d4ed8;
}
.content-grid {
display: grid;
grid-template-columns: 340px minmax(0, 1fr);
gap: 20px;
}
.sidebar-body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.summary-row {
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fcfdfd;
padding: 16px;
}
.summary-row-label {
font-size: 12px;
font-weight: 500;
color: #64748b;
}
.summary-row-value {
margin-top: 8px;
font-size: 22px;
line-height: 1;
letter-spacing: -0.03em;
font-weight: 600;
color: #0f172a;
}
.summary-row-desc {
margin-top: 12px;
font-size: 13px;
line-height: 1.75;
color: #475569;
}
.table-toolbar {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 20px;
border-bottom: 1px solid #e2e8f0;
background: #fcfdfd;
}
.toolbar-left {
min-width: 0;
}
.toolbar-title {
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
.toolbar-desc {
margin-top: 2px;
font-size: 12px;
color: #64748b;
}
.toolbar-filters {
display: flex;
gap: 8px;
}
.filter {
display: inline-flex;
align-items: center;
height: 32px;
padding: 0 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #ffffff;
color: #64748b;
font-size: 12px;
font-weight: 500;
}
.filter.active {
border-color: rgba(0, 104, 74, 0.2);
background: #e8f3ef;
color: #00684a;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
min-width: 1320px;
border-collapse: collapse;
}
thead tr {
background: #f8fafc;
color: #475569;
font-size: 13px;
font-weight: 500;
}
th {
padding: 16px 20px;
text-align: left;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
td {
padding: 20px;
vertical-align: top;
border-bottom: 1px solid #f1f5f9;
}
tbody tr:hover {
background: #f8fafc;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.id-cell {
font-size: 13px;
color: #64748b;
}
.rule-id {
font-size: 15px;
font-weight: 600;
color: #1e293b;
}
.rule-name {
margin-top: 4px;
font-size: 13px;
color: #64748b;
}
.category-cell {
font-size: 14px;
color: #334155;
}
.location-cell {
font-size: 13px;
color: #334155;
}
.message-cell {
min-width: 560px;
}
.message-main {
font-size: 15px;
line-height: 1.8;
color: #0f172a;
}
.context-box,
.suggestion-box {
margin-top: 12px;
border-radius: 6px;
padding: 12px 16px;
font-size: 13px;
line-height: 1.8;
}
.context-box {
border: 1px solid #e2e8f0;
background: #f8fafc;
color: #475569;
}
.suggestion-box {
border: 1px solid #cfe4dc;
background: #f4faf7;
color: #0d6b4d;
}
.empty {
padding: 24px 20px;
text-align: center;
color: #64748b;
font-size: 14px;
}
@media (max-width: 1200px) {
.summary-grid,
.content-grid {
grid-template-columns: 1fr;
}
.metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.page {
padding: 16px;
}
.metrics {
grid-template-columns: 1fr;
}
.table-toolbar,
.card-head {
height: auto;
min-height: 48px;
padding-top: 12px;
padding-bottom: 12px;
align-items: flex-start;
flex-direction: column;
}
}
"""
def render_html(result: AuditResult) -> str:
s = result.summary
score = s.score
pct = f"{score}%"
rows = []
for f in result.findings:
loc = f.location
suggest = (
f'<div style="color:#0369a1">建议: {escape(f.suggestion)}</div>'
if f.suggestion else ""
)
rows.append(f"""
<tr>
<td>{escape(f.finding_id)}</td>
<td>{escape(f.rule_id)}<br><span style="color:#64748b;font-size:12px">{escape(f.rule_name)}</span></td>
<td><span class="tag {f.severity}">{f.severity}</span></td>
<td>{escape(f.category)}</td>
<td>P{loc.paragraph_index} ({escape(loc.role or '')})</td>
<td class="msg">{escape(f.message)}
<div class="context">原文: {escape((loc.context or '')[:80])}</div>
{suggest}
</td>
</tr>""")
summary = result.summary
score = int(summary.score or 0)
score_pct = max(0, min(score, 100))
severity_counts = _severity_counts(result)
category_count = len([key for key, value in (summary.by_category or {}).items() if key and value])
filename = escape(str(result.document.get("filename", "")))
top_rule_id, top_rule_count = _top_rule(result)
line_range = _line_range(result)
entity_summary = _entity_summary(result)
body = f"""<!doctype html>
<html lang="zh"><head><meta charset="utf-8"><title>公文审核报告</title>
<style>{_CSS}</style></head><body>
<div class="header">
<div class="score" style="--p:{pct}"><div class="score-inner">{score}</div></div>
<div>
<h1 style="margin:0">公文格式审核报告</h1>
<div style="color:#64748b">{escape(result.document.get('filename', ''))} · 共 {s.total_findings} 项</div>
<div style="margin-top:6px">
<span class="tag error">错误 {s.by_severity.get('error', 0)}</span>
<span class="tag warning">警告 {s.by_severity.get('warning', 0)}</span>
<span class="tag info">提示 {s.by_severity.get('info', 0)}</span>
rows = []
for finding in result.findings:
location_label = _format_location(finding.location.paragraph_index)
context = escape((finding.location.context or "").strip())
message = escape(finding.message)
suggestion = escape(finding.suggestion) if finding.suggestion else "按规则要求修正对应内容。"
rows.append(
f"""
<tr>
<td class="mono id-cell">{escape(finding.finding_id)}</td>
<td>
<div class="rule-id">{escape(finding.rule_id)}</div>
<div class="rule-name">{escape(finding.rule_name)}</div>
</td>
<td><span class="severity-tag {escape(finding.severity)}">{escape(finding.severity)}</span></td>
<td class="category-cell">{escape(finding.category)}</td>
<td class="mono location-cell">{location_label}</td>
<td class="message-cell">
<div class="message-main">{message}</div>
<div class="context-box">原文:{context or "未提取到上下文"}</div>
<div class="suggestion-box">建议:{suggestion}</div>
</td>
</tr>"""
)
return f"""<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>公文审核报告</title>
<style>{_CSS}</style>
</head>
<body>
<div class="page">
<div class="stack">
<section class="card">
<div class="card-head">
<div class="card-title">报告摘要</div>
<div class="card-subtitle">不改报告语义,仅收敛样式、配色与信息层级</div>
</div>
<div class="summary-grid">
<div class="score-box">
<div class="score-label">综合得分</div>
<div class="score-value">{score}</div>
<div class="score-track"><div class="score-fill" style="width:{score_pct}%"></div></div>
<div class="score-note">这份正式 HTML 报告沿用平台工作台的版式语言,突出摘要、明细和建议三层信息。</div>
</div>
<div class="summary-main">
<div class="eyebrow">正式报告样式方向</div>
<h1 class="report-title">公文格式审核报告</h1>
<div class="report-meta">{filename} · 共 {summary.total_findings} 项问题 · 用作正式 HTML 报告输出</div>
<div class="metrics">
<div class="metric">
<div class="metric-label">错误项</div>
<div class="metric-value"><strong>{severity_counts["error"]}</strong><span>error</span></div>
</div>
<div class="metric">
<div class="metric-label">警告项</div>
<div class="metric-value"><strong>{severity_counts["warning"]}</strong><span>warning</span></div>
</div>
<div class="metric">
<div class="metric-label">提示项</div>
<div class="metric-value"><strong>{severity_counts["info"]}</strong><span>info</span></div>
</div>
<div class="metric">
<div class="metric-label">问题类别</div>
<div class="metric-value"><strong>{category_count}</strong><span>标题 / 发文 / 格式 / 其他</span></div>
</div>
</div>
<div class="chips">
<span class="chip error">错误 {severity_counts["error"]}</span>
<span class="chip warning">警告 {severity_counts["warning"]}</span>
<span class="chip info">提示 {severity_counts["info"]}</span>
</div>
</div>
</div>
</section>
<section class="content-grid">
<aside class="card">
<div class="card-head">
<div class="card-title">侧边摘要</div>
<div class="card-subtitle">工作台侧栏语义</div>
</div>
<div class="sidebar-body">
<article class="summary-row">
<div class="summary-row-label">命中最多规则</div>
<div class="summary-row-value">{escape(top_rule_id)}</div>
<div class="summary-row-desc">当前命中最多的规则共 {top_rule_count} 项,适合在正式版中作为摘要提示保留。</div>
</article>
<article class="summary-row">
<div class="summary-row-label">集中行号</div>
<div class="summary-row-value">{escape(line_range)}</div>
<div class="summary-row-desc">问题主要集中在这一段行号范围,便于阅读者快速判断问题分布区域。</div>
</article>
<article class="summary-row">
<div class="summary-row-label">实体状态</div>
<div class="summary-row-value">{escape(entity_summary)}</div>
<div class="summary-row-desc">按现有识别结果汇总实体抽取状态,用于辅助理解顶部结构类问题。</div>
</article>
</div>
</aside>
<article class="card">
<div class="table-toolbar">
<div class="toolbar-left">
<div class="toolbar-title">问题明细</div>
<div class="toolbar-desc">保留当前报告语义,只收敛版式、层级和配色。</div>
</div>
<div class="toolbar-filters">
<span class="filter active">全部</span>
<span class="filter">错误</span>
<span class="filter">警告</span>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>编号</th>
<th>规则</th>
<th>严重度</th>
<th>类别</th>
<th>位置</th>
<th>说明</th>
</tr>
</thead>
<tbody>
{''.join(rows) or '<tr><td colspan="6" class="empty">未发现问题</td></tr>'}
</tbody>
</table>
</div>
</article>
</section>
</div>
</div>
</div>
<table>
<thead><tr>
<th>编号</th><th>规则</th><th>严重度</th><th>类别</th><th>位置</th><th>说明</th>
</tr></thead>
<tbody>{''.join(rows) or '<tr><td colspan=6>未发现问题</td></tr>'}</tbody>
</table>
</body></html>"""
return body
</body>
</html>"""
def _severity_counts(result: AuditResult) -> dict[str, int]:
counts = Counter(finding.severity for finding in result.findings)
return {
"error": counts.get("error", 0),
"warning": counts.get("warning", 0),
"info": counts.get("info", 0),
}
def _top_rule(result: AuditResult) -> tuple[str, int]:
counter = Counter(finding.rule_id for finding in result.findings if finding.rule_id)
if not counter:
return "", 0
rule_id, count = counter.most_common(1)[0]
return rule_id, count
def _line_range(result: AuditResult) -> str:
indices = sorted(
{
int(finding.location.paragraph_index) + 1
for finding in result.findings
if finding.location.paragraph_index is not None
}
)
if not indices:
return "未定位"
if len(indices) == 1:
return f"{indices[0]}"
return f"{indices[0]} 行 - 第 {indices[-1]}"
def _entity_summary(result: AuditResult) -> str:
expected = ["title", "doc_number", "recipient", "date"]
missing = [key for key in expected if not result.entities.get(key)]
if not missing:
return "核心实体齐全"
if len(missing) == len(expected):
return "标题 / 发文"
return "缺少 " + " / ".join(missing[:2])
def _format_location(paragraph_index: int | None) -> str:
if paragraph_index is None:
return "未定位"
return f"{int(paragraph_index) + 1}"