test_pipeline_guards.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. import inspect
  2. import tempfile
  3. import unittest
  4. from pathlib import Path
  5. from types import SimpleNamespace
  6. from unittest.mock import patch
  7. from models import (
  8. AgentState,
  9. BidOutline,
  10. Chapter,
  11. ChapterType,
  12. ParsedDocument,
  13. ProjectData,
  14. ReviewIssue,
  15. ScoringCriterion,
  16. TenderAnalysis,
  17. )
  18. from workflow import export_bid_document
  19. from step3_outlining import _build_outline_cache_fingerprint
  20. from step4_writing import write_content
  21. from step5_reviewing.fixer import _Fixer, _insert_into_existing_section
  22. from step5_reviewing.reviewer import (
  23. _Reviewer,
  24. _balanced_outline_excerpt,
  25. _canonical_outline_content,
  26. _chapter_effective_content,
  27. )
  28. from step6_exporting.docx_builder import (
  29. _fill_chapters_into_template,
  30. _try_assemble_from_template,
  31. )
  32. class OutlineCacheTests(unittest.TestCase):
  33. def test_fingerprint_changes_with_project_and_scoring(self):
  34. analysis_a = TenderAnalysis(
  35. project_name="项目A",
  36. scoring_criteria=[
  37. ScoringCriterion("SC-1", "技术", "需求理解", "分析重点", 10)
  38. ],
  39. )
  40. analysis_b = TenderAnalysis(
  41. project_name="项目B",
  42. scoring_criteria=[
  43. ScoringCriterion("SC-1", "技术", "需求理解", "分析重点", 10)
  44. ],
  45. )
  46. pd_a = ProjectData(project_id="A", project_name="项目A")
  47. pd_b = ProjectData(project_id="B", project_name="项目B")
  48. fingerprint_a = _build_outline_cache_fingerprint(analysis_a, pd_a)
  49. fingerprint_b = _build_outline_cache_fingerprint(analysis_b, pd_b)
  50. self.assertNotEqual(fingerprint_a, fingerprint_b)
  51. analysis_a.scoring_criteria[0].description = "分析重点和难点"
  52. fingerprint_changed = _build_outline_cache_fingerprint(analysis_a, pd_a)
  53. self.assertNotEqual(fingerprint_a, fingerprint_changed)
  54. class ReviewCorpusTests(unittest.TestCase):
  55. def test_demand_chapter_requires_local_scoring_dimensions(self):
  56. demand = Chapter(
  57. id="3", title="需求理解", generated_content="项目概况与一般特点分析"
  58. )
  59. scoring_node = Chapter(
  60. id="4.1", title="需求理解(6分)", level=2,
  61. related_criteria=["SC-02"], generated_content="第四章已有完整响应",
  62. )
  63. outline = BidOutline(project_name="测试", chapters=[
  64. demand,
  65. Chapter(id="4", title="基本服务方案", children=[scoring_node]),
  66. ])
  67. criterion = ScoringCriterion(
  68. "SC-02", "技术方案", "需求理解",
  69. "分析服务定位和预期目标,分析重点难点并提出应对或改进措施。", 6,
  70. )
  71. reviewer = _Reviewer.__new__(_Reviewer)
  72. issues = reviewer._check_demand_understanding_scoring_coverage(
  73. outline, [criterion]
  74. )
  75. self.assertEqual(len(issues), 1)
  76. self.assertEqual(issues[0].chapter_id, "3")
  77. self.assertEqual(issues[0].issue_type, "demand_scoring_coverage")
  78. def test_restructure_parent_content_satisfies_legacy_empty_children(self):
  79. outline = BidOutline(
  80. project_name="测试项目",
  81. chapters=[
  82. Chapter(
  83. id="3",
  84. title="需求理解",
  85. chapter_type=ChapterType.TECHNICAL,
  86. generated_content="根据评分项重构后的完整需求理解正文",
  87. children=[
  88. Chapter(
  89. id="3.1",
  90. title="旧模板子节",
  91. chapter_type=ChapterType.TECHNICAL,
  92. )
  93. ],
  94. )
  95. ],
  96. )
  97. reviewer = _Reviewer.__new__(_Reviewer)
  98. self.assertEqual(reviewer._check_empty_chapters(outline), [])
  99. def test_parent_and_children_are_not_double_counted(self):
  100. child = Chapter(id="3.1", title="子节", generated_content="子节正文")
  101. parent = Chapter(
  102. id="3",
  103. title="需求理解",
  104. generated_content="父章已汇总子节正文",
  105. children=[child],
  106. )
  107. outline = BidOutline(project_name="测试", chapters=[parent])
  108. self.assertEqual(_chapter_effective_content(parent), "父章已汇总子节正文")
  109. corpus = _canonical_outline_content(outline)
  110. self.assertEqual(corpus.count("子节正文"), 1)
  111. def test_balanced_excerpt_contains_late_chapters(self):
  112. outline = BidOutline(
  113. project_name="测试",
  114. chapters=[
  115. Chapter(id=str(i), title=f"章节{i}", generated_content=(str(i) * 4000))
  116. for i in range(1, 9)
  117. ],
  118. )
  119. excerpt = _balanced_outline_excerpt(outline, max_chars=12000)
  120. self.assertIn("【1 章节1】", excerpt)
  121. self.assertIn("【8 章节8】", excerpt)
  122. class FixerTests(unittest.TestCase):
  123. def test_demand_scoring_patch_stays_in_chapter_three_without_new_heading(self):
  124. class FakeLlm:
  125. def generate(self, **_kwargs):
  126. return "一、新增标题\n结合场馆开放特点制定分区分时保障方案。"
  127. demand = Chapter(
  128. id="3", title="需求理解",
  129. generated_content="一、项目概况\n原有正文",
  130. )
  131. scoring_node = Chapter(
  132. id="4.1", title="需求理解(6分)", level=2,
  133. related_criteria=["SC-02"], generated_content="第四章原文",
  134. )
  135. outline = BidOutline(project_name="测试", chapters=[
  136. demand,
  137. Chapter(id="4", title="基本服务方案", children=[scoring_node]),
  138. ])
  139. analysis = TenderAnalysis(
  140. project_name="测试",
  141. scoring_criteria=[ScoringCriterion(
  142. "SC-02", "技术方案", "需求理解",
  143. "分析服务定位和预期目标,分析重点难点并提出应对或改进措施。", 6,
  144. )],
  145. )
  146. issue = ReviewIssue(
  147. chapter_id="3", severity="error",
  148. issue_type="demand_scoring_coverage",
  149. description="需求理解章未完整覆盖评分项 [SC-02] 需求理解",
  150. )
  151. fixer = object.__new__(_Fixer)
  152. fixer.llm = FakeLlm()
  153. fixer.project_data = None
  154. fixer._fix_demand_understanding_criterion(outline, issue, analysis)
  155. self.assertIn("服务定位", demand.generated_content)
  156. self.assertIn("预期目标", demand.generated_content)
  157. self.assertIn("重点与难点", demand.generated_content)
  158. self.assertIn("应对或改进措施", demand.generated_content)
  159. self.assertNotIn("一、新增标题", demand.generated_content)
  160. self.assertEqual(scoring_node.generated_content, "第四章原文")
  161. def test_missing_criterion_patch_targets_deepest_mapped_heading(self):
  162. class FakeLlm:
  163. def generate(self, **_kwargs):
  164. return "一、模型自造标题\n补充后的针对性响应正文"
  165. scoring_node = Chapter(
  166. id="4.1.1",
  167. title="(一)保洁作业流程(8分)",
  168. level=3,
  169. structure_locked=True,
  170. related_criteria=["SC-1"],
  171. )
  172. parent = Chapter(
  173. id="4.1", title="一、保洁服务", level=2,
  174. related_criteria=["SC-1"], children=[scoring_node],
  175. )
  176. top = Chapter(
  177. id="4", title="基本服务方案", level=1,
  178. chapter_type=ChapterType.TECHNICAL,
  179. generated_content="原有顶层正文",
  180. related_criteria=["SC-1"], children=[parent],
  181. )
  182. outline = BidOutline(project_name="测试", chapters=[top])
  183. analysis = TenderAnalysis(
  184. project_name="测试",
  185. scoring_criteria=[
  186. ScoringCriterion(
  187. "SC-1", "基本服务", "保洁作业流程", "流程完整", 8
  188. )
  189. ],
  190. )
  191. issue = ReviewIssue(
  192. chapter_id="*", severity="error", issue_type="missing_criterion",
  193. description="[LLM深度检查] 评分项 [SC-1] 保洁作业流程 未充分覆盖",
  194. )
  195. fixer = object.__new__(_Fixer)
  196. fixer.llm = FakeLlm()
  197. fixer.project_data = None
  198. fixer._appended_chapters = set()
  199. fixer._fix_missing_criterion(outline, issue, analysis)
  200. self.assertEqual(top.generated_content, "原有顶层正文")
  201. self.assertIn("补充后的针对性响应正文", scoring_node.generated_content)
  202. self.assertNotIn("一、模型自造标题", scoring_node.generated_content)
  203. def test_scoring_patch_is_inserted_under_matching_existing_heading(self):
  204. content = (
  205. "一、保洁服务\n原保洁正文\n"
  206. "二、绿化服务\n原绿化正文"
  207. )
  208. result = _insert_into_existing_section(
  209. content,
  210. "补充保洁作业流程和质量检查要求。",
  211. ["保洁", "作业流程"],
  212. )
  213. self.assertLess(result.index("补充保洁"), result.index("二、绿化服务"))
  214. self.assertEqual(
  215. [line for line in result.splitlines() if line.startswith(("一、", "二、"))],
  216. ["一、保洁服务", "二、绿化服务"],
  217. )
  218. def test_word_expansion_only_targets_restructure_chapter(self):
  219. class FakeLlm:
  220. def generate(self, **_kwargs):
  221. return "一、新增章节标题\n新增的实施细节正文"
  222. fixer = object.__new__(_Fixer)
  223. fixer.llm = FakeLlm()
  224. fixer.project_data = None
  225. preserved = Chapter(
  226. id="6", title="项目经理", generated_content="模板结构正文"
  227. )
  228. scoring = Chapter(
  229. id="3.1", title="一、需求理解", level=2,
  230. generated_content="原有评分响应正文",
  231. related_criteria=["SC-02"], structure_locked=True,
  232. )
  233. restructure = Chapter(
  234. id="3", title="需求理解", generated_content="模板需求理解正文",
  235. children=[scoring],
  236. )
  237. locked = Chapter(
  238. id="4", title="基本服务方案", generated_content="原有方案正文"
  239. )
  240. outline = BidOutline(
  241. project_name="测试", chapters=[preserved, restructure, locked]
  242. )
  243. analysis = TenderAnalysis(project_name="测试")
  244. fixer._fix_word_count(outline, 1000, analysis)
  245. self.assertEqual(preserved.generated_content, "模板结构正文")
  246. self.assertEqual(locked.generated_content, "原有方案正文")
  247. self.assertEqual(restructure.generated_content, "模板需求理解正文")
  248. self.assertTrue(scoring.generated_content.startswith("原有评分响应正文"))
  249. self.assertIn("新增的实施细节正文", scoring.generated_content)
  250. self.assertNotIn("一、新增章节标题", scoring.generated_content)
  251. class PipelinePolicyTests(unittest.TestCase):
  252. def test_step4_no_longer_calls_full_chapter_alignment(self):
  253. self.assertNotIn(
  254. "_llm_apply_requirement_alignment(", inspect.getsource(write_content)
  255. )
  256. def test_step6_formal_path_has_no_native_table_structure_mutations(self):
  257. source = inspect.getsource(_try_assemble_from_template)
  258. self.assertEqual(source.count("_resolve_remaining_table_placeholders("), 1)
  259. self.assertNotIn("_dedupe_identical_tables(", source)
  260. self.assertNotIn("_append_missing_rejections_to_commitment_tables(", source)
  261. self.assertNotIn("_append_compliance_checklist(", source)
  262. self.assertNotIn("_center_document_tables(", source)
  263. chapter_source = inspect.getsource(_fill_chapters_into_template)
  264. self.assertNotIn("_append_unpaired_tender_tables(", chapter_source)
  265. def test_workflow_preserves_requested_final_path_and_ends_at_step6(self):
  266. with tempfile.TemporaryDirectory() as temp_dir:
  267. final_path = str(Path(temp_dir) / "用户指定结果.docx")
  268. project_data = ProjectData(
  269. project_id="P-1",
  270. project_name="测试项目",
  271. reference_bids=[
  272. ParsedDocument(
  273. file_path="reference.docx",
  274. file_name="reference.docx",
  275. content="",
  276. )
  277. ],
  278. )
  279. state = AgentState(
  280. project_name="测试项目",
  281. project_data=project_data,
  282. tender_analysis=TenderAnalysis(project_name="测试项目"),
  283. outline=BidOutline(
  284. project_name="测试项目",
  285. chapters=[Chapter(id="3", title="需求理解", generated_content="正文")],
  286. ),
  287. output_path=final_path,
  288. )
  289. def fake_export(**kwargs):
  290. self.assertEqual(kwargs["output_path"], final_path)
  291. self.assertEqual(kwargs["reference_bid_path"], "reference.docx")
  292. return kwargs["output_path"]
  293. fake_cfg = SimpleNamespace(
  294. output_dir=temp_dir,
  295. save_chapter_docs=False,
  296. )
  297. with patch("workflow.get_config", return_value=fake_cfg), \
  298. patch("workflow.export_to_docx", side_effect=fake_export):
  299. result = export_bid_document(state)
  300. self.assertEqual(result["output_path"], final_path)
  301. self.assertEqual(result["current_step"], "export")
  302. if __name__ == "__main__":
  303. unittest.main()