reviewer.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923
  1. """
  2. 内容审核器
  3. 多维度检查投书质量:
  4. 1. 评分项覆盖
  5. 2. 废标项规避
  6. 3. 字数达标
  7. 4. 章节完整性
  8. 5. 响应清单完整性
  9. """
  10. from __future__ import annotations
  11. import json
  12. import logging
  13. import re
  14. from concurrent.futures import ThreadPoolExecutor, as_completed
  15. from typing import List
  16. from llm_client import LLMClient
  17. from models import (
  18. BidOutline,
  19. Chapter,
  20. ChapterType,
  21. ReviewReport,
  22. ReviewIssue,
  23. TenderAnalysis,
  24. ScoringCriterion,
  25. RejectionItem,
  26. ResponseRequirement,
  27. )
  28. from config import get_config
  29. from chapter_policy import ChapterEditMode, get_chapter_edit_mode
  30. from step5_reviewing.policies import (
  31. analysis_has_package_info,
  32. find_body_indent_issues,
  33. find_duplicate_info_issues,
  34. find_heading_score_issues,
  35. find_package_info_issues,
  36. find_rejection_filling_issues,
  37. find_sibling_numbering_issues,
  38. find_template_integrity_issues,
  39. )
  40. logger = logging.getLogger(__name__)
  41. def _heading_match_token(text: str) -> str:
  42. """归一化标题用于在父章实际文本中判断小节是否存在。"""
  43. value = re.sub(r"^第\s*[一二三四五六七八九十百\d]+\s*章\s*[::]?\s*", "", (text or ""))
  44. return re.sub(r"[\s\u3000]+", "", value).rstrip("::。;;")
  45. def _chapter_effective_content(chapter: Chapter) -> str:
  46. """返回章节唯一正文;父章已有汇总正文时不再重复统计子章。"""
  47. if (chapter.generated_content or "").strip():
  48. return chapter.generated_content
  49. return "\n".join(
  50. _chapter_effective_content(child)
  51. for child in (chapter.children or [])
  52. if _chapter_effective_content(child).strip()
  53. )
  54. def _canonical_outline_content(outline: BidOutline) -> str:
  55. """构建不重复父子内容的全文语料。"""
  56. return "\n".join(
  57. f"【{ch.id} {ch.title}】\n{_chapter_effective_content(ch)}"
  58. for ch in outline.chapters
  59. if _chapter_effective_content(ch).strip()
  60. )
  61. def _balanced_outline_excerpt(
  62. outline: BidOutline,
  63. max_chars: int = 12000,
  64. ) -> str:
  65. """从每个顶层章节均匀取样,避免审核只看到文档开头。"""
  66. populated = [
  67. (ch, _chapter_effective_content(ch))
  68. for ch in outline.chapters
  69. if _chapter_effective_content(ch).strip()
  70. ]
  71. if not populated:
  72. return ""
  73. quota = max(500, max_chars // len(populated))
  74. blocks = []
  75. for ch, content in populated:
  76. available = max(quota - len(ch.id) - len(ch.title) - 8, 100)
  77. if len(content) > available:
  78. head = available * 2 // 3
  79. tail = available - head
  80. excerpt = content[:head] + "\n……\n" + content[-tail:]
  81. else:
  82. excerpt = content
  83. blocks.append(f"【{ch.id} {ch.title}】\n{excerpt}")
  84. return "\n".join(blocks)[:max_chars]
  85. class _Reviewer:
  86. """内容审核器"""
  87. def __init__(self):
  88. self.llm = LLMClient()
  89. self.cfg = get_config()
  90. def review(
  91. self,
  92. outline: BidOutline,
  93. analysis: TenderAnalysis,
  94. ) -> ReviewReport:
  95. """执行全面审核"""
  96. issues: List[ReviewIssue] = []
  97. total_words = 0
  98. # 1. 字数统计(仅用于报告,不再做字数达标判定)
  99. for ch in outline.chapters:
  100. total_words += len(_chapter_effective_content(ch))
  101. total_words += self._count_template_words(outline)
  102. # 2. 标题格式/序号检查(确定性)
  103. issues.extend(self._check_heading_format(outline))
  104. # 3. 冗余信息删除(包号/重复/表前表后信息,包号与重复判断使用 LLM 结果)
  105. issues.extend(self._check_redundant_info(outline, analysis))
  106. report = ReviewReport(
  107. passed=len([i for i in issues if i.severity == "error"]) == 0,
  108. issues=issues,
  109. total_word_count=total_words,
  110. )
  111. return report
  112. # ============================================================
  113. # 评分项覆盖检查
  114. # ============================================================
  115. def _check_criteria_coverage(
  116. self,
  117. outline: BidOutline,
  118. criteria: List[ScoringCriterion],
  119. ) -> List[ReviewIssue]:
  120. """检查每个评分项是否按 Step3 大纲填充到其绑定节点。
  121. 优先读取 Step3 持久化的评分绑定节点完整正文做 LLM 语义判断,
  122. 避免再使用全书 12,000 字均衡抽样而遗漏章中部材料。LLM 失败或大纲
  123. 缺少绑定时使用确定性关键词/空正文保底。
  124. """
  125. issues: List[ReviewIssue] = []
  126. all_content = _canonical_outline_content(outline)
  127. entries = list(getattr(outline, "evaluation_index_entries", []) or [])
  128. entries_by_criterion: dict[str, List[dict]] = {}
  129. for entry in entries:
  130. if entry.get("entry_type") != "scoring":
  131. continue
  132. cid = str(entry.get("criterion_id", "") or "")
  133. entries_by_criterion.setdefault(cid, []).append(entry)
  134. top_criteria = sorted(criteria, key=lambda c: c.max_score, reverse=True)
  135. llm_failed = False
  136. # 旧大纲缺少 evaluation_index_entries 时保留原有全书均衡抽样路径。
  137. if not entries_by_criterion:
  138. content_excerpt = _balanced_outline_excerpt(outline)
  139. try:
  140. for batch_start in range(0, len(top_criteria), 15):
  141. batch = top_criteria[batch_start:batch_start + 15]
  142. criteria_text = "\n".join(
  143. f"[{c.id}] {c.category} | {c.name} ({c.max_score}分): "
  144. f"{c.description[:200]}"
  145. for c in batch
  146. )
  147. result = self.llm.extract_json(
  148. system_prompt=(
  149. "你是投标文件质量审核专家。严格检查投标书内容是否充分覆盖了"
  150. "每条评分要求。只报告确实未覆盖或覆盖不足的项。"
  151. "你必须输出一个 JSON 对象,包含 uncovered 数组和 summary 字符串。"
  152. ),
  153. user_prompt=(
  154. f"## 本批评分标准({len(batch)}项)\n{criteria_text}\n\n"
  155. f"## 投标书内容(各章均衡抽样)\n{content_excerpt}\n\n"
  156. "请逐一检查本批评分项是否被充分覆盖。\n"
  157. '输出 JSON:{"uncovered": [{"criteria_id": "ID", '
  158. '"reason": "说明"}], "summary": "总结"}'
  159. ),
  160. max_tokens=16384,
  161. )
  162. if not isinstance(result, dict):
  163. llm_failed = True
  164. continue
  165. uncovered = result.get("uncovered", [])
  166. if isinstance(uncovered, list):
  167. for item in uncovered:
  168. if not isinstance(item, dict):
  169. continue
  170. cid = item.get("criteria_id", "")
  171. reason = item.get("reason", "")
  172. matched = [c for c in criteria if c.id == cid]
  173. if matched:
  174. c = matched[0]
  175. issues.append(ReviewIssue(
  176. chapter_id="*",
  177. severity="error",
  178. issue_type="missing_criterion",
  179. description=(
  180. f"[LLM深度检查] 评分项 [{c.id}] "
  181. f"{c.name} 未充分覆盖"
  182. ),
  183. suggestion=reason or f"需要补充{c.name}相关内容",
  184. ))
  185. except Exception as e:
  186. logger.warning(f"LLM 覆盖检查失败: {e},降级使用关键词检查")
  187. llm_failed = True
  188. else:
  189. scoring_entries = [
  190. entry for entry in entries
  191. if entry.get("entry_type") == "scoring"
  192. ]
  193. try:
  194. for batch_start in range(0, len(scoring_entries), 15):
  195. batch = scoring_entries[batch_start:batch_start + 15]
  196. blocks = []
  197. for entry in batch:
  198. evidence = self._bound_entry_evidence(outline, entry)
  199. evidence = (
  200. evidence[:2500] if evidence else "(未找到该评分项绑定正文)"
  201. )
  202. blocks.append(
  203. f"[{entry.get('source_id')}] "
  204. f"{entry.get('criterion_id')} | "
  205. f"{entry.get('display_name')}\n"
  206. f"评分要求:{entry.get('requirement', '')[:300]}\n"
  207. f"绑定节点:{entry.get('final_heading_id')} "
  208. f"{entry.get('final_heading_title')}\n"
  209. f"绑定节点正文:\n{evidence}"
  210. )
  211. payload = "\n\n".join(blocks)
  212. result = self.llm.extract_json(
  213. system_prompt=(
  214. "你是投标文件质量审核专家。系统已按 Step3 大纲把每个评分小项"
  215. "绑定到唯一标题节点,并给出该节点正文。请只检查这些评分小项"
  216. "是否在各自绑定节点中被充分覆盖。只报告确实未覆盖或覆盖不足的项。"
  217. "输出 JSON:{\"uncovered\": [{\"source_id\": \"ID\", "
  218. "\"reason\": \"说明\"}], \"summary\": \"总结\"}"
  219. ),
  220. user_prompt=(
  221. f"## 本批评分小项与绑定正文({len(batch)}项)\n{payload}\n\n"
  222. '请逐一检查并输出 JSON。'
  223. ),
  224. max_tokens=16384,
  225. )
  226. if not isinstance(result, dict):
  227. llm_failed = True
  228. logger.warning("LLM 覆盖检查返回非预期格式")
  229. continue
  230. uncovered = result.get("uncovered", [])
  231. if isinstance(uncovered, list):
  232. for item in uncovered:
  233. if not isinstance(item, dict):
  234. continue
  235. source_id = str(
  236. item.get("source_id")
  237. or item.get("criteria_id")
  238. or ""
  239. )
  240. reason = item.get("reason", "")
  241. entry = next(
  242. (
  243. e for e in scoring_entries
  244. if str(e.get("source_id", "")) == source_id
  245. or str(e.get("criterion_id", "")) == source_id
  246. ),
  247. None,
  248. )
  249. cid = (
  250. str(entry.get("criterion_id", ""))
  251. if entry
  252. else source_id.split("#", 1)[0]
  253. )
  254. matched = [c for c in criteria if c.id == cid]
  255. if matched:
  256. c = matched[0]
  257. issues.append(ReviewIssue(
  258. chapter_id="*",
  259. severity="error",
  260. issue_type="missing_criterion",
  261. description=(
  262. f"[LLM深度检查] 评分项 [{c.id}] "
  263. f"{c.name} 未按 Step3 大纲充分覆盖"
  264. ),
  265. suggestion=reason or f"在绑定节点补充{c.name}相关内容",
  266. ))
  267. if result.get("summary"):
  268. logger.info(f"LLM 覆盖检查: {result['summary']}")
  269. except Exception as e:
  270. logger.warning(f"LLM 覆盖检查失败: {e},降级使用关键词检查")
  271. llm_failed = True
  272. # ---- 确定性保底:只报告绑定节点空正文或未映射,不做模糊关键词误报 ----
  273. if llm_failed:
  274. scoring_entries = [
  275. entry for entry in entries
  276. if entry.get("entry_type") == "scoring"
  277. ]
  278. mapped_ids = {
  279. str(entry.get("criterion_id", ""))
  280. for entry in scoring_entries
  281. }
  282. for entry in scoring_entries:
  283. criterion_id = str(entry.get("criterion_id", ""))
  284. evidence = self._bound_entry_evidence(outline, entry)
  285. if not evidence.strip():
  286. issues.append(ReviewIssue(
  287. chapter_id="*",
  288. severity="error",
  289. issue_type="missing_criterion",
  290. description=(
  291. f"评分项 [{criterion_id}] "
  292. f"{entry.get('display_name', '')} 绑定节点内容为空"
  293. ),
  294. suggestion="在 Step3 绑定的最深层标题节点补充响应正文",
  295. ))
  296. for criterion in criteria:
  297. if criterion.id in mapped_ids or not criterion.name:
  298. continue
  299. if criterion.name[:4] in all_content:
  300. continue
  301. issues.append(ReviewIssue(
  302. chapter_id="*",
  303. severity="warning",
  304. issue_type="missing_criterion",
  305. description=(
  306. f"评分项 [{criterion.id}] {criterion.name} 未映射到任何章节"
  307. ),
  308. suggestion="需要将此项加入相关章节",
  309. ))
  310. return issues
  311. def _bound_criterion_evidence(
  312. self,
  313. outline: BidOutline,
  314. entries: List[dict],
  315. ) -> str:
  316. """收集某评分项绑定节点的完整正文(供旧调用方/单评分项证据使用)。"""
  317. nodes = self._bound_nodes(outline, entries)
  318. if not nodes:
  319. return ""
  320. parts = []
  321. for node in nodes:
  322. evidence = self._node_section_text(outline, node)
  323. if evidence:
  324. parts.append(f"【{node.id} {node.title}】\n{evidence}")
  325. return "\n\n".join(parts)
  326. def _bound_entry_evidence(self, outline: BidOutline, entry: dict) -> str:
  327. """返回单个评分小项绑定节点的正文,供 LLM 逐小项审核。"""
  328. nodes = self._bound_nodes(outline, [entry])
  329. if not nodes:
  330. return ""
  331. return self._node_section_text(outline, nodes[0])
  332. def _node_section_text(self, outline: BidOutline, node: Chapter) -> str:
  333. """返回绑定节点自身的正文;自身为空时按 Step3 标题关系切出对应小节。"""
  334. specific = "\n".join(
  335. part for part in (
  336. node.supplement_content or "",
  337. node.generated_content or "",
  338. )
  339. if part.strip()
  340. ).strip()
  341. if specific:
  342. return specific
  343. from step4_writing import _split_artifact_content_by_outline
  344. root_id = str(node.id).split(".", 1)[0]
  345. root = next(
  346. (chapter for chapter in outline.chapters if str(chapter.id) == root_id),
  347. None,
  348. )
  349. if root is None:
  350. return ""
  351. buckets = self._chapter_section_buckets(root)
  352. return "\n".join(buckets.get(str(node.id), []) or []).strip()
  353. def _chapter_section_buckets(self, root: Chapter) -> dict:
  354. """按章节缓存 Step3 标题切分结果,避免重复扫描大段正文。"""
  355. from step4_writing import _split_artifact_content_by_outline
  356. cache = getattr(self, "_section_bucket_cache", None)
  357. if cache is None:
  358. cache = {}
  359. self._section_bucket_cache = cache
  360. key = str(root.id)
  361. if key not in cache:
  362. cache[key] = _split_artifact_content_by_outline(root)
  363. return cache[key]
  364. def _bound_nodes(
  365. self,
  366. outline: BidOutline,
  367. entries: List[dict],
  368. ) -> List[Chapter]:
  369. """返回评分项在 Step3 大纲中绑定的最终标题节点。"""
  370. heading_ids = {
  371. str(entry.get("final_heading_id", "") or "")
  372. for entry in entries
  373. if entry.get("final_heading_id")
  374. }
  375. return [
  376. node for node in outline.flatten()
  377. if str(node.id) in heading_ids
  378. ]
  379. def _check_heading_format(self, outline: BidOutline) -> List[ReviewIssue]:
  380. """标题分值、同级序号与正文首行缩进的确定性检查。"""
  381. issues: List[ReviewIssue] = []
  382. issues.extend(find_heading_score_issues(outline))
  383. issues.extend(find_sibling_numbering_issues(outline))
  384. issues.extend(find_body_indent_issues(outline))
  385. return issues
  386. def _check_rejection_filling(self, outline: BidOutline) -> List[ReviewIssue]:
  387. """废标项是否按 Step3 大纲填充到第一章要求承诺函。"""
  388. entries = [
  389. entry for entry in getattr(outline, "evaluation_index_entries", []) or []
  390. if entry.get("entry_type") == "rejection"
  391. ]
  392. return find_rejection_filling_issues(outline, entries)
  393. def _check_cleanup_policies(
  394. self,
  395. outline: BidOutline,
  396. analysis: TenderAnalysis,
  397. ) -> List[ReviewIssue]:
  398. """无分包包号清理、重复正文与模板补充块一致性检查。"""
  399. issues: List[ReviewIssue] = []
  400. issues.extend(find_package_info_issues(
  401. outline, analysis_has_package_info(analysis)
  402. ))
  403. issues.extend(find_duplicate_info_issues(outline))
  404. issues.extend(find_template_integrity_issues(outline))
  405. return issues
  406. def _check_redundant_info(
  407. self,
  408. outline: BidOutline,
  409. analysis: TenderAnalysis,
  410. ) -> List[ReviewIssue]:
  411. """冗余信息删除:包号、重复正文,以及表前表后重复项目信息。"""
  412. issues: List[ReviewIssue] = []
  413. issues.extend(find_package_info_issues(
  414. outline, analysis_has_package_info(analysis)
  415. ))
  416. issues.extend(find_duplicate_info_issues(outline))
  417. issues.extend(self._check_redundant_info_with_llm(outline, analysis))
  418. return issues
  419. def _check_redundant_info_with_llm(
  420. self,
  421. outline: BidOutline,
  422. analysis: TenderAnalysis,
  423. ) -> List[ReviewIssue]:
  424. """用 LLM 判断表前表后重复信息及需要回填的 Step2 项目字段。"""
  425. fields = getattr(analysis, "project_fields", {}) or {}
  426. field_text = "\n".join(
  427. f"{key}:{value}" for key, value in fields.items()
  428. ) or "无"
  429. chapters = [
  430. chapter for chapter in outline.chapters
  431. if len((chapter.generated_content or "").strip()) >= 50
  432. ]
  433. if not chapters:
  434. return []
  435. max_workers = min(
  436. int(getattr(self.cfg, "max_concurrent_writers", 5) or 5),
  437. len(chapters),
  438. )
  439. logger.info(
  440. f"Step5 冗余信息 LLM 并发审核 {len(chapters)} 章 "
  441. f"(max_workers={max_workers})"
  442. )
  443. def _review_one(chapter):
  444. from llm_client import LLMClient
  445. return self._review_chapter_redundant(
  446. chapter, field_text, LLMClient()
  447. )
  448. issues: List[ReviewIssue] = []
  449. with ThreadPoolExecutor(max_workers=max_workers) as executor:
  450. futures = {
  451. executor.submit(_review_one, chapter): chapter
  452. for chapter in chapters
  453. }
  454. for future in as_completed(futures):
  455. issue = future.result()
  456. if issue is not None:
  457. issues.append(issue)
  458. return issues
  459. def _review_chapter_redundant(
  460. self,
  461. chapter: Chapter,
  462. field_text: str,
  463. llm,
  464. ):
  465. """单个章节的冗余信息 LLM 判断,返回 ReviewIssue 或 None。"""
  466. content = (chapter.generated_content or "").strip()
  467. try:
  468. result = llm.extract_json(
  469. system_prompt=(
  470. "你是投标文件冗余信息审核专家。请识别章节中表前/表后重复出现的"
  471. "项目信息、重复段落,以及无实际包号时应删除的包号行;并结合给定"
  472. "Step2 项目字段指出需要回填/补全的字段。只输出 JSON,不要改写正文。"
  473. ),
  474. user_prompt=(
  475. f"## Step2 项目字段\n{field_text}\n\n"
  476. f"## 章节正文(前 8000 字)\n{content[:8000]}\n\n"
  477. '输出 JSON:{"removals": [{"text": "需删除原文", "reason": "原因"}], '
  478. '"fills": [{"label": "服务内容", "value": "..."}]}'
  479. ),
  480. max_tokens=16384,
  481. )
  482. removals = result.get("removals", []) if isinstance(result, dict) else []
  483. fills = result.get("fills", []) if isinstance(result, dict) else []
  484. if removals or fills:
  485. return ReviewIssue(
  486. chapter_id=chapter.id,
  487. severity="error",
  488. issue_type="redundant_info",
  489. description=(
  490. f"第{chapter.id}章存在需清理或回填的表前表后冗余信息"
  491. ),
  492. suggestion=json.dumps(
  493. {"removals": removals, "fills": fills},
  494. ensure_ascii=False,
  495. ),
  496. )
  497. except Exception as exc:
  498. logger.warning(f"第{chapter.id}章冗余信息 LLM 判断失败,使用确定性结果: {exc}")
  499. return None
  500. def _check_response_requirements_coverage(
  501. self,
  502. outline: BidOutline,
  503. requirements: List[ResponseRequirement],
  504. ) -> List[ReviewIssue]:
  505. """检查必须提交的商务/技术响应清单是否被覆盖。
  506. 优先使用 LLM 语义判断,避免关键词误报;LLM 失败时降级关键词兜底。
  507. """
  508. issues: List[ReviewIssue] = []
  509. required = [r for r in requirements if getattr(r, "required", True)]
  510. if not required:
  511. return issues
  512. all_content = _canonical_outline_content(outline)
  513. content_excerpt = _balanced_outline_excerpt(outline)
  514. llm_failed = False
  515. try:
  516. req_text = "\n".join(
  517. f"[{r.id}] {r.category or '响应要求'} | {r.name}:{r.description}"
  518. for r in required
  519. )
  520. result = self.llm.extract_json(
  521. system_prompt=(
  522. "你是投标文件审核专家。请判断投标书正文是否逐项覆盖了"
  523. "商务/技术响应文件清单中的必交项。只报告确实未覆盖或覆盖不足的项。"
  524. ),
  525. user_prompt=(
  526. f"## 必交响应清单(共{len(required)}项)\n{req_text}\n\n"
  527. f"## 投标书内容(各章均衡抽样)\n{content_excerpt}\n\n"
  528. '输出严格 JSON:{"missing": [{"id": "RR-01", "reason": "说明"}]}'
  529. ),
  530. max_tokens=16384,
  531. )
  532. if isinstance(result, dict):
  533. missing = result.get("missing", [])
  534. if isinstance(missing, list):
  535. for item in missing:
  536. if not isinstance(item, dict):
  537. continue
  538. rid = item.get("id", "")
  539. reason = item.get("reason", "")
  540. matched = [r for r in required if r.id == rid]
  541. if matched:
  542. r = matched[0]
  543. issues.append(ReviewIssue(
  544. chapter_id="*",
  545. severity="warning",
  546. issue_type="response_incomplete",
  547. description=f"[LLM检查] 响应清单项 [{r.id}] {r.name} 未充分覆盖",
  548. suggestion=reason or f"补充“{r.name}”的说明或材料引用",
  549. ))
  550. else:
  551. llm_failed = True
  552. except Exception as e:
  553. logger.warning(f"响应清单 LLM 覆盖检查失败: {e},降级关键词检查")
  554. llm_failed = True
  555. if llm_failed:
  556. for req in required:
  557. name = (req.name or "").strip()
  558. desc = (req.description or "").strip()
  559. keyword = name[:8] or desc[:8]
  560. if not keyword:
  561. continue
  562. if keyword in all_content or name in all_content:
  563. continue
  564. issues.append(ReviewIssue(
  565. chapter_id="*",
  566. severity="warning",
  567. issue_type="response_incomplete",
  568. description=f"响应清单项 [{req.id}] {name} 未在正文中明确出现",
  569. suggestion=f"建议补充“{name}”的说明或证明材料引用",
  570. ))
  571. return issues
  572. def _check_procurement_requirements_coverage(
  573. self,
  574. outline: BidOutline,
  575. procurement_requirements: str,
  576. ) -> List[ReviewIssue]:
  577. """对采购需求关键要求做覆盖检查。
  578. 优先使用 LLM 判断;LLM 失败时降级为关键词兜底。
  579. """
  580. issues: List[ReviewIssue] = []
  581. if not procurement_requirements:
  582. return issues
  583. all_content = _canonical_outline_content(outline)
  584. content_excerpt = _balanced_outline_excerpt(outline)
  585. llm_failed = False
  586. try:
  587. result = self.llm.extract_json(
  588. system_prompt=(
  589. "你是投标文件审核专家。请判断投标书正文是否充分覆盖采购需求中的关键要求。"
  590. "只报告确实未覆盖或覆盖不足的要点。"
  591. ),
  592. user_prompt=(
  593. f"## 采购需求关键要求\n{procurement_requirements[:6000]}\n\n"
  594. f"## 投标书内容(各章均衡抽样)\n{content_excerpt}\n\n"
  595. '输出严格 JSON:{"missing": ["要点1", "要点2"], "summary": "一句话总结"}'
  596. ),
  597. max_tokens=16384,
  598. )
  599. if isinstance(result, dict):
  600. missing = result.get("missing", [])
  601. if isinstance(missing, list) and missing:
  602. issues.append(ReviewIssue(
  603. chapter_id="*",
  604. severity="warning",
  605. issue_type="procurement_incomplete",
  606. description="[LLM检查] 采购需求关键要求覆盖不足: " + ", ".join(
  607. str(x)[:40] for x in missing[:5]
  608. ),
  609. suggestion="检查正文是否逐项响应采购需求中的关键要求",
  610. ))
  611. else:
  612. llm_failed = True
  613. except Exception as e:
  614. logger.warning(f"采购需求 LLM 覆盖检查失败: {e},降级关键词检查")
  615. llm_failed = True
  616. if llm_failed:
  617. key_lines = [
  618. line.strip()
  619. for line in procurement_requirements.splitlines()
  620. if line.strip() and (":" in line or ":" in line)
  621. ]
  622. missing = []
  623. for line in key_lines[:8]:
  624. head = line.split(":")[0].split(":")[0].strip()
  625. if head and head not in all_content and line[:20] not in all_content:
  626. missing.append(head[:30])
  627. if missing:
  628. issues.append(ReviewIssue(
  629. chapter_id="*",
  630. severity="warning",
  631. issue_type="procurement_incomplete",
  632. description=f"采购需求关键要求可能未覆盖: {', '.join(missing[:5])}",
  633. suggestion="检查正文是否逐项响应采购需求中的关键要求",
  634. ))
  635. return issues
  636. # ============================================================
  637. # 废标项规避检查
  638. # ============================================================
  639. def _check_rejection_avoidance(
  640. self,
  641. outline: BidOutline,
  642. rejection_items: List[RejectionItem],
  643. ) -> List[ReviewIssue]:
  644. """检查是否触及废标条件(LLM 为主,正则保底)"""
  645. issues: List[ReviewIssue] = []
  646. all_content = _canonical_outline_content(outline)
  647. content_excerpt = _balanced_outline_excerpt(outline, max_chars=10000)
  648. # ---- 策略 A: LLM 废标风险分析(优先) ----
  649. if rejection_items:
  650. try:
  651. rejection_text = "\n".join(
  652. f"[{r.id}] {r.description[:200]}"
  653. for r in rejection_items[:10]
  654. )
  655. result = self.llm.extract_json(
  656. system_prompt=(
  657. "你是投标文件合规审核专家。检查投标书内容是否可能触发废标条件。"
  658. "只报告有实际风险的问题,不要报告已经正确规避的情况。"
  659. "你必须输出一个 JSON 对象,包含 risks 数组和 summary 字符串。"
  660. ),
  661. user_prompt=(
  662. f"## 废标条件\n{rejection_text}\n\n"
  663. f"## 投标书内容(各章均衡抽样)\n{content_excerpt}\n\n"
  664. f"请检查是否可能触发废标。\n"
  665. f'输出 JSON 对象格式:{{"risks": [{{"item_id": "RI-01", "description": "风险描述"}}], "summary": "总结"}}\n'
  666. f'如果没有风险,输出 {{"risks": [], "summary": "所有废标条件均已正确规避"}}'
  667. ),
  668. max_tokens=8192,
  669. )
  670. if isinstance(result, dict):
  671. risks = result.get("risks", [])
  672. if isinstance(risks, list):
  673. for risk in risks:
  674. if isinstance(risk, dict):
  675. issues.append(ReviewIssue(
  676. chapter_id="*",
  677. severity="error",
  678. issue_type="rejection_involved",
  679. description=f"[LLM检查] 废标风险: {risk.get('description', '')}",
  680. suggestion="请人工检查并修正相关内容",
  681. ))
  682. if result.get("summary"):
  683. logger.info(f"LLM 废标检查: {result['summary']}")
  684. return issues # LLM 成功,直接返回
  685. else:
  686. logger.warning("LLM 废标检查返回非预期格式,降级使用正则检查")
  687. except Exception as e:
  688. logger.warning(f"LLM 废标检查失败: {e},降级使用正则检查")
  689. # ---- 策略 B: 正则保底(仅 LLM 失败时使用) ----
  690. danger_patterns = [
  691. (r"(?i)(投标无效|废标|否决投标)", "内容中包含废标相关术语"),
  692. (r"(?i)(无.*资质|不具备.*条件|不符合.*要求)", "内容中可能存在否定性资格描述"),
  693. (r"(?i)(无法满足|不能满足|不响应)", "内容中可能存在对要求的否定响应"),
  694. ]
  695. for pattern, desc in danger_patterns:
  696. matches = re.findall(pattern, all_content)
  697. if matches and len(matches) > 2:
  698. issues.append(ReviewIssue(
  699. chapter_id="*",
  700. severity="warning",
  701. issue_type="rejection_involved",
  702. description=f"检测到可能的废标风险: {desc} (出现 {len(matches)} 次)",
  703. suggestion="请人工检查这些上下文是否构成废标风险",
  704. ))
  705. for item in rejection_items[:5]:
  706. keywords = item.description[:10]
  707. if keywords and keywords in all_content:
  708. issues.append(ReviewIssue(
  709. chapter_id="*",
  710. severity="warning",
  711. issue_type="rejection_involved",
  712. description=f"废标项关键词出现在正文中: {keywords}...",
  713. suggestion="请确认该处描述不会触发废标条件",
  714. ))
  715. return issues
  716. # ============================================================
  717. # 空章节检查
  718. # ============================================================
  719. def _check_empty_chapters(self, outline: BidOutline) -> List[ReviewIssue]:
  720. """检查是否有空章节
  721. 商务标子章节(如 1.1 资格条件响应表、2.1 投标函)是目录骨架节点:
  722. 其实际内容由父章的模板文本承载,表格/函件在 Step 6 从 Step 1
  723. 提取的招标表格替换并填充。因此只要父章有内容,这些子章节不视为
  724. 空章节,避免误报并触发 LLM 对表格做无意义补写。
  725. """
  726. issues = []
  727. parent_content = {}
  728. parent_modes = {}
  729. for ch in outline.chapters:
  730. if ch.id.isdigit():
  731. parent_content[ch.id] = ch.generated_content
  732. parent_modes[ch.id] = get_chapter_edit_mode(ch.title)
  733. for ch in outline.flatten():
  734. if not ch.generated_content.strip():
  735. parent_id = ch.id.split(".")[0]
  736. parent_text = parent_content.get(parent_id, "").strip()
  737. if "." in ch.id and parent_text:
  738. if (
  739. ch.chapter_type == ChapterType.BUSINESS
  740. or parent_modes.get(parent_id)
  741. == ChapterEditMode.RESTRUCTURE
  742. ):
  743. # 商务骨架由父章模板承载;重构章的旧模板
  744. # 子节也不再是必须单独生成的内容单元。
  745. continue
  746. # 模板容器标题自身可能没有独立正文,内容在父章实际文本中;
  747. # 只要父章文本出现该子标题,就视为该小节非空,避免误报。
  748. heading_present = any(
  749. _heading_match_token(text) in _heading_match_token(parent_text)
  750. for text in (ch.title, ch.template_original_title)
  751. if text and _heading_match_token(text)
  752. )
  753. if heading_present:
  754. continue
  755. issues.append(ReviewIssue(
  756. chapter_id=ch.id,
  757. severity="error",
  758. issue_type="style",
  759. description=f"章节 {ch.id} {ch.title} 内容为空",
  760. suggestion=f"需要撰写该章节内容(目标 {ch.word_count_target:,} 字)",
  761. ))
  762. return issues
  763. # ============================================================
  764. # 章节编号检查
  765. # ============================================================
  766. def _check_chapter_numbering(self, outline: BidOutline) -> List[ReviewIssue]:
  767. """检查章节编号是否符合规范"""
  768. issues = []
  769. tech_chapters = [c for c in outline.chapters if c.chapter_type.value == "technical"]
  770. for ch in tech_chapters:
  771. try:
  772. ch_num = int(ch.id)
  773. if ch_num < 3:
  774. issues.append(ReviewIssue(
  775. chapter_id=ch.id,
  776. severity="error",
  777. issue_type="style",
  778. description=f"技术标章节 {ch.id} 编号应 >= 3(技术标从第三章开始)",
  779. suggestion=f"将技术标章节编号调整为 3 起",
  780. ))
  781. except ValueError:
  782. pass
  783. return issues
  784. def _check_demand_understanding_scoring_coverage(
  785. self,
  786. outline: BidOutline,
  787. criteria: List[ScoringCriterion],
  788. ) -> List[ReviewIssue]:
  789. """确保“需求理解”章覆盖名称相同评分项的具体评分维度。
  790. 评分大类可能是“技术方案”,其小评分标题按结构规则放在第四章;
  791. 但模板另有“需求理解”一级章时,该章正文仍必须覆盖服务定位、预期
  792. 目标、重点难点及应对/改进措施,不能只依赖第四章的全局命中。
  793. """
  794. demand_chapter = next(
  795. (chapter for chapter in outline.chapters if "需求理解" in chapter.title),
  796. None,
  797. )
  798. if demand_chapter is None:
  799. return []
  800. content = _chapter_effective_content(demand_chapter)
  801. issues = []
  802. for criterion in criteria:
  803. criterion_text = " ".join((
  804. criterion.name or "", criterion.description or ""
  805. ))
  806. if not any(key in criterion_text for key in ("需求理解", "重点难点")):
  807. continue
  808. groups = []
  809. for token in ("服务定位", "预期目标", "重点", "难点"):
  810. if token in criterion.description:
  811. groups.append((token,))
  812. if "应对" in criterion.description or "改进措施" in criterion.description:
  813. groups.append(("应对", "改进措施"))
  814. if not groups:
  815. groups.append((criterion.name,))
  816. missing = [
  817. "/".join(group) for group in groups
  818. if not any(token and token in content for token in group)
  819. ]
  820. if missing:
  821. issues.append(ReviewIssue(
  822. chapter_id=demand_chapter.id,
  823. severity="error",
  824. issue_type="demand_scoring_coverage",
  825. description=(
  826. f"需求理解章未完整覆盖评分项 [{criterion.id}] "
  827. f"{criterion.name}:缺少 {', '.join(missing)}"
  828. ),
  829. suggestion=(
  830. "在需求理解章既有结构内补充服务定位、预期目标、"
  831. "重点难点分析及应对或改进措施,不新增评分标题。"
  832. ),
  833. ))
  834. return issues
  835. # ============================================================
  836. # 辅助
  837. # ============================================================
  838. def _count_template_words(self, outline: BidOutline) -> int:
  839. """估算模板内容字数。
  840. 历史实现硬编码返回 5000,但:
  841. 1. 商务章等模板内容已在 Step 4 载入 generated_content,flatten
  842. 统计已包含它们,再凭空加 5000 属于双重计数;
  843. 2. 未被章节载入的模板表格/函件字数无法从 outline 得知,凭空估算
  844. 可能掩盖真实字数缺口,导致"缺字仍达标"。
  845. 因此不虚增;若将来需要计入模板表格字数,应显式传入模板统计。
  846. """
  847. return 0