server.py 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056
  1. """
  2. 完整性审查对比测试 - FastAPI后端
  3. 提供API端点:
  4. GET /api/compare/files — 列出可用测试文件
  5. POST /api/compare/chapters — 获取文件的章节列表
  6. POST /api/compare/run — 执行测试(SSE流式返回)
  7. GET / — 返回前端页面
  8. """
  9. import asyncio
  10. import io
  11. import json
  12. import sys
  13. import time
  14. import uuid
  15. import zipfile
  16. from datetime import datetime
  17. from pathlib import Path
  18. from typing import Any, Dict, List, Optional
  19. from fastapi import FastAPI, Request
  20. from fastapi.middleware.cors import CORSMiddleware
  21. from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
  22. PROJECT_ROOT = str(Path(__file__).parent.parent.parent)
  23. if PROJECT_ROOT not in sys.path:
  24. from utils_test.Completeness_Compare_Test.compare_test import (
  25. extract_chunks_by_chapter,
  26. get_all_chapter_codes,
  27. load_final_result,
  28. load_standard_items_for_chapter,
  29. run_method_a,
  30. compare_results,
  31. )
  32. from utils_test.Completeness_Compare_Test.method_b_direct_llm import (
  33. run_direct_llm_check,
  34. direct_result_to_dict,
  35. )
  36. # ── 路径常量 ──
  37. RESULT_DIR = Path(PROJECT_ROOT) / "temp" / "construction_review" / "final_result"
  38. CSV_PATH = (
  39. Path(PROJECT_ROOT)
  40. / "core"
  41. / "construction_review"
  42. / "component"
  43. / "doc_worker"
  44. / "config"
  45. / "StandardCategoryTable.csv"
  46. )
  47. HTML_PATH = Path(__file__).parent / "index.html"
  48. # ── FastAPI 应用 ──
  49. app = FastAPI(title="完整性审查对比测试")
  50. app.add_middleware(
  51. CORSMiddleware,
  52. allow_origins=["*"],
  53. allow_methods=["*"],
  54. allow_headers=["*"],
  55. )
  56. # ── 工具函数 ──
  57. def _format_sse(event: str, data: Any) -> str:
  58. """格式化SSE事件"""
  59. return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
  60. def _find_file(file_id: str) -> Optional[Path]:
  61. """根据文件ID(不含.json)找到完整路径"""
  62. for f in RESULT_DIR.glob("*.json"):
  63. if f.stem == file_id:
  64. return f
  65. return None
  66. def _pick_5_distinct_files() -> List[Path]:
  67. """选出5个不同文件(按hash前缀+文件名双重去重),过滤章节数<3的残缺文件"""
  68. files_by_hash = {}
  69. seen_names = set()
  70. for f in sorted(RESULT_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
  71. hash_prefix = f.stem.split("-")[0]
  72. if hash_prefix in files_by_hash:
  73. continue
  74. try:
  75. data = load_final_result(str(f))
  76. codes = get_all_chapter_codes(data)
  77. if len(codes) < 3:
  78. continue
  79. fname = data.get("file_name", "")
  80. if fname in seen_names:
  81. continue
  82. seen_names.add(fname)
  83. except Exception:
  84. continue
  85. files_by_hash[hash_prefix] = f
  86. if len(files_by_hash) >= 5:
  87. break
  88. return list(files_by_hash.values())
  89. def _make_zip_response(html_content: str, zip_filename: str) -> StreamingResponse:
  90. """将HTML内容打包为ZIP并返回"""
  91. from urllib.parse import quote
  92. buf = io.BytesIO()
  93. html_name = zip_filename.replace(".zip", ".html")
  94. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  95. zf.writestr(html_name, html_content.encode("utf-8"))
  96. buf.seek(0)
  97. encoded = quote(zip_filename, safe="")
  98. return StreamingResponse(
  99. iter([buf.getvalue()]),
  100. media_type="application/zip",
  101. headers={
  102. "Content-Disposition": (
  103. f"attachment; filename=report.zip; filename*=UTF-8''{encoded}"
  104. ),
  105. "Access-Control-Allow-Origin": "*",
  106. },
  107. )
  108. def _gen_report_html(
  109. chapters: List[Dict], summary: Dict, file_name: str, mode: str
  110. ) -> str:
  111. """生成单文件测试的HTML报告"""
  112. ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  113. css = _report_css()
  114. # ── 统计卡片 ──
  115. stats = ""
  116. stats += f'<div class="stat-card"><div class="value">{summary.get("total_chapters", len(chapters))}</div><div class="label">测试章节数</div></div>'
  117. stats += f'<div class="stat-card"><div class="value">{summary.get("total_time", 0)}s</div><div class="label">总耗时</div></div>'
  118. if summary.get("method_a"):
  119. ma = summary["method_a"]
  120. stats += f'<div class="stat-card"><div class="value">{ma["total_time"]}s</div><div class="label">方案A总耗时</div></div>'
  121. stats += f'<div class="stat-card red"><div class="value">{ma["total_missing"]}</div><div class="label">方案A总缺失</div></div>'
  122. if summary.get("method_b"):
  123. mb = summary["method_b"]
  124. stats += f'<div class="stat-card"><div class="value">{mb["total_time"]}s</div><div class="label">方案B总耗时</div></div>'
  125. stats += f'<div class="stat-card orange"><div class="value">{mb["total_missing"]}</div><div class="label">方案B总缺失</div></div>'
  126. if summary.get("comparison"):
  127. c = summary["comparison"]
  128. stats += f'<div class="stat-card green"><div class="value">{c["agreement_rate"]}%</div><div class="label">一致率</div></div>'
  129. stats += f'<div class="stat-card"><div class="value">{c["total_agreement"]}</div><div class="label">一致项</div></div>'
  130. stats += f'<div class="stat-card red"><div class="value">{c["total_disagreement"]}</div><div class="label">分歧项</div></div>'
  131. # ── 对比表格 ──
  132. thead = "<th>章节</th><th>方案A缺失</th><th>方案A完整率</th><th>方案A耗时</th><th>方案B缺失</th><th>方案B完整率</th><th>方案B耗时</th><th>一致</th><th>分歧</th>"
  133. rows = ""
  134. for r in chapters:
  135. c = r.get("comparison", {})
  136. mb = r.get("method_b", {})
  137. if not c:
  138. continue
  139. rows += f"""<tr>
  140. <td><strong>{r['chapter_code']}</strong> {r.get('chapter_name','')}</td>
  141. <td>{c.get('a_missing','')}</td>
  142. <td>{c.get('a_rate',0):.1f}%</td>
  143. <td>{c.get('a_time','')}s</td>
  144. <td>{c.get('b_missing','')}</td>
  145. <td>{c.get('b_rate',0):.1f}%</td>
  146. <td>{mb.get('execution_time','')}s</td>
  147. <td><span class="badge badge-green">{c.get('agreement','')}</span></td>
  148. <td><span class="badge badge-red">{c.get('disagreement','')}</span></td>
  149. </tr>"""
  150. # ── 差异分析 ──
  151. diff = ""
  152. for r in chapters:
  153. c = r.get("comparison", {})
  154. if not c or (not c.get("a_only_missing") and not c.get("b_only_missing")):
  155. continue
  156. nm = r.get("code_name_map", {})
  157. diff += f'<h3 style="font-size:14px;margin:12px 0 8px">{r["chapter_code"]} - {r.get("chapter_name","")}</h3>'
  158. if c.get("a_only_missing"):
  159. names_a = ", ".join(nm.get(x, x) for x in c["a_only_missing"])
  160. diff += f'''<div class="diff-item a-only">
  161. <strong>仅方案A认为缺失</strong>(方案B认为已覆盖):{len(c["a_only_missing"])}项
  162. <div style="margin-top:4px">{names_a}</div>
  163. </div>'''
  164. if c.get("b_only_missing"):
  165. names_b = ", ".join(nm.get(x, x) for x in c["b_only_missing"])
  166. diff += f'''<div class="diff-item b-only">
  167. <strong>仅方案B认为缺失</strong>(方案A认为已覆盖):{len(c["b_only_missing"])}项
  168. <div style="margin-top:4px">{names_b}</div>
  169. </div>'''
  170. if not diff:
  171. diff = '<div class="empty-state">无分歧项,两种方案判断完全一致</div>'
  172. # ── 章节详情 ──
  173. details = ""
  174. for r in chapters:
  175. details += f'<h3 style="font-size:14px;margin:16px 0 8px;color:#667eea">{r["chapter_code"]} - {r.get("chapter_name","")}</h3>'
  176. # 方案A
  177. ma = r.get("method_a", {})
  178. if ma:
  179. recs = ma.get("result", {}).get("recommendations", [])
  180. pass_rec = next((rec for rec in recs if rec.get("level") == "通过"), None)
  181. issue_recs = [rec for rec in recs if rec.get("level") != "通过"]
  182. details += '<h4 style="font-size:13px;color:#667eea">方案A(先分类再比对)</h4>'
  183. if pass_rec:
  184. details += f'<div class="item-row" style="background:#f0fdf4;border-left:3px solid #22c55e">{pass_rec.get("issue_point","")}</div>'
  185. for rec in issue_recs:
  186. details += f'''<div class="item-row">
  187. <div><strong>[{rec.get("level","")}]</strong> {rec.get("issue_point","")}</div>
  188. <div class="meta">位置: {rec.get("location","-")}</div>
  189. {f'<div class="meta">建议: {rec["suggestion"]}</div>' if rec.get("suggestion") else ""}
  190. {f'<div class="meta">依据: {rec["reason"]}</div>' if rec.get("reason") else ""}
  191. </div>'''
  192. # 方案B
  193. mb = r.get("method_b", {})
  194. if mb:
  195. details += '<h4 style="font-size:13px;color:#22c55e;margin-top:12px">方案B(直接LLM解释)</h4>'
  196. items = mb.get("items", [])
  197. covered = [i for i in items if i.get("is_covered")]
  198. missing = [i for i in items if not i.get("is_covered")]
  199. for item in missing:
  200. details += f'''<div class="item-row" style="border-left:3px solid #ef4444">
  201. <div><span class="badge badge-red">缺失</span> <strong>{item.get("standard_name","")}</strong> ({item.get("standard_code","")})</div>
  202. <div class="meta">原因: {item.get("reason","-")}</div>
  203. <div class="meta">置信度: {int((item.get("confidence",0) or 0)*100)}%</div>
  204. </div>'''
  205. if covered:
  206. details += f'<div style="margin-top:8px;font-size:12px;color:#888">已覆盖 {len(covered)} 项:</div>'
  207. for item in covered[:5]:
  208. ev = (item.get("evidence","") or "")[:120]
  209. details += f'''<div class="item-row" style="border-left:3px solid #22c55e;font-size:12px">
  210. <div><span class="badge badge-green">覆盖</span> {item.get("standard_name","")}</div>
  211. {f'<div class="meta">证据: {ev}...</div>' if ev else ""}
  212. </div>'''
  213. if len(covered) > 5:
  214. details += f'<div style="font-size:11px;color:#aaa;padding:4px 12px">... 还有 {len(covered)-5} 项</div>'
  215. mode_label = {"compare": "双方案对比", "method_a": "仅方案A", "method_b": "仅方案B"}.get(mode, mode)
  216. return f"""<!DOCTYPE html>
  217. <html lang="zh-CN">
  218. <head>
  219. <meta charset="UTF-8">
  220. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  221. <title>完整性审查对比报告 - {file_name}</title>
  222. <style>{css}</style>
  223. </head>
  224. <body>
  225. <div class="container">
  226. <header><h1>完整性审查对比报告</h1><p>文件: {file_name} | 模式: {mode_label} | 生成: {ts}</p></header>
  227. <div class="panel"><h2>汇总统计</h2><div class="stats-grid">{stats}</div></div>
  228. <div class="panel"><h2>章节对比明细</h2><div style="overflow-x:auto"><table><thead><tr>{thead}</tr></thead><tbody>{rows}</tbody></table></div></div>
  229. <div class="panel"><h2>差异分析</h2>{diff}</div>
  230. <div class="panel"><h2>章节详情</h2>{details}</div>
  231. </div>
  232. </body>
  233. </html>"""
  234. def _report_css() -> str:
  235. """报告专用CSS(内联,支持打印)"""
  236. return """
  237. *{margin:0;padding:0;box-sizing:border-box}
  238. body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;background:#f5f7fa;color:#333;line-height:1.6;font-size:14px}
  239. .container{max-width:1100px;margin:0 auto;padding:20px}
  240. header{background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:#fff;padding:20px;border-radius:10px;margin-bottom:16px}
  241. header h1{font-size:22px;margin-bottom:4px}
  242. header p{opacity:.9;font-size:12px}
  243. .panel{background:#fff;border-radius:10px;padding:16px;margin-bottom:14px;box-shadow:0 1px 4px rgba(0,0,0,.04);break-inside:avoid}
  244. .panel h2{font-size:15px;margin-bottom:10px;padding-bottom:8px;border-bottom:2px solid #f0f0f0}
  245. .stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px;margin-bottom:12px}
  246. .stat-card{background:linear-gradient(135deg,#f8f9ff,#f0f2ff);border-radius:8px;padding:12px;text-align:center;border:1px solid #e8ecff}
  247. .stat-card .value{font-size:24px;font-weight:700;color:#667eea}
  248. .stat-card .label{font-size:11px;color:#888;margin-top:2px}
  249. .stat-card.green .value{color:#22c55e}
  250. .stat-card.red .value{color:#ef4444}
  251. .stat-card.orange .value{color:#f59e0b}
  252. table{width:100%;border-collapse:collapse;font-size:12px}
  253. th,td{padding:8px 10px;text-align:left;border-bottom:1px solid #f0f0f0}
  254. th{background:#f8f9fa;font-weight:600;color:#555}
  255. tr:hover{background:#fafbff}
  256. .badge{display:inline-block;padding:1px 6px;border-radius:8px;font-size:10px;font-weight:600}
  257. .badge-green{background:#dcfce7;color:#16a34a}
  258. .badge-red{background:#fee2e2;color:#dc2626}
  259. .badge-blue{background:#dbeafe;color:#2563eb}
  260. .badge-orange{background:#fef3c7;color:#d97706}
  261. .diff-item{padding:8px 10px;border-radius:6px;margin-bottom:6px;font-size:12px}
  262. .diff-item.a-only{background:#fef2f2;border-left:3px solid #ef4444}
  263. .diff-item.b-only{background:#fff7ed;border-left:3px solid #f59e0b}
  264. .diff-item .code{font-family:monospace;font-weight:600;font-size:11px}
  265. .item-row{padding:6px 10px;border-radius:5px;margin-bottom:4px;font-size:12px;background:#f8f9fa}
  266. .item-row .meta{font-size:10px;color:#888;margin-top:1px}
  267. .empty-state{text-align:center;padding:30px;color:#aaa;font-size:13px}
  268. @media print {
  269. body{background:#fff}
  270. .panel{box-shadow:none;border:1px solid #e0e0e0}
  271. header{background:#667eea!important;-webkit-print-color-adjust:exact;print-color-adjust:exact}
  272. }
  273. """
  274. def _gen_conclusion(
  275. files_data: List[Dict], avg_rate: float,
  276. total_a_m: int, total_b_m: int,
  277. total_a_t: float, total_b_t: float,
  278. ) -> str:
  279. """根据批量数据生成AI对比分析结论(面向业务人员)"""
  280. from collections import Counter
  281. a_only_all = []
  282. b_only_all = []
  283. for f in files_data:
  284. nm = f.get("code_name_map", {})
  285. for c in f.get("chapters", []):
  286. for code in c.get("a_only_missing", []):
  287. a_only_all.append(nm.get(code, code))
  288. for code in c.get("b_only_missing", []):
  289. b_only_all.append(nm.get(code, code))
  290. top_a = Counter(a_only_all).most_common(3)
  291. top_b = Counter(b_only_all).most_common(3)
  292. speedup = round(total_b_t / max(total_a_t, 0.1), 1)
  293. if total_a_m > total_b_m:
  294. diff_text = f"方案A比方案B多报出 {total_a_m - total_b_m} 项缺失"
  295. elif total_b_m > total_a_m:
  296. diff_text = f"方案B比方案A多报出 {total_b_m - total_a_m} 项缺失"
  297. else:
  298. diff_text = "两种方案报出的缺失总数相近"
  299. avg_rate_text = "高" if avg_rate >= 90 else "中等" if avg_rate >= 75 else "一般"
  300. parts = []
  301. # 结论总述
  302. parts.append(f"""
  303. <div class="panel" style="background:linear-gradient(135deg,#f0f4ff,#faf5ff);border:2px solid #667eea;">
  304. <h2>AI 对比分析结论</h2>
  305. <div style="font-size:14px;line-height:2">
  306. <p>对 {len(files_data)} 份施工方案文档进行双方案完整性审查对比,两方案判断 <strong style="color:#667eea">一致率约 {avg_rate}%</strong>({avg_rate_text}一致性)。{diff_text}。</p>
  307. </div>
  308. </div>""")
  309. # 两方案特点对比
  310. parts.append(f"""
  311. <div class="panel">
  312. <h2>两方案特点对比</h2>
  313. <table>
  314. <thead><tr><th style="width:15%">维度</th><th style="width:42%">方案A:先分类再比对</th><th style="width:43%">方案B:直接LLM解释</th></tr></thead>
  315. <tbody>
  316. <tr>
  317. <td><strong>审查逻辑</strong></td>
  318. <td>先由分类器将文档内容归类到标准代码,再用集合运算判断是否覆盖。分类器漏分则误判缺失。</td>
  319. <td>将文档原文和标准要求一起交给LLM,LLM逐条判断,并给出<strong>证据原文</strong>和<strong>判断理由</strong>。</td>
  320. </tr>
  321. <tr>
  322. <td><strong>可解释性</strong></td>
  323. <td style="color:#dc2626">较弱。输出为模板字符串拼接,审查人员无法直接看到判断依据,需回溯分类链路。</td>
  324. <td style="color:#16a34a"><strong>强。每条判断都附带文档原文引用和具体理由,审查人员可直接验证,无需追溯中间过程。</strong></td>
  325. </tr>
  326. <tr>
  327. <td><strong>漏报风险</strong></td>
  328. <td>分类器对组织架构类、人员职责类标准项召回率偏低,有内容也可能误判缺失。</td>
  329. <td>基于语义理解,不受分类器限制。但对细节参数(预警值、监测频率)可能过度严格。</td>
  330. </tr>
  331. <tr>
  332. <td><strong>误报风险</strong></td>
  333. <td>低。集合运算确定性高,分类正确则判断正确。</td>
  334. <td>中等。LLM判断有随机性,同文档多次运行可能略有差异。</td>
  335. </tr>
  336. <tr>
  337. <td><strong>速度</strong></td>
  338. <td>快(~1s/章节),大部分运算为集合操作。</td>
  339. <td>慢(~10s/章节),每章节需完整LLM推理。并发可改善。</td>
  340. </tr>
  341. <tr>
  342. <td><strong>扩展性</strong></td>
  343. <td>差。新增标准需调整分类器。</td>
  344. <td>好。新增标准只改CSV和prompt。</td>
  345. </tr>
  346. <tr>
  347. <td><strong>客户理解成本</strong></td>
  348. <td style="color:#dc2626">链路绕,解释困难。</td>
  349. <td style="color:#16a34a"><strong>直观:文档+标准→AI判断→证据+结论,一句话讲清楚。</strong></td>
  350. </tr>
  351. </tbody>
  352. </table>
  353. </div>""")
  354. # 分歧模式分析
  355. parts.append('<div class="panel"><h2>分歧模式分析</h2>')
  356. if top_a:
  357. a_items = "、".join(f"{name}({cnt}次)" for name, cnt in top_a)
  358. parts.append(f"""
  359. <div class="diff-item a-only" style="margin-bottom:10px">
  360. <strong>方案A反复漏报(B认为已覆盖):</strong>{a_items}
  361. <br><span style="font-size:12px;color:#888">→ 多为组织架构/人员职责类标准,分类器召回率偏低。方案B能通过语义理解正确识别。</span>
  362. </div>""")
  363. if top_b:
  364. b_items = "、".join(f"{name}({cnt}次)" for name, cnt in top_b)
  365. parts.append(f"""
  366. <div class="diff-item b-only" style="margin-bottom:10px">
  367. <strong>方案B反复报告(A认为已覆盖):</strong>{b_items}
  368. <br><span style="font-size:12px;color:#888">→ 多为预警值/监测频率等细节参数类标准。B对此判断更严格,需人工确认。</span>
  369. </div>""")
  370. if not top_a and not top_b:
  371. parts.append('<p style="color:#888">两方案分歧较为分散,未出现系统性高频分歧项。</p>')
  372. parts.append('</div>')
  373. # 建议
  374. parts.append(f"""
  375. <div class="panel" style="background:#f0fdf4;border:2px solid #22c55e;">
  376. <h2>建议</h2>
  377. <div style="font-size:14px;line-height:2">
  378. <ol>
  379. <li><strong>建议采用方案B作为主方案</strong>。核心优势在<strong>可解释性</strong>:每条判断有证据原文和推理理由,客户沟通直观,审查人员可直接验证。</li>
  380. <li><strong>融合方案</strong>:B的判断为主,A的分类结果作上下文增强,帮助LLM更准确定位文档内容。</li>
  381. <li><strong>校准分歧</strong>:对高频分歧项人工抽查2-3个章节原文,确认哪方更准确,据此调整prompt或分类器。</li>
  382. <li><strong>性能</strong>:B每章节~{speedup}x于A(串行),已通过并发改善。后续可缓存LLM结果。</li>
  383. </ol>
  384. </div>
  385. </div>""")
  386. return "".join(parts)
  387. # ── 端点:首页 ──
  388. @app.get("/", response_class=HTMLResponse)
  389. async def index():
  390. return HTML_PATH.read_text(encoding="utf-8")
  391. # ── 端点:列出文件 ──
  392. @app.get("/api/compare/files")
  393. async def list_files():
  394. files = []
  395. for f in sorted(RESULT_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
  396. try:
  397. with open(f, "r", encoding="utf-8") as fh:
  398. data = json.load(fh)
  399. files.append({
  400. "file_id": f.stem,
  401. "file_name": data.get("file_name", f.name),
  402. "chunks_count": len(
  403. data.get("document_result", {})
  404. .get("structured_content", {})
  405. .get("chunks", [])
  406. ),
  407. })
  408. except Exception:
  409. continue
  410. return JSONResponse(content={"files": files})
  411. # ── 端点:获取章节列表 ──
  412. @app.post("/api/compare/chapters")
  413. async def get_chapters(request: Request):
  414. body = await request.json()
  415. file_id = body.get("file_id", "")
  416. fpath = _find_file(file_id)
  417. if not fpath:
  418. return JSONResponse(status_code=404, content={"error": "文件不存在"})
  419. data = load_final_result(str(fpath))
  420. codes = get_all_chapter_codes(data)
  421. chapters = []
  422. for code in codes:
  423. chunks = extract_chunks_by_chapter(data, code)
  424. name = chunks[0].get("first_name", code) if chunks else code
  425. chapters.append({
  426. "code": code,
  427. "name": name,
  428. "chunks_count": len(chunks),
  429. })
  430. return JSONResponse(content={"chapters": chapters})
  431. # ── 端点:执行测试(SSE) ──
  432. @app.post("/api/compare/run")
  433. async def run_test(request: Request):
  434. body = await request.json()
  435. file_id = body.get("file_id", "")
  436. chapters = body.get("chapters", [])
  437. mode = body.get("mode", "compare") # method_a | method_b | compare
  438. fpath = _find_file(file_id)
  439. if not fpath:
  440. return JSONResponse(status_code=404, content={"error": "文件不存在"})
  441. async def event_stream():
  442. try:
  443. data = load_final_result(str(fpath))
  444. file_name = data.get("file_name", file_id)
  445. # 如果未指定章节,使用全部
  446. if not chapters:
  447. chapter_codes = get_all_chapter_codes(data)
  448. else:
  449. chapter_codes = chapters
  450. total = len(chapter_codes)
  451. all_results = []
  452. start_all = time.time()
  453. yield _format_sse("started", {
  454. "file_name": file_name,
  455. "total_chapters": total,
  456. "mode": mode,
  457. })
  458. for idx, chapter_code in enumerate(chapter_codes):
  459. chunks = extract_chunks_by_chapter(data, chapter_code)
  460. if not chunks:
  461. yield _format_sse("progress", {
  462. "chapter": chapter_code,
  463. "status": "skipped",
  464. "reason": "无chunks",
  465. "current": idx + 1,
  466. "total": total,
  467. })
  468. continue
  469. chapter_name = chunks[0].get("first_name", chapter_code)
  470. standard_items = load_standard_items_for_chapter(
  471. str(CSV_PATH), chapter_code
  472. )
  473. chapter_result = {
  474. "chapter_code": chapter_code,
  475. "chapter_name": chapter_name,
  476. "mode": mode,
  477. "code_name_map": {si["third_code"]: si["third_name"] for si in standard_items},
  478. }
  479. # ── 方案A ──
  480. if mode in ("method_a", "compare"):
  481. yield _format_sse("progress", {
  482. "chapter": chapter_code,
  483. "chapter_name": chapter_name,
  484. "status": "running",
  485. "method": "A",
  486. "current": idx + 1,
  487. "total": total,
  488. })
  489. a_result, a_time, a_llm_calls = await run_method_a(
  490. chunks=chunks,
  491. csv_path=str(CSV_PATH),
  492. chapter_code=chapter_code,
  493. )
  494. chapter_result["method_a"] = {
  495. "result": a_result,
  496. "time": round(a_time, 2),
  497. "llm_calls": a_llm_calls,
  498. }
  499. # ── 方案B ──
  500. if mode in ("method_b", "compare"):
  501. yield _format_sse("progress", {
  502. "chapter": chapter_code,
  503. "chapter_name": chapter_name,
  504. "status": "running",
  505. "method": "B",
  506. "current": idx + 1,
  507. "total": total,
  508. })
  509. b_result = await run_direct_llm_check(
  510. chunks=chunks,
  511. standard_items=standard_items,
  512. chapter_code=chapter_code,
  513. chapter_name=chapter_name,
  514. )
  515. chapter_result["method_b"] = direct_result_to_dict(b_result)
  516. # ── 对比 ──
  517. if mode == "compare" and "method_a" in chapter_result and "method_b" in chapter_result:
  518. cr = compare_results(
  519. chapter_code=chapter_code,
  520. chapter_name=chapter_name,
  521. method_a=chapter_result["method_a"]["result"],
  522. method_b=b_result,
  523. a_time=chapter_result["method_a"]["time"],
  524. a_llm_calls=chapter_result["method_a"]["llm_calls"],
  525. )
  526. chapter_result["comparison"] = {
  527. "a_missing": cr.a_missing,
  528. "b_missing": cr.b_missing,
  529. "a_rate": cr.a_completeness_rate,
  530. "b_rate": cr.b_completeness_rate,
  531. "a_time": cr.a_execution_time,
  532. "b_time": cr.b_execution_time,
  533. "agreement": cr.agreement_count,
  534. "disagreement": cr.disagreement_count,
  535. "a_only_missing": cr.a_only_missing,
  536. "b_only_missing": cr.b_only_missing,
  537. "a_missing_details": cr.a_missing_details,
  538. "b_items": cr.b_items,
  539. "a_recommendations": cr.a_recommendations,
  540. }
  541. all_results.append(chapter_result)
  542. yield _format_sse("chapter_result", chapter_result)
  543. # ── 汇总 ──
  544. total_time = time.time() - start_all
  545. summary = _build_summary(all_results, mode, total_time)
  546. yield _format_sse("summary", summary)
  547. except Exception as e:
  548. yield _format_sse("error", {"message": str(e)})
  549. return StreamingResponse(
  550. event_stream(),
  551. media_type="text/event-stream",
  552. headers={
  553. "Cache-Control": "no-cache",
  554. "X-Accel-Buffering": "no",
  555. "Access-Control-Allow-Origin": "*",
  556. },
  557. )
  558. def _build_summary(
  559. results: List[Dict], mode: str, total_time: float
  560. ) -> Dict[str, Any]:
  561. """构建汇总统计"""
  562. summary: Dict[str, Any] = {
  563. "mode": mode,
  564. "total_chapters": len(results),
  565. "total_time": round(total_time, 2),
  566. }
  567. if mode in ("method_a", "compare"):
  568. a_times = [r["method_a"]["time"] for r in results if "method_a" in r]
  569. a_missing = []
  570. for r in results:
  571. if "method_a" in r:
  572. tertiary = r["method_a"]["result"].get("tertiary_completeness", {})
  573. a_missing.append(tertiary.get("missing", 0))
  574. summary["method_a"] = {
  575. "total_time": round(sum(a_times), 2),
  576. "avg_time": round(sum(a_times) / len(a_times), 2) if a_times else 0,
  577. "total_missing": sum(a_missing),
  578. "avg_missing": round(sum(a_missing) / len(a_missing), 1) if a_missing else 0,
  579. }
  580. if mode in ("method_b", "compare"):
  581. b_times = [r["method_b"]["execution_time"] for r in results if "method_b" in r]
  582. b_missing = [r["method_b"]["missing_count"] for r in results if "method_b" in r]
  583. summary["method_b"] = {
  584. "total_time": round(sum(b_times), 2),
  585. "avg_time": round(sum(b_times) / len(b_times), 2) if b_times else 0,
  586. "total_missing": sum(b_missing),
  587. "avg_missing": round(sum(b_missing) / len(b_missing), 1) if b_missing else 0,
  588. }
  589. if mode == "compare":
  590. agreements = [
  591. r["comparison"]["agreement"] for r in results if "comparison" in r
  592. ]
  593. disagreements = [
  594. r["comparison"]["disagreement"] for r in results if "comparison" in r
  595. ]
  596. total_agree = sum(agreements)
  597. total_disagree = sum(disagreements)
  598. summary["comparison"] = {
  599. "total_agreement": total_agree,
  600. "total_disagreement": total_disagree,
  601. "agreement_rate": round(
  602. total_agree / (total_agree + total_disagree) * 100, 1
  603. )
  604. if (total_agree + total_disagree) > 0
  605. else 0,
  606. }
  607. return summary
  608. # ═══════════════════════════════════════════════════════════════════
  609. # 导出端点
  610. # ═══════════════════════════════════════════════════════════════════
  611. @app.post("/api/compare/export")
  612. async def export_results(request: Request):
  613. """接收前端结果数据,生成HTML报告并返回ZIP"""
  614. body = await request.json()
  615. file_name = body.get("file_name", "unknown")
  616. mode = body.get("mode", "compare")
  617. chapters = body.get("chapters", [])
  618. summary = body.get("summary", {})
  619. html = _gen_report_html(chapters, summary, file_name, mode)
  620. safe = Path(file_name).stem or "report"
  621. zip_name = f"{safe}_对比报告.zip"
  622. return _make_zip_response(html, zip_name)
  623. # ═══════════════════════════════════════════════════════════════════
  624. # 批量测试端点
  625. # ═══════════════════════════════════════════════════════════════════
  626. @app.post("/api/compare/batch/run")
  627. async def run_batch_test(request: Request):
  628. """批量测试5个文件(SSE流式返回),通过并发数参数控制并行度"""
  629. body = {}
  630. try:
  631. raw = await request.body()
  632. if raw:
  633. body = json.loads(raw)
  634. except Exception:
  635. pass
  636. concurrency = body.get("concurrency", 2)
  637. concurrency = max(1, min(concurrency, 5)) # 限制1-5
  638. async def batch_event_stream():
  639. files = _pick_5_distinct_files()
  640. if not files:
  641. yield _format_sse("error", {"message": "无可用测试文件"})
  642. return
  643. file_infos = []
  644. for f in files:
  645. try:
  646. d = load_final_result(str(f))
  647. file_infos.append({
  648. "file_id": f.stem,
  649. "file_name": d.get("file_name", f.name),
  650. })
  651. except Exception:
  652. file_infos.append({"file_id": f.stem, "file_name": f.name})
  653. yield _format_sse("batch_started", {
  654. "total_files": len(files),
  655. "concurrency": concurrency,
  656. "files": file_infos,
  657. })
  658. start_all = time.time()
  659. queue: asyncio.Queue = asyncio.Queue()
  660. sem = asyncio.Semaphore(concurrency)
  661. collected_results: List[Dict] = []
  662. async def process_one_file(idx: int, fpath: Path, fid: str, fname: str):
  663. async with sem:
  664. await queue.put(("batch_file_started", {
  665. "file_id": fid, "file_name": fname, "file_index": idx,
  666. }))
  667. try:
  668. data = load_final_result(str(fpath))
  669. except Exception as e:
  670. await queue.put(("batch_file_error", {"file_id": fid, "error": str(e)}))
  671. await queue.put(("batch_file_done", {"file_id": fid, "result": None}))
  672. return
  673. chapter_codes = get_all_chapter_codes(data)
  674. file_result = {
  675. "file_id": fid, "file_name": fname,
  676. "chapters": [], "code_name_map": {},
  677. }
  678. t_a = t_b = t_agree = t_disagree = t_am = t_bm = t_req = 0
  679. for ci, chapter_code in enumerate(chapter_codes):
  680. chunks = extract_chunks_by_chapter(data, chapter_code)
  681. if not chunks:
  682. continue
  683. chapter_name = chunks[0].get("first_name", chapter_code)
  684. standard_items = load_standard_items_for_chapter(str(CSV_PATH), chapter_code)
  685. for si in standard_items:
  686. file_result["code_name_map"][si["third_code"]] = si["third_name"]
  687. if not standard_items:
  688. continue
  689. await queue.put(("batch_chapter_progress", {
  690. "file_id": fid, "chapter_code": chapter_code,
  691. "chapter_name": chapter_name, "current": ci + 1,
  692. "total": len(chapter_codes),
  693. }))
  694. a_result, a_time, alc = await run_method_a(
  695. chunks=chunks, csv_path=str(CSV_PATH), chapter_code=chapter_code)
  696. b_result = await run_direct_llm_check(
  697. chunks=chunks, standard_items=standard_items,
  698. chapter_code=chapter_code, chapter_name=chapter_name)
  699. cr = compare_results(
  700. chapter_code=chapter_code, chapter_name=chapter_name,
  701. method_a=a_result, method_b=b_result, a_time=a_time, a_llm_calls=alc)
  702. file_result["chapters"].append({
  703. "chapter_code": chapter_code,
  704. "chapter_name": chapter_name,
  705. "a_total": cr.a_total_required,
  706. "a_missing": cr.a_missing,
  707. "a_rate": cr.a_completeness_rate,
  708. "a_time": round(a_time, 2),
  709. "b_total": cr.b_total_required,
  710. "b_missing": cr.b_missing,
  711. "b_rate": cr.b_completeness_rate,
  712. "b_time": round(b_result.execution_time, 2),
  713. "agreement": cr.agreement_count,
  714. "disagreement": cr.disagreement_count,
  715. "a_only_missing": cr.a_only_missing,
  716. "b_only_missing": cr.b_only_missing,
  717. "a_recommendations": [
  718. {
  719. "level": r.get("level", ""),
  720. "issue_point": r.get("issue_point", ""),
  721. "location": r.get("location", ""),
  722. "suggestion": r.get("suggestion", ""),
  723. "reason": r.get("reason", ""),
  724. }
  725. for r in cr.a_recommendations
  726. ],
  727. "b_items": [
  728. {
  729. "standard_code": item.get("standard_code", ""),
  730. "standard_name": item.get("standard_name", ""),
  731. "is_covered": item.get("is_covered", False),
  732. "evidence": item.get("evidence", ""),
  733. "reason": item.get("reason", ""),
  734. "confidence": item.get("confidence", 0),
  735. }
  736. for item in cr.b_items
  737. ],
  738. })
  739. t_a += a_time; t_b += b_result.execution_time
  740. t_agree += cr.agreement_count; t_disagree += cr.disagreement_count
  741. t_am += cr.a_missing; t_bm += cr.b_missing; t_req += cr.a_total_required
  742. n = len(file_result["chapters"])
  743. file_result["summary"] = {
  744. "chapter_count": n, "total_required": t_req,
  745. "total_a_missing": t_am, "total_b_missing": t_bm,
  746. "total_a_time": round(t_a, 2), "total_b_time": round(t_b, 2),
  747. "total_agreement": t_agree, "total_disagreement": t_disagree,
  748. "agreement_rate": (
  749. round(t_agree / (t_agree + t_disagree) * 100, 1)
  750. if (t_agree + t_disagree) > 0 else 0),
  751. }
  752. await queue.put(("batch_file_done", {"file_id": fid, "result": file_result}))
  753. # 启动并发任务
  754. tasks = [
  755. asyncio.create_task(process_one_file(i, fpath, f["file_id"], f["file_name"]))
  756. for i, (fpath, f) in enumerate(zip(files, file_infos))
  757. ]
  758. # 从队列读取并 yield SSE,直到所有文件完成
  759. done = 0
  760. total = len(tasks)
  761. while done < total:
  762. event_type, data = await queue.get()
  763. if event_type == "batch_file_done":
  764. done += 1
  765. if data.get("result"):
  766. collected_results.append(data["result"])
  767. yield _format_sse("batch_file_result", data["result"])
  768. else:
  769. yield _format_sse(event_type, data)
  770. await asyncio.gather(*tasks, return_exceptions=True)
  771. # 汇总
  772. total_time = time.time() - start_all
  773. all_chapters = sum(f["summary"]["chapter_count"] for f in collected_results)
  774. collected_results.sort(key=lambda r: file_infos.index(
  775. next(f for f in file_infos if f["file_id"] == r["file_id"])))
  776. batch_summary = {
  777. "total_files": len(collected_results),
  778. "total_chapters": all_chapters,
  779. "total_time": round(total_time, 2),
  780. "files": collected_results,
  781. }
  782. yield _format_sse("batch_summary", batch_summary)
  783. return StreamingResponse(
  784. batch_event_stream(),
  785. media_type="text/event-stream",
  786. headers={
  787. "Cache-Control": "no-cache",
  788. "X-Accel-Buffering": "no",
  789. "Access-Control-Allow-Origin": "*",
  790. },
  791. )
  792. @app.post("/api/compare/batch/export")
  793. async def export_batch_results(request: Request):
  794. """接收批量结果数据,生成详细HTML报告并返回ZIP"""
  795. body = await request.json()
  796. files_data = body.get("files", [])
  797. ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  798. css = _report_css() + """
  799. .chapter-block{border:1px solid #e0e0e0;border-radius:8px;padding:14px;margin-bottom:14px;break-inside:avoid}
  800. .chapter-block h3{font-size:14px;color:#667eea;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid #f0f0f0}
  801. .method-section{margin-bottom:10px}
  802. .method-section h4{font-size:12px;font-weight:600;margin-bottom:6px}
  803. .cover-item{padding:6px 8px;margin:3px 0;border-radius:4px;font-size:12px;border-left:3px solid #22c55e;background:#f0fdf4}
  804. .miss-item{padding:6px 8px;margin:3px 0;border-radius:4px;font-size:12px;border-left:3px solid #ef4444;background:#fef2f2}
  805. .diff-block{margin-top:8px;padding:8px;border-radius:6px;background:#fffbe6;border:1px solid #fde68a;font-size:12px}
  806. .chapter-summary-line{font-size:11px;color:#888;margin-bottom:8px}
  807. .toc{background:#f8f9fa;border-radius:8px;padding:12px;margin-bottom:16px;font-size:13px}
  808. .toc a{color:#667eea;text-decoration:none;margin:0 8px}
  809. .section-divider{border:0;border-top:2px dashed #e0e0e0;margin:20px 0}
  810. """
  811. # 汇总统计
  812. total_files = len(files_data)
  813. total_chapters = sum(f["summary"]["chapter_count"] for f in files_data)
  814. total_a_m = sum(f["summary"]["total_a_missing"] for f in files_data)
  815. total_b_m = sum(f["summary"]["total_b_missing"] for f in files_data)
  816. total_a_t = sum(f["summary"]["total_a_time"] for f in files_data)
  817. total_b_t = sum(f["summary"]["total_b_time"] for f in files_data)
  818. avg_rate = (
  819. round(sum(f["summary"]["agreement_rate"] for f in files_data) / total_files, 1)
  820. if total_files > 0 else 0
  821. )
  822. # ── AI 对比结论 ──
  823. conclusion = _gen_conclusion(files_data, avg_rate, total_a_m, total_b_m, total_a_t, total_b_t)
  824. stats = (
  825. f'<div class="stat-card"><div class="value">{total_files}</div><div class="label">文件数</div></div>'
  826. f'<div class="stat-card"><div class="value">{total_chapters}</div><div class="label">总章节</div></div>'
  827. f'<div class="stat-card red"><div class="value">{total_a_m}</div><div class="label">A总缺失</div></div>'
  828. f'<div class="stat-card orange"><div class="value">{total_b_m}</div><div class="label">B总缺失</div></div>'
  829. f'<div class="stat-card green"><div class="value">{avg_rate}%</div><div class="label">平均一致率</div></div>'
  830. f'<div class="stat-card"><div class="value">{total_a_t}s</div><div class="label">A总耗时</div></div>'
  831. f'<div class="stat-card"><div class="value">{total_b_t}s</div><div class="label">B总耗时</div></div>'
  832. )
  833. # 目录
  834. toc = '<div class="toc"><strong>目录:</strong>'
  835. for fi, f in enumerate(files_data):
  836. fid_short = f.get("file_id", "")[:8]
  837. toc += f'<a href="#file-{fi}">文件{fi+1}: {f.get("file_name","")[:20]}...</a>'
  838. toc += '</div>'
  839. # 各文件详情
  840. file_details = ""
  841. for fi, f in enumerate(files_data):
  842. chapters = f.get("chapters", [])
  843. fname = f.get("file_name", f.get("file_id", ""))
  844. s = f.get("summary", {})
  845. nm = f.get("code_name_map", {})
  846. file_details += f'<hr class="section-divider" id="file-{fi}">'
  847. file_details += f'<div class="panel"><h2>文件{fi+1}: {fname}</h2>'
  848. file_details += f'<p style="font-size:12px;color:#888;margin-bottom:12px">'
  849. file_details += f'{s.get("chapter_count",0)}章节 | '
  850. file_details += f'总要求{s.get("total_required",0)}项 | '
  851. file_details += f'A缺失{s.get("total_a_missing",0)} | B缺失{s.get("total_b_missing",0)} | '
  852. file_details += f'一致率{s.get("agreement_rate",0)}% | '
  853. file_details += f'A耗时{s.get("total_a_time",0)}s | B耗时{s.get("total_b_time",0)}s'
  854. file_details += f'</p>'
  855. # 每个章节的详细审查结果
  856. for c in chapters:
  857. code = c.get("chapter_code", "")
  858. name = c.get("chapter_name", "")
  859. file_details += f'<div class="chapter-block">'
  860. file_details += f'<h3>{code} — {name}</h3>'
  861. file_details += f'<div class="chapter-summary-line">'
  862. file_details += f'A: {c["a_missing"]}/{c["a_total"]}缺失 ({c["a_rate"]:.0f}%) | '
  863. file_details += f'B: {c["b_missing"]}/{c["b_total"]}缺失 ({c["b_rate"]:.0f}%) | '
  864. file_details += f'一致{c["agreement"]} | 分歧{c["disagreement"]}'
  865. file_details += f'</div>'
  866. # ── 差异项(优先展示) ──
  867. a_only = c.get("a_only_missing", [])
  868. b_only = c.get("b_only_missing", [])
  869. if a_only or b_only:
  870. file_details += '<div class="diff-block"><strong>差异项:</strong>'
  871. if a_only:
  872. parts = [f"{nm.get(x,x)}" for x in a_only]
  873. file_details += f' <span style="color:#dc2626">仅A缺失: {", ".join(parts)}</span>;'
  874. if b_only:
  875. parts = [f"{nm.get(x,x)}" for x in b_only]
  876. file_details += f' <span style="color:#d97706">仅B缺失: {", ".join(parts)}</span>'
  877. file_details += '</div>'
  878. # ── 方案A详情 ──
  879. a_recs = c.get("a_recommendations", [])
  880. if a_recs:
  881. file_details += '<div class="method-section"><h4 style="color:#667eea">方案A — 审查结果</h4>'
  882. for rec in a_recs:
  883. level = rec.get("level", "")
  884. if level == "通过":
  885. file_details += f'<div class="cover-item">{rec.get("issue_point","")}</div>'
  886. else:
  887. file_details += f'<div class="miss-item"><strong>[{level}]</strong> {rec.get("issue_point","")}'
  888. if rec.get("location"):
  889. file_details += f' <span style="color:#888">— {rec["location"]}</span>'
  890. if rec.get("reason"):
  891. file_details += f'<br><span style="color:#888;font-size:11px">依据: {rec["reason"]}</span>'
  892. if rec.get("suggestion"):
  893. file_details += f'<br><span style="color:#667eea;font-size:11px">建议: {rec["suggestion"]}</span>'
  894. file_details += '</div>'
  895. file_details += '</div>'
  896. # ── 方案B详情 ──
  897. b_items = c.get("b_items", [])
  898. if b_items:
  899. covered = [i for i in b_items if i.get("is_covered")]
  900. missing = [i for i in b_items if not i.get("is_covered")]
  901. file_details += '<div class="method-section"><h4 style="color:#22c55e">方案B — 逐项判断</h4>'
  902. if missing:
  903. file_details += f'<div style="font-size:12px;color:#dc2626;margin-bottom:4px">缺失 {len(missing)} 项:</div>'
  904. for item in missing:
  905. cn = item.get("standard_name", item.get("standard_code", ""))
  906. file_details += f'<div class="miss-item"><strong>缺失 - {cn}</strong>'
  907. file_details += f'<br><span style="color:#888;font-size:11px">原因: {item.get("reason","-")}</span>'
  908. file_details += f' <span style="color:#888;font-size:11px">置信度: {int((item.get("confidence",0) or 0)*100)}%</span>'
  909. file_details += '</div>'
  910. if covered:
  911. file_details += f'<div style="font-size:12px;color:#16a34a;margin:6px 0 4px">已覆盖 {len(covered)} 项:</div>'
  912. for item in covered:
  913. cn = item.get("standard_name", item.get("standard_code", ""))
  914. ev = (item.get("evidence", "") or "")[:200]
  915. file_details += f'<div class="cover-item"><strong>覆盖 - {cn}</strong>'
  916. if ev and ev != "无":
  917. file_details += f'<br><span style="color:#888;font-size:11px">证据: {ev}</span>'
  918. file_details += f' <span style="color:#888;font-size:11px">置信度: {int((item.get("confidence",0) or 0)*100)}%</span>'
  919. file_details += '</div>'
  920. file_details += '</div>'
  921. file_details += '</div>' # chapter-block
  922. file_details += '</div>' # panel
  923. html = f"""<!DOCTYPE html>
  924. <html lang="zh-CN">
  925. <head>
  926. <meta charset="UTF-8">
  927. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  928. <title>批量对比报告</title>
  929. <style>{css}</style>
  930. </head>
  931. <body>
  932. <div class="container">
  933. <header><h1>批量完整性审查对比报告</h1><p>文件: {total_files}个 | 章节: {total_chapters}个 | 生成: {ts}</p></header>
  934. {conclusion}
  935. <div class="panel"><h2>汇总统计</h2><div class="stats-grid">{stats}</div></div>
  936. {toc}
  937. {file_details}
  938. </div>
  939. </body>
  940. </html>"""
  941. zip_name = f"批量对比报告_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
  942. return _make_zip_response(html, zip_name)