test_step5_review.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. """Step 5 新审核策略的快速回归测试(不调用真实 LLM)。"""
  2. import unittest
  3. from models import (
  4. BidOutline,
  5. Chapter,
  6. ChapterType,
  7. ReviewIssue,
  8. ScoringCriterion,
  9. TenderAnalysis,
  10. )
  11. from step5_reviewing.fixer import _Fixer
  12. from step5_reviewing.policies import (
  13. dedupe_chapter_content,
  14. find_duplicate_info_issues,
  15. find_heading_score_issues,
  16. find_package_info_issues,
  17. find_rejection_filling_issues,
  18. find_sibling_numbering_issues,
  19. find_template_integrity_issues,
  20. remove_package_lines,
  21. renumber_sibling_children,
  22. strip_score_from_title,
  23. )
  24. from step5_reviewing.reviewer import _Reviewer
  25. class FakeLlm:
  26. def __init__(self, response=None, error=None):
  27. self.response = response if response is not None else {}
  28. self.error = error
  29. self.calls = []
  30. def extract_json(self, **_kwargs):
  31. self.calls.append(_kwargs)
  32. if self.error:
  33. raise self.error
  34. return self.response
  35. class HeadingFormatPolicyTests(unittest.TestCase):
  36. def test_scoring_heading_with_score_value_is_detected_and_cleaned(self):
  37. node = Chapter(
  38. id="4.1.1",
  39. title="(一)保洁作业流程(8分)",
  40. level=3,
  41. direct_scoring_bindings=["SC-1#1"],
  42. )
  43. outline = BidOutline(project_name="测试", chapters=[
  44. Chapter(id="4", title="基本服务方案", children=[node])
  45. ])
  46. issues = find_heading_score_issues(outline)
  47. self.assertEqual(len(issues), 1)
  48. self.assertEqual(issues[0].chapter_id, "4.1.1")
  49. self.assertEqual(strip_score_from_title(node.title), "(一)保洁作业流程")
  50. def test_duplicate_sibling_number_is_detected_and_renumbered(self):
  51. parent = Chapter(id="4", title="基本服务方案", level=1, children=[
  52. Chapter(id="4.1", title="一、保洁服务", level=2),
  53. Chapter(id="4.2", title="一、绿化服务", level=2),
  54. Chapter(id="4.3", title="二、秩序维护", level=2),
  55. ])
  56. outline = BidOutline(project_name="测试", chapters=[parent])
  57. issues = find_sibling_numbering_issues(outline)
  58. self.assertEqual(len(issues), 1)
  59. self.assertEqual(issues[0].chapter_id, "4")
  60. changed = renumber_sibling_children(parent)
  61. self.assertEqual(changed, 2)
  62. self.assertEqual(
  63. [child.title for child in parent.children],
  64. ["一、保洁服务", "二、绿化服务", "三、秩序维护"],
  65. )
  66. def test_renumbering_skips_child_level_gap(self):
  67. parent = Chapter(id="4", title="基本服务方案", level=1, children=[
  68. Chapter(id="4.1", title="一、保洁服务", level=2),
  69. Chapter(id="4.1.1", title="(一)保洁流程", level=3),
  70. Chapter(id="4.2", title="二、绿化服务", level=2),
  71. ])
  72. changed = renumber_sibling_children(parent)
  73. self.assertEqual(changed, 0)
  74. self.assertEqual(parent.children[0].title, "一、保洁服务")
  75. self.assertEqual(parent.children[2].title, "二、绿化服务")
  76. class CleanupPolicyTests(unittest.TestCase):
  77. def test_package_lines_removed_when_project_has_no_packages(self):
  78. chapter = Chapter(
  79. id="2",
  80. title="投标报价",
  81. generated_content=(
  82. "报价说明\n包号:01\n服务内容:物业管理\n"
  83. "包件名称:无\n响应内容"
  84. ),
  85. )
  86. outline = BidOutline(project_name="测试", chapters=[chapter])
  87. issues = find_package_info_issues(outline, has_packages=False)
  88. self.assertEqual(len(issues), 1)
  89. self.assertEqual(issues[0].issue_type, "package_info")
  90. cleaned, removed = remove_package_lines(chapter.generated_content)
  91. self.assertEqual(removed, 2)
  92. self.assertNotIn("包号", cleaned)
  93. self.assertNotIn("包件", cleaned)
  94. self.assertIn("服务内容:物业管理", cleaned)
  95. def test_package_lines_kept_when_project_has_packages(self):
  96. chapter = Chapter(id="2", title="投标报价", generated_content="包号:01\n正文")
  97. outline = BidOutline(project_name="测试", chapters=[chapter])
  98. self.assertEqual(find_package_info_issues(outline, has_packages=True), [])
  99. def test_duplicate_body_lines_are_detected_and_deduplicated(self):
  100. duplicated = "结合本项目服务定位和采购需求制定针对性保障方案并落实各项管理措施。"
  101. chapter = Chapter(
  102. id="3",
  103. title="需求理解",
  104. generated_content=f"{duplicated}\n正文一\n{duplicated}",
  105. )
  106. outline = BidOutline(project_name="测试", chapters=[chapter])
  107. issues = find_duplicate_info_issues(outline)
  108. self.assertEqual(len(issues), 1)
  109. self.assertEqual(issues[0].issue_type, "duplicate_content")
  110. cleaned, removed = dedupe_chapter_content(chapter.generated_content)
  111. self.assertEqual(removed, 1)
  112. self.assertEqual(cleaned.count(duplicated), 1)
  113. def test_top_level_copied_supplement_block_is_not_flagged(self):
  114. child = Chapter(
  115. id="4.1",
  116. title="一、保洁作业流程",
  117. level=2,
  118. content_blocks=[{
  119. "order": 2,
  120. "block_type": "scoring_supplement",
  121. "target_heading_id": "4.1",
  122. "source_ids": ["SC-1#1"],
  123. }],
  124. )
  125. top = Chapter(
  126. id="4",
  127. title="基本服务方案",
  128. level=1,
  129. children=[child],
  130. content_blocks=[{
  131. "order": 2,
  132. "block_type": "scoring_supplement",
  133. "target_heading_id": "4.1",
  134. "source_ids": ["SC-1#1"],
  135. }],
  136. )
  137. outline = BidOutline(
  138. project_name="测试",
  139. chapters=[top],
  140. evaluation_index_entries=[{
  141. "entry_type": "scoring",
  142. "source_id": "SC-1#1",
  143. "criterion_id": "SC-1",
  144. "final_heading_id": "4.1",
  145. }],
  146. )
  147. self.assertEqual(find_template_integrity_issues(outline), [])
  148. class FillingPolicyTests(unittest.TestCase):
  149. def test_rejection_filling_detects_missing_commitment(self):
  150. commitment = Chapter(
  151. id="1.17",
  152. title="十七、要求承诺函",
  153. level=2,
  154. direct_rejection_bindings=["RI-01"],
  155. )
  156. outline = BidOutline(project_name="测试", chapters=[
  157. Chapter(id="1", title="资格证明", children=[commitment])
  158. ])
  159. entries = [{
  160. "entry_type": "rejection",
  161. "source_id": "RI-01",
  162. "requirement": "投标文件必须加盖公章。",
  163. }]
  164. issues = find_rejection_filling_issues(outline, entries)
  165. self.assertEqual(len(issues), 1)
  166. self.assertEqual(issues[0].issue_type, "rejection_filling")
  167. def test_rejection_filling_passes_when_commitment_filled(self):
  168. commitment = Chapter(
  169. id="1.17",
  170. title="十七、要求承诺函",
  171. level=2,
  172. direct_rejection_bindings=["RI-01"],
  173. supplement_content="投标文件必须加盖公章。",
  174. )
  175. outline = BidOutline(project_name="测试", chapters=[
  176. Chapter(id="1", title="资格证明", children=[commitment])
  177. ])
  178. entries = [{
  179. "entry_type": "rejection",
  180. "source_id": "RI-01",
  181. "requirement": "投标文件必须加盖公章。",
  182. }]
  183. self.assertEqual(find_rejection_filling_issues(outline, entries), [])
  184. def test_fixer_writes_missing_rejections_to_bound_node(self):
  185. commitment = Chapter(
  186. id="1.17",
  187. title="十七、要求承诺函",
  188. level=2,
  189. direct_rejection_bindings=["RI-01", "RI-02"],
  190. )
  191. outline = BidOutline(
  192. project_name="测试",
  193. chapters=[Chapter(id="1", title="资格证明", children=[commitment])],
  194. evaluation_index_entries=[
  195. {
  196. "entry_type": "rejection",
  197. "source_id": "RI-01",
  198. "requirement": "投标文件必须加盖公章。",
  199. },
  200. {
  201. "entry_type": "rejection",
  202. "source_id": "RI-02",
  203. "requirement": "不得改变投标文件实质性内容。",
  204. },
  205. ],
  206. )
  207. report = type(
  208. "Report", (), {"issues": [ReviewIssue(
  209. chapter_id="1.17",
  210. severity="error",
  211. issue_type="rejection_filling",
  212. description="要求承诺函未完整填充废标项",
  213. )]}
  214. )()
  215. fixer = object.__new__(_Fixer)
  216. fixer._fix_rejection_filling(outline, TenderAnalysis(project_name="测试"), report)
  217. self.assertIn("投标文件必须加盖公章", commitment.supplement_content)
  218. self.assertIn("不得改变投标文件实质性内容", commitment.supplement_content)
  219. class CriteriaCoveragePolicyTests(unittest.TestCase):
  220. def _outline_with_bound_scoring(self, evidence):
  221. node = Chapter(
  222. id="4.1",
  223. title="一、保洁作业流程",
  224. level=2,
  225. supplement_content=evidence,
  226. direct_scoring_bindings=["SC-1#1"],
  227. )
  228. outline = BidOutline(
  229. project_name="测试",
  230. chapters=[Chapter(id="4", title="基本服务方案", children=[node])],
  231. evaluation_index_entries=[{
  232. "entry_type": "scoring",
  233. "source_id": "SC-1#1",
  234. "criterion_id": "SC-1",
  235. "display_name": "保洁作业流程",
  236. "requirement": "流程完整、可操作",
  237. "final_heading_id": "4.1",
  238. }],
  239. )
  240. return outline
  241. def test_llm_reports_uncovered_bound_criterion(self):
  242. outline = self._outline_with_bound_scoring("已有保洁流程响应")
  243. reviewer = _Reviewer.__new__(_Reviewer)
  244. reviewer.llm = FakeLlm({"uncovered": [{"criteria_id": "SC-1", "reason": "缺少"}], "summary": "x"})
  245. issues = reviewer._check_criteria_coverage(
  246. outline,
  247. [ScoringCriterion("SC-1", "基本服务", "保洁作业流程", "流程完整", 8)],
  248. )
  249. self.assertEqual(len(issues), 1)
  250. self.assertEqual(issues[0].issue_type, "missing_criterion")
  251. self.assertIn("SC-1", issues[0].description)
  252. self.assertIn("4.1", reviewer.llm.calls[0]["user_prompt"])
  253. def test_llm_failure_falls_back_to_empty_bound_node_detection(self):
  254. outline = self._outline_with_bound_scoring("")
  255. reviewer = _Reviewer.__new__(_Reviewer)
  256. reviewer.llm = FakeLlm(error=RuntimeError("offline"))
  257. issues = reviewer._check_criteria_coverage(
  258. outline,
  259. [ScoringCriterion("SC-1", "基本服务", "保洁作业流程", "流程完整", 8)],
  260. )
  261. self.assertEqual(len(issues), 1)
  262. self.assertEqual(issues[0].issue_type, "missing_criterion")
  263. self.assertIn("绑定节点内容为空", issues[0].description)
  264. if __name__ == "__main__":
  265. unittest.main()