policies.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. """Step 5 的确定性审核策略。
  2. 该模块只做不依赖 LLM 的结构/文本检查与修复,供 reviewer 和 fixer 共同使用:
  3. - 标题层级与同级序号
  4. - 标题是否携带分值
  5. - 正文首行缩进提示
  6. - 无分包时的包号清理
  7. - 重复内容清理
  8. - 模板基块与评分/废标补充块的对应关系
  9. - 废标项是否按 Step3 绑定节点填充
  10. """
  11. from __future__ import annotations
  12. import re
  13. from typing import Dict, Iterable, List, Optional, Tuple
  14. from models import BidOutline, Chapter, ReviewIssue
  15. try:
  16. from step3_outlining.scoring_structure import _next_prefix
  17. except Exception: # pragma: no cover - 仅用于极端导入环境兜底
  18. def _next_prefix(level: int, index: int) -> str:
  19. return f"{index}. "
  20. FULLWIDTH_SPACE = "\u3000"
  21. _PACKAGE_KEYWORDS = ("包号", "包件")
  22. _PACKAGE_LINE_RE = re.compile(r"(包号|包件)")
  23. _PROTECTED_SIGNATURE_RE = re.compile(
  24. r"^\s*(投标人授权代表签字|投标人(公章)|投标人\(公章\)|日期|法定代表人|"
  25. r"授权代表|签署人)[::((]"
  26. )
  27. _PLACEHOLDER_RE = re.compile(r"%{1,3}\s*([^%]+?)\s*%{1,3}")
  28. _SCORE_SUFFIX_RE = re.compile(
  29. r"[((]\s*\d+(?:\.\d+)?\s*分\s*[))]\s*$"
  30. )
  31. _PREFIX_PATTERNS: Dict[int, str] = {
  32. 1: r"^第\s*[一二三四五六七八九十百零\d]+\s*章\s*",
  33. 2: r"^[一二三四五六七八九十百]+\s*[、,]\s*",
  34. 3: r"^[((][一二三四五六七八九十百\d]+[))]\s*",
  35. 4: r"^(?:\d+[.、.]|[((]\d+[))])\s*",
  36. }
  37. _CHINESE_DIGITS = "零一二三四五六七八九"
  38. def _chinese_to_int(raw: str) -> int:
  39. """把 1-99 的中文数字或阿拉伯数字转为 int;无法识别时返回 0。"""
  40. text = (raw or "").strip()
  41. if text.isdigit():
  42. return int(text)
  43. if not text:
  44. return 0
  45. if text == "十":
  46. return 10
  47. if text.startswith("十") and len(text) == 2:
  48. return 10 + _chinese_to_int(text[1])
  49. if text.endswith("十") and len(text) == 2:
  50. return _chinese_to_int(text[0]) * 10
  51. if "十" in text:
  52. tens, ones = text.split("十", 1)
  53. return _chinese_to_int(tens) * 10 + _chinese_to_int(ones)
  54. if text in _CHINESE_DIGITS:
  55. return _CHINESE_DIGITS.index(text)
  56. return 0
  57. def split_heading_number(text: str) -> Optional[Tuple[int, str]]:
  58. """解析标题前缀,返回 (level, 序号原文);不是已知标题时返回 None。"""
  59. value = (text or "").strip()
  60. match = re.match(r"^第\s*([一二三四五六七八九十百零\d]+)\s*章", value)
  61. if match:
  62. return 1, match.group(1)
  63. match = re.match(r"^([一二三四五六七八九十百]+)[、,]", value)
  64. if match:
  65. return 2, match.group(1)
  66. match = re.match(r"^[((]([一二三四五六七八九十百\d]+)[))]", value)
  67. if match:
  68. return 3, match.group(1)
  69. match = re.match(r"^(\d+)[.、.]", value)
  70. if match:
  71. return 4, match.group(1)
  72. match = re.match(r"^[((](\d+)[))]", value)
  73. if match:
  74. return 4, match.group(1)
  75. return None
  76. def is_heading_line(line: str) -> bool:
  77. """判断一行是否属于标题/编号行(不参与正文首行缩进检查)。"""
  78. value = (line or "").strip()
  79. if not value:
  80. return False
  81. if split_heading_number(value) is not None:
  82. return True
  83. return bool(re.match(r"^(?:【|#{1,6}\s)", value))
  84. def is_list_or_table_line(line: str) -> bool:
  85. """判断一行是否为列表、表格或其他不应缩进的辅助行。"""
  86. value = (line or "").strip()
  87. if not value:
  88. return True
  89. if value.startswith("|"):
  90. return True
  91. if value.startswith(("-", "*", "·", "●", "◆", "■", "▍", "—")):
  92. return True
  93. if re.match(r"^\d+[.、.]\s", value) or re.match(r"^[((]\d+[))]\s", value):
  94. return True
  95. return False
  96. def strip_existing_prefix(text: str, level: int) -> str:
  97. """去掉标题开头的既有编号前缀,保留标题本体。"""
  98. pattern = _PREFIX_PATTERNS.get(level)
  99. if not pattern:
  100. return (text or "").strip()
  101. return re.sub(pattern, "", text or "", count=1).strip()
  102. def strip_score_from_title(title: str) -> str:
  103. """去掉新增评分标题末尾携带的分值,例如“(8分)”。"""
  104. cleaned = _SCORE_SUFFIX_RE.sub("", (title or "").strip())
  105. return cleaned.strip()
  106. def iter_body_lines(chapter: Chapter) -> Iterable[str]:
  107. """遍历章节正文中的普通正文行(不含标题、列表、表格与空白)。"""
  108. def _emit(content: str):
  109. for raw in (content or "").splitlines():
  110. line = raw.strip()
  111. if not line:
  112. continue
  113. if is_heading_line(line) or is_list_or_table_line(line):
  114. continue
  115. if _PLACEHOLDER_RE.search(line):
  116. continue
  117. yield line
  118. def _walk(node: Chapter):
  119. yield node
  120. for child in node.children or []:
  121. yield from _walk(child)
  122. for node in _walk(chapter):
  123. yield from _emit(node.generated_content or "")
  124. yield from _emit(node.supplement_content or "")
  125. def _direct_bindings(chapter: Chapter) -> bool:
  126. return bool(
  127. getattr(chapter, "direct_scoring_bindings", [])
  128. or getattr(chapter, "direct_scoring_criteria", [])
  129. or getattr(chapter, "direct_rejection_bindings", [])
  130. )
  131. def find_heading_score_issues(outline: BidOutline) -> List[ReviewIssue]:
  132. """发现新增评分/废标标题仍携带分值的问题。"""
  133. issues: List[ReviewIssue] = []
  134. for node in outline.flatten():
  135. if not _direct_bindings(node):
  136. continue
  137. cleaned = strip_score_from_title(node.title)
  138. if cleaned != (node.title or "").strip():
  139. issues.append(ReviewIssue(
  140. chapter_id=node.id,
  141. severity="error",
  142. issue_type="format_heading",
  143. description=f"标题 {node.id} {node.title} 携带分值,不应附加分值",
  144. suggestion="去掉标题末尾分值,分值保留在评分要求和索引中",
  145. ))
  146. return issues
  147. def renumber_sibling_children(parent: Chapter) -> int:
  148. """按顺序重排直接子标题的同一级序号,返回修改数量。"""
  149. children = list(parent.children or [])
  150. if not children:
  151. return 0
  152. level = parent.level + 1
  153. if level not in (2, 3, 4):
  154. return 0
  155. changed = 0
  156. position = 1
  157. for child in children:
  158. if child.level != level:
  159. continue
  160. if split_heading_number(child.title) is None:
  161. # 未带标准编号的标题可能是模板原标题,保持不动。
  162. continue
  163. prefix = _next_prefix(level, position)
  164. body = strip_existing_prefix(child.title, level)
  165. new_title = prefix + body
  166. if new_title != (child.title or "").strip():
  167. child.title = new_title
  168. changed += 1
  169. position += 1
  170. return changed
  171. def find_sibling_numbering_issues(outline: BidOutline) -> List[ReviewIssue]:
  172. """发现同级子标题序号重复、跳号或乱序的问题。"""
  173. issues: List[ReviewIssue] = []
  174. for parent in outline.flatten():
  175. children = [
  176. child for child in (parent.children or [])
  177. if child.level == parent.level + 1 and child.level in (2, 3, 4)
  178. ]
  179. if not children:
  180. continue
  181. problems = []
  182. seen: Dict[int, str] = {}
  183. position = 1
  184. for child in children:
  185. parsed = split_heading_number(child.title)
  186. if parsed is None:
  187. continue
  188. number = _chinese_to_int(parsed[1])
  189. if number == 0 or number != position:
  190. problems.append(f"{child.id} {child.title}")
  191. if number in seen:
  192. problems.append(f"{child.id} {child.title} 与 {seen[number]} 重复序号")
  193. seen[number] = child.id
  194. position += 1
  195. if problems:
  196. issues.append(ReviewIssue(
  197. chapter_id=parent.id,
  198. severity="error",
  199. issue_type="format_numbering",
  200. description=(
  201. f"父标题 {parent.id} {parent.title} 下同级子标题序号异常: "
  202. + ";".join(problems[:6])
  203. ),
  204. suggestion="按顺序重新生成同级中文序号",
  205. ))
  206. return issues
  207. def find_body_indent_issues(outline: BidOutline) -> List[ReviewIssue]:
  208. """统计正文未以两个全角空格开头的段落,供 Step6 统一格式化。"""
  209. issues: List[ReviewIssue] = []
  210. for chapter in outline.chapters:
  211. missing = [
  212. line for line in iter_body_lines(chapter)
  213. if not line.startswith(FULLWIDTH_SPACE * 2)
  214. ]
  215. if missing:
  216. issues.append(ReviewIssue(
  217. chapter_id=chapter.id,
  218. severity="warning",
  219. issue_type="format_body",
  220. description=f"第{chapter.id}章有 {len(missing)} 段正文未以两个全角空格开头",
  221. suggestion="正文首行缩进将由 Step6 统一应用,Step5 仅记录不改写文本",
  222. ))
  223. return issues
  224. def remove_package_lines(content: str) -> Tuple[str, int]:
  225. """删除无分包项目中与包号/包件相关的整行信息。"""
  226. if not content:
  227. return content, 0
  228. kept = []
  229. removed = 0
  230. for line in content.splitlines():
  231. if _PACKAGE_LINE_RE.search(line):
  232. removed += 1
  233. continue
  234. kept.append(line)
  235. return "\n".join(kept), removed
  236. def find_package_info_issues(
  237. outline: BidOutline,
  238. has_packages: bool,
  239. ) -> List[ReviewIssue]:
  240. """无分包时发现仍残留包号/包件信息的章节。"""
  241. if has_packages:
  242. return []
  243. issues: List[ReviewIssue] = []
  244. for chapter in outline.flatten():
  245. content = chapter.generated_content or ""
  246. if not _PACKAGE_LINE_RE.search(content):
  247. continue
  248. count = sum(1 for line in content.splitlines() if _PACKAGE_LINE_RE.search(line))
  249. issues.append(ReviewIssue(
  250. chapter_id=chapter.id,
  251. severity="error",
  252. issue_type="package_info",
  253. description=f"项目无分包,但章节 {chapter.id} 仍残留 {count} 行包号/包件信息",
  254. suggestion="删除章节中的包号/包件字段,避免多余信息",
  255. ))
  256. return issues
  257. def analysis_has_package_info(analysis) -> bool:
  258. """依据 Step2 LLM 提取结果判断项目是否真的存在包号/包件信息。
  259. Step2 的 _extract_packages 会严格区分“实际包号值”和“模板占位/说明性文字”;
  260. 这里以 packages 为主,并兼容 project_fields 中出现包号/包件键的情况。
  261. """
  262. if getattr(analysis, "packages", None):
  263. return True
  264. fields = getattr(analysis, "project_fields", {}) or {}
  265. return any(
  266. key in fields
  267. for key in ("包号", "包件", "包件号", "包件名称", "包名")
  268. )
  269. def _normalize_line(line: str) -> str:
  270. return re.sub(r"[\s\u3000]+", "", line or "")
  271. def find_duplicate_info_issues(outline: BidOutline) -> List[ReviewIssue]:
  272. """发现章节正文中重复出现的非空正文行。"""
  273. issues: List[ReviewIssue] = []
  274. for chapter in outline.chapters:
  275. seen: Dict[str, str] = {}
  276. duplicates: List[str] = []
  277. for line in iter_body_lines(chapter):
  278. if _PROTECTED_SIGNATURE_RE.match(line.strip()):
  279. continue
  280. key = _normalize_line(line)
  281. if len(key) < 12:
  282. continue
  283. if key in seen:
  284. if key not in duplicates:
  285. duplicates.append(seen[key])
  286. else:
  287. seen[key] = line
  288. if duplicates:
  289. issues.append(ReviewIssue(
  290. chapter_id=chapter.id,
  291. severity="error",
  292. issue_type="duplicate_content",
  293. description=f"第{chapter.id}章存在 {len(duplicates)} 段重复正文",
  294. suggestion="删除重复段落,保留首次出现的内容",
  295. ))
  296. return issues
  297. def dedupe_chapter_content(content: str) -> Tuple[str, int]:
  298. """按最小标题节删除正文中重复的非空行,保留首次出现顺序。"""
  299. if not content:
  300. return content, 0
  301. seen = set()
  302. kept = []
  303. removed = 0
  304. for line in content.splitlines():
  305. if _PROTECTED_SIGNATURE_RE.match(line.strip()):
  306. kept.append(line)
  307. continue
  308. if is_heading_line(line):
  309. seen = set()
  310. kept.append(line)
  311. continue
  312. key = _normalize_line(line)
  313. if key and len(key) >= 12 and key in seen:
  314. removed += 1
  315. continue
  316. if key:
  317. seen.add(key)
  318. kept.append(line)
  319. return "\n".join(kept), removed
  320. def find_template_integrity_issues(outline: BidOutline) -> List[ReviewIssue]:
  321. """发现模板基块与评分/废标补充块的对应关系不一致。"""
  322. issues: List[ReviewIssue] = []
  323. entries = list(getattr(outline, "evaluation_index_entries", []) or [])
  324. entries_by_heading: Dict[str, set] = {}
  325. for entry in entries:
  326. heading_id = str(entry.get("final_heading_id", "") or "")
  327. if heading_id:
  328. entries_by_heading.setdefault(heading_id, set()).add(
  329. str(entry.get("entry_type", ""))
  330. )
  331. for node in outline.flatten():
  332. for block in (node.content_blocks or []):
  333. block_type = str(block.get("block_type", ""))
  334. if block_type not in ("scoring_supplement", "rejection_supplement"):
  335. continue
  336. kind = "scoring" if block_type == "scoring_supplement" else "rejection"
  337. target_id = str(block.get("target_heading_id", "") or "") or str(node.id)
  338. expected = entries_by_heading.get(target_id, set())
  339. if kind not in expected:
  340. issues.append(ReviewIssue(
  341. chapter_id=node.id,
  342. severity="error",
  343. issue_type="duplicate_content",
  344. description=(
  345. f"节点 {node.id} {node.title} 存在未被 Step3 大纲授权的"
  346. f"{'评分' if kind == 'scoring' else '废标'}补充块"
  347. ),
  348. suggestion="删除多余补充块,仅保留 Step3 绑定的授权内容",
  349. ))
  350. return issues
  351. _REJECTION_CONSEQUENCE_SUFFIXES = (
  352. r"(?:,|;|。)?(?:其)?投标(?:将)?(?:被)?(?:认定为|作为|按)?无效(?:投标)?",
  353. r"(?:,|;|。)?(?:其)?投标无效",
  354. r"(?:,|;|。)?(?:招标人|采购人|评标委员会)?(?:将|可|有权)?(?:予以)?拒绝"
  355. r"(?:其)?(?:投标|接收|参加本次采购活动)?",
  356. r"(?:,|;|。)?不能通过(?:资格性|符合性)?审查",
  357. )
  358. def compact_rejection_requirement(text: str) -> str:
  359. """保留废标条件本体,去掉每条末尾重复出现的处理后果。"""
  360. value = re.sub(r"\s+", "", str(text or "")).strip("。;; ")
  361. previous = None
  362. while value and value != previous:
  363. previous = value
  364. for suffix in _REJECTION_CONSEQUENCE_SUFFIXES:
  365. value = re.sub(f"{suffix}[。;; ]*$", "", value).strip(",。;; ")
  366. return value
  367. def find_rejection_filling_issues(
  368. outline: BidOutline,
  369. rejection_entries: List[dict],
  370. ) -> List[ReviewIssue]:
  371. """检查废标项是否全部填充到 Step3 绑定的承诺节点。"""
  372. if not rejection_entries:
  373. return []
  374. nodes = [
  375. node for node in outline.flatten()
  376. if getattr(node, "direct_rejection_bindings", [])
  377. ]
  378. # Step3 把所有废标项唯一绑定到第一章要求承诺函;未找到时给出可定位问题。
  379. if not nodes:
  380. return [ReviewIssue(
  381. chapter_id="*",
  382. severity="error",
  383. issue_type="rejection_filling",
  384. description="Step3 大纲存在废标项,但未找到直接绑定节点",
  385. suggestion="在第一章要求承诺函绑定废标项",
  386. )]
  387. target = nodes[0]
  388. content = "\n".join([
  389. target.supplement_content or "",
  390. target.generated_content or "",
  391. ])
  392. missing = []
  393. for entry in rejection_entries:
  394. requirement = compact_rejection_requirement(
  395. str(entry.get("requirement", ""))
  396. )
  397. if requirement and requirement not in content:
  398. missing.append(str(entry.get("source_id", "")))
  399. if missing:
  400. return [ReviewIssue(
  401. chapter_id=target.id,
  402. severity="error",
  403. issue_type="rejection_filling",
  404. description=(
  405. f"要求承诺函未完整填充 {len(missing)} 条废标项: "
  406. + ", ".join(missing[:8])
  407. ),
  408. suggestion="把缺失废标项写入该承诺节点,不得散落到其他章节",
  409. )]
  410. return []
  411. __all__ = [
  412. "split_heading_number",
  413. "strip_existing_prefix",
  414. "strip_score_from_title",
  415. "is_heading_line",
  416. "is_list_or_table_line",
  417. "iter_body_lines",
  418. "renumber_sibling_children",
  419. "find_heading_score_issues",
  420. "find_sibling_numbering_issues",
  421. "find_body_indent_issues",
  422. "remove_package_lines",
  423. "find_package_info_issues",
  424. "dedupe_chapter_content",
  425. "find_duplicate_info_issues",
  426. "find_template_integrity_issues",
  427. "compact_rejection_requirement",
  428. "find_rejection_filling_issues",
  429. ]