test_step4_block_generation.py 61 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557
  1. import base64
  2. import json
  3. import os
  4. import tempfile
  5. import unittest
  6. from types import SimpleNamespace
  7. from unittest.mock import patch
  8. from docx import Document
  9. from docx.enum.section import WD_SECTION
  10. from docx.oxml import OxmlElement
  11. from models import (
  12. BidOutline,
  13. Chapter,
  14. ChapterType,
  15. CompanyInfo,
  16. ExtractedTable,
  17. ParsedDocument,
  18. ProjectData,
  19. RejectionItem,
  20. ScoringCriterion,
  21. FormatReconciliation,
  22. ReconciliationDecision,
  23. ReconciliationReport,
  24. TableCell,
  25. TenderAnalysis,
  26. )
  27. from step4_writing import (
  28. _defer_evaluation_index_chapter,
  29. _partition_step4_chapters,
  30. _persist_all_chapter_docs,
  31. _resolve_chapter_text_placeholders,
  32. _sanitize_bound_block,
  33. _save_chapter_docx,
  34. _select_placeholder_table,
  35. _write_single_chapter,
  36. )
  37. from step4_writing.content_injector import _ContentInjector
  38. from step4_writing.placeholder_engine import build_placeholder_map
  39. from step6_exporting.docx_builder import _insert_locked_scoring_sections
  40. class FakeBlockLlm:
  41. def __init__(self, blocks=None):
  42. self.blocks = blocks or []
  43. self.calls = []
  44. def extract_json(self, **kwargs):
  45. self.calls.append(kwargs)
  46. return {"blocks": self.blocks}
  47. class FakeWriter:
  48. def __init__(self, blocks=None):
  49. self.llm = FakeBlockLlm(blocks)
  50. def write_chapter(self, **_kwargs):
  51. raise AssertionError("模板章不得进入整章 LLM 攄写路径")
  52. def analysis():
  53. return TenderAnalysis(
  54. project_name="群众艺术馆项目",
  55. buyer_name="采购人甲",
  56. tender_project_id="XM-001",
  57. )
  58. class Step4BlockGenerationTests(unittest.TestCase):
  59. def test_table_source_priority_rejects_reconciliation_type_mismatch(self):
  60. wrong_scoring = ExtractedTable(
  61. table_id="tender-scoring",
  62. source_file="tender.pdf",
  63. source_type="tender_pdf",
  64. table_type="scoring",
  65. rows=8,
  66. cols=5,
  67. title_hint="客观分评审因素响应情况表",
  68. )
  69. tender_qualification = ExtractedTable(
  70. table_id="tender-qualification",
  71. source_file="tender.pdf",
  72. source_type="tender_pdf",
  73. table_type="qualification_response",
  74. rows=5,
  75. cols=5,
  76. title_hint="资格条件响应表",
  77. )
  78. reference_qualification = ExtractedTable(
  79. table_id="reference-qualification",
  80. source_file="reference.docx",
  81. source_type="reference_docx",
  82. table_type="qualification_response",
  83. rows=5,
  84. cols=5,
  85. title_hint="资格条件响应表",
  86. )
  87. report = ReconciliationReport(reconciliations=[FormatReconciliation(
  88. section_name="资格条件响应表",
  89. template_table=ExtractedTable(table_type="qualification_response"),
  90. best_match=wrong_scoring,
  91. replace_with_tender_table=wrong_scoring,
  92. decision=ReconciliationDecision.REPLACE_WITH_TENDER,
  93. )])
  94. project_data = ProjectData(
  95. project_id="test",
  96. project_name="测试项目",
  97. tender_docs=[ParsedDocument(
  98. file_path="tender.pdf",
  99. file_name="tender.pdf",
  100. content="",
  101. doc_type="tender",
  102. )],
  103. reference_tables=[reference_qualification],
  104. metadata={"reconciliation_report": report},
  105. )
  106. analysis = TenderAnalysis(project_name="测试项目", tender_tables=[
  107. wrong_scoring,
  108. tender_qualification,
  109. ])
  110. selected = _select_placeholder_table(
  111. "资格条件响应表", analysis, project_data
  112. )
  113. self.assertIs(selected, tender_qualification)
  114. def test_qualification_placeholder_uses_current_tender_rows_not_wrong_match(self):
  115. def cells(rows):
  116. return [
  117. [TableCell(text=value, row=ri, col=ci) for ci, value in enumerate(row)]
  118. for ri, row in enumerate(rows)
  119. ]
  120. wrong_scoring = ExtractedTable(
  121. table_id="scoring",
  122. source_file="tender.pdf",
  123. source_type="tender_pdf",
  124. table_type="scoring",
  125. rows=2,
  126. cols=5,
  127. cells=cells([
  128. ["序号", "名称", "是否响应", "响应情况", "页码"],
  129. ["1", "历史错表", "", "", ""],
  130. ]),
  131. )
  132. current_qualification = ExtractedTable(
  133. table_id="qualification",
  134. source_file="tender.pdf",
  135. source_type="tender_pdf",
  136. table_type="qualification_response",
  137. rows=2,
  138. cols=5,
  139. title_hint="资格条件响应表",
  140. cells=cells([
  141. ["项目内容", "具备的条件说明(要求)", "投标检查项(响应内容说明(是/否))", "详细内容所对应电子投标文件名称", "备注"],
  142. ["当前资格", "当前招标资格要求", "", "", ""],
  143. ]),
  144. )
  145. report = ReconciliationReport(reconciliations=[FormatReconciliation(
  146. section_name="资格条件响应表",
  147. template_table=ExtractedTable(table_type="qualification_response"),
  148. best_match=wrong_scoring,
  149. replace_with_tender_table=wrong_scoring,
  150. decision=ReconciliationDecision.REPLACE_WITH_TENDER,
  151. )])
  152. project_data = ProjectData(
  153. project_id="test",
  154. project_name="当前项目",
  155. tender_docs=[ParsedDocument(
  156. file_path="tender.pdf", file_name="tender.pdf", content="",
  157. doc_type="tender",
  158. )],
  159. metadata={"reconciliation_report": report},
  160. )
  161. analysis = TenderAnalysis(
  162. project_name="当前项目",
  163. tender_tables=[wrong_scoring, current_qualification],
  164. )
  165. chapter = Chapter(id="1", title="投标人资格、资信证明", level=1)
  166. with tempfile.TemporaryDirectory() as directory:
  167. template_path = os.path.join(directory, "template.docx")
  168. template = Document()
  169. template.add_paragraph("第一章:投标人资格、资信证明", style="Heading 1")
  170. template.add_paragraph("%%资格条件响应表%%")
  171. template.add_paragraph("第二章:投标报价", style="Heading 1")
  172. template.save(template_path)
  173. output_path = _save_chapter_docx(
  174. chapter,
  175. directory,
  176. template_path=template_path,
  177. analysis=analysis,
  178. project_data=project_data,
  179. )
  180. artifact = Document(output_path)
  181. values = [
  182. cell.text
  183. for table in artifact.tables
  184. for row in table.rows
  185. for cell in row.cells
  186. ]
  187. self.assertIn("当前招标资格要求", values)
  188. self.assertIn("是", values)
  189. self.assertIn("投标文件对应章节", values)
  190. self.assertNotIn("历史错表", values)
  191. def test_table_source_priority_is_tender_then_procurement_then_reference(self):
  192. def table(table_id, path, source_type):
  193. return ExtractedTable(
  194. table_id=table_id,
  195. source_file=path,
  196. source_type=source_type,
  197. table_type="qualification_response",
  198. title_hint="资格条件响应表",
  199. )
  200. tender = table("tender", "tender.pdf", "tender_pdf")
  201. procurement = table("procurement", "procurement.docx", "procurement_docx")
  202. reference = table("reference", "reference.docx", "reference_docx")
  203. project_data = ProjectData(
  204. project_id="test",
  205. project_name="测试项目",
  206. tender_docs=[ParsedDocument(
  207. file_path="tender.pdf", file_name="tender.pdf", content="",
  208. doc_type="tender"
  209. )],
  210. procurement_docs=[ParsedDocument(
  211. file_path="procurement.docx",
  212. file_name="procurement.docx",
  213. content="",
  214. doc_type="procurement",
  215. )],
  216. reference_tables=[reference],
  217. )
  218. selected = _select_placeholder_table(
  219. "资格条件响应表",
  220. TenderAnalysis(
  221. project_name="测试项目",
  222. tender_tables=[reference, procurement, tender],
  223. ),
  224. project_data,
  225. )
  226. self.assertIs(selected, tender)
  227. selected_without_tender = _select_placeholder_table(
  228. "资格条件响应表",
  229. TenderAnalysis(
  230. project_name="测试项目",
  231. tender_tables=[reference, procurement],
  232. ),
  233. project_data,
  234. )
  235. self.assertIs(selected_without_tender, procurement)
  236. selected_reference_only = _select_placeholder_table(
  237. "资格条件响应表",
  238. TenderAnalysis(project_name="测试项目", tender_tables=[]),
  239. project_data,
  240. )
  241. self.assertIs(selected_reference_only, reference)
  242. def test_generic_table_name_uses_same_priority_without_known_type(self):
  243. def table(table_id, path, source_type, title):
  244. return ExtractedTable(
  245. table_id=table_id,
  246. source_file=path,
  247. source_type=source_type,
  248. table_type="other",
  249. title_hint=title,
  250. )
  251. tender = table("tender", "tender.pdf", "tender_pdf", "拟投入设备清单表")
  252. procurement = table(
  253. "procurement", "procurement.docx", "procurement_docx", "拟投入设备清单表"
  254. )
  255. reference = table(
  256. "reference", "reference.docx", "reference_docx", "拟投入设备清单表"
  257. )
  258. wrong = table("wrong", "tender.pdf", "tender_pdf", "人员配置表")
  259. project_data = ProjectData(
  260. project_id="test",
  261. project_name="测试项目",
  262. tender_docs=[ParsedDocument(
  263. file_path="tender.pdf", file_name="tender.pdf", content="",
  264. doc_type="tender",
  265. )],
  266. procurement_docs=[ParsedDocument(
  267. file_path="procurement.docx", file_name="procurement.docx", content="",
  268. doc_type="procurement",
  269. )],
  270. reference_tables=[reference],
  271. metadata={"reconciliation_report": ReconciliationReport(
  272. reconciliations=[FormatReconciliation(
  273. section_name="拟投入设备清单表",
  274. template_table=ExtractedTable(title_hint="拟投入设备清单表"),
  275. best_match=wrong,
  276. replace_with_tender_table=wrong,
  277. decision=ReconciliationDecision.REPLACE_WITH_TENDER,
  278. )],
  279. )},
  280. )
  281. selected = _select_placeholder_table(
  282. "拟投入设备清单表",
  283. TenderAnalysis(
  284. project_name="测试项目",
  285. tender_tables=[wrong, reference, procurement, tender],
  286. ),
  287. project_data,
  288. )
  289. self.assertIs(selected, tender)
  290. def test_template_fill_precedes_scoring_supplement_block(self):
  291. child = Chapter(
  292. id="4.1", title="一、需求分析", level=2,
  293. direct_scoring_criteria=["SC-01"],
  294. direct_scoring_bindings=["SC-01#1"],
  295. content_generation_mode="template_project_scoring",
  296. )
  297. chapter = Chapter(
  298. id="4", title="服务方案", level=1,
  299. chapter_type=ChapterType.TECHNICAL,
  300. children=[child],
  301. )
  302. outline = BidOutline(
  303. project_name="群众艺术馆项目",
  304. chapters=[chapter],
  305. evaluation_index_entries=[{
  306. "entry_type": "scoring",
  307. "source_id": "SC-01#1",
  308. "criterion_id": "SC-01",
  309. "display_name": "需求分析",
  310. "requirement": "结合项目特点分析需求",
  311. "final_heading_id": "4.1",
  312. }],
  313. )
  314. writer = FakeWriter([{
  315. "node_id": "4.1",
  316. "content": "围绕群众艺术馆开放特点形成针对性服务安排。",
  317. }])
  318. with patch(
  319. "step4_writing._get_template_text_for_chapter",
  320. return_value="项目名称:%%项目名称%%\n一、需求分析\n模板原文\n[图片块保持原位]",
  321. ):
  322. result = _write_single_chapter(
  323. chapter=chapter,
  324. writer=writer,
  325. injector=_ContentInjector({}, [], ["招标要求"]),
  326. analysis=analysis(),
  327. company_info=CompanyInfo(full_name="测试公司"),
  328. template_structure=object(),
  329. cfg=SimpleNamespace(enforce_word_limit=False),
  330. outline=outline,
  331. placeholder_map={"项目名称": "群众艺术馆项目"},
  332. table_fill_planned=True,
  333. )
  334. self.assertTrue(result.preserve_template_layout)
  335. self.assertTrue(result.template_fill_completed)
  336. self.assertIn("项目名称:群众艺术馆项目", result.generated_content)
  337. self.assertIn("[图片块保持原位]", result.generated_content)
  338. self.assertNotIn("针对性服务安排", result.generated_content)
  339. self.assertIn("针对性服务安排", child.supplement_content)
  340. self.assertEqual(
  341. [block["block_type"] for block in result.content_blocks],
  342. ["template_base", "native_table_plan", "scoring_supplement"],
  343. )
  344. self.assertEqual(child.content_blocks[0]["order"], 2)
  345. def test_missing_scoring_blocks_are_retried_for_full_coverage(self):
  346. child1 = Chapter(
  347. id="3.1",
  348. title="一、服务定位及预期目标",
  349. level=2,
  350. direct_scoring_bindings=["SC-02#1"],
  351. )
  352. child2 = Chapter(
  353. id="3.4",
  354. title="四、重点难点分析及应对措施",
  355. level=2,
  356. direct_scoring_bindings=["SC-02#2"],
  357. )
  358. child3 = Chapter(
  359. id="3.5",
  360. title="五、服务保障措施",
  361. level=2,
  362. direct_scoring_bindings=["SC-02#3"],
  363. )
  364. chapter = Chapter(
  365. id="3",
  366. title="需求理解",
  367. children=[child1, child2, child3],
  368. )
  369. outline = BidOutline(
  370. project_name="测试项目",
  371. chapters=[chapter],
  372. evaluation_index_entries=[
  373. {
  374. "entry_type": "scoring",
  375. "source_id": "SC-02#1",
  376. "criterion_id": "SC-02",
  377. "display_name": "服务定位及预期目标",
  378. "requirement": "服务定位分析及目标合理性",
  379. "final_heading_id": "3.1",
  380. },
  381. {
  382. "entry_type": "scoring",
  383. "source_id": "SC-02#2",
  384. "criterion_id": "SC-02",
  385. "display_name": "重点难点分析及应对措施",
  386. "requirement": "重点难点分析深度及应对措施",
  387. "final_heading_id": "3.4",
  388. },
  389. {
  390. "entry_type": "scoring",
  391. "source_id": "SC-02#3",
  392. "criterion_id": "SC-02",
  393. "display_name": "服务保障措施",
  394. "requirement": "服务保障措施要求",
  395. "final_heading_id": "3.5",
  396. },
  397. ],
  398. )
  399. class PartialLlm:
  400. def __init__(self, responses):
  401. self.responses = list(responses)
  402. self.calls = []
  403. def extract_json(self, **kwargs):
  404. self.calls.append(kwargs)
  405. return self.responses.pop(0)
  406. class PartialWriter:
  407. def __init__(self, responses):
  408. self.llm = PartialLlm(responses)
  409. writer = PartialWriter([
  410. {"blocks": [{"node_id": "3.1", "content": "服务定位正文"}]},
  411. {"blocks": [{"node_id": "3.1", "content": "服务定位正文"}]},
  412. {"blocks": [{"node_id": "3.1", "content": "服务定位正文"}]},
  413. {"blocks": [{"node_id": "3.1", "content": "服务定位正文"}]},
  414. {"blocks": [
  415. {"node_id": "3.4", "content": "重点难点正文"},
  416. {"node_id": "3.5", "content": "服务保障正文"},
  417. ]},
  418. ])
  419. with patch(
  420. "step4_writing._get_template_text_for_chapter",
  421. return_value="第三章模板原文",
  422. ):
  423. result = _write_single_chapter(
  424. chapter=chapter,
  425. writer=writer,
  426. injector=_ContentInjector({}, [], ["招标要求"]),
  427. analysis=analysis(),
  428. company_info=CompanyInfo(),
  429. template_structure=object(),
  430. cfg=SimpleNamespace(enforce_word_limit=False),
  431. outline=outline,
  432. placeholder_map={},
  433. )
  434. self.assertEqual(len(writer.llm.calls), 5)
  435. self.assertEqual(
  436. [call["max_tokens"] for call in writer.llm.calls],
  437. [32768, 65536, 65536, 65536, 65536],
  438. )
  439. self.assertEqual(child1.supplement_content, "服务定位正文")
  440. self.assertEqual(child2.supplement_content, "重点难点正文")
  441. self.assertEqual(child3.supplement_content, "服务保障正文")
  442. self.assertTrue(result.preserve_template_layout)
  443. def test_scoring_supplement_prompt_includes_existing_template_content(self):
  444. child = Chapter(
  445. id="1.18",
  446. title="十八、保险购买承诺",
  447. level=2,
  448. template_original_title="十八、保险购买承诺",
  449. direct_scoring_bindings=["SC-14#9"],
  450. )
  451. chapter = Chapter(
  452. id="1",
  453. title="投标人资格、资信证明",
  454. children=[child],
  455. )
  456. outline = BidOutline(
  457. project_name="测试项目",
  458. chapters=[chapter],
  459. evaluation_index_entries=[{
  460. "entry_type": "scoring",
  461. "source_id": "SC-14#9",
  462. "criterion_id": "SC-14",
  463. "display_name": "保险购买承诺",
  464. "requirement": "承诺购买足额保险",
  465. "final_heading_id": "1.18",
  466. }],
  467. )
  468. writer = FakeWriter([{
  469. "node_id": "1.18",
  470. "content": "补充正文",
  471. }])
  472. with patch(
  473. "step4_writing._get_template_text_for_chapter",
  474. return_value=(
  475. "十八、保险购买承诺\n"
  476. "我方已承诺购买足额雇主责任险和公众责任险。\n"
  477. "模板其他原文"
  478. ),
  479. ):
  480. _write_single_chapter(
  481. chapter=chapter,
  482. writer=writer,
  483. injector=_ContentInjector({}, []),
  484. analysis=analysis(),
  485. company_info=CompanyInfo(),
  486. template_structure=object(),
  487. cfg=SimpleNamespace(enforce_word_limit=False),
  488. outline=outline,
  489. placeholder_map={},
  490. )
  491. self.assertEqual(len(writer.llm.calls), 1)
  492. self.assertIn(
  493. "我方已承诺购买足额雇主责任险和公众责任险。",
  494. writer.llm.calls[0]["user_prompt"],
  495. )
  496. self.assertEqual(child.supplement_content, "补充正文")
  497. def test_invalid_chapter_reference_is_sanitized(self):
  498. child = Chapter(
  499. id="8.2",
  500. title="二、服务人员专业素质与职业资格证书",
  501. level=2,
  502. direct_scoring_bindings=["SC-10#3"],
  503. )
  504. chapter = Chapter(
  505. id="8",
  506. title="服务人员配置承诺",
  507. children=[child],
  508. )
  509. outline = BidOutline(
  510. project_name="测试项目",
  511. chapters=[chapter],
  512. evaluation_index_entries=[{
  513. "entry_type": "scoring",
  514. "source_id": "SC-10#3",
  515. "criterion_id": "SC-10",
  516. "display_name": "服务人员专业素质与职业资格证书",
  517. "requirement": "提供人员专业素质与资格证书材料",
  518. "final_heading_id": "8.2",
  519. }],
  520. )
  521. writer = FakeWriter([{
  522. "node_id": "8.2",
  523. "content": (
  524. "具体证明材料及证书复印件,详见投标文件"
  525. "第十二章“项目服务人员配置”相应表单及附件。"
  526. ),
  527. }])
  528. with patch(
  529. "step4_writing._get_template_text_for_chapter",
  530. return_value="八、服务人员配置承诺\n模板原文",
  531. ):
  532. _write_single_chapter(
  533. chapter=chapter,
  534. writer=writer,
  535. injector=_ContentInjector({}, []),
  536. analysis=analysis(),
  537. company_info=CompanyInfo(),
  538. template_structure=object(),
  539. cfg=SimpleNamespace(enforce_word_limit=False),
  540. outline=outline,
  541. placeholder_map={},
  542. )
  543. self.assertNotIn("第十二章", child.supplement_content)
  544. self.assertIn("本投标文件相应章节及附件", child.supplement_content)
  545. def test_placeholder_replacement_collapses_duplicate_periods(self):
  546. filled = _resolve_chapter_text_placeholders(
  547. "服务内容:%%服务内容%%。",
  548. {"服务内容": "包括建筑物管理、设施设备管理、保安、保洁等内容。"},
  549. )
  550. self.assertNotIn("。。", filled)
  551. self.assertTrue(filled.endswith("。"))
  552. self.assertIn("包括建筑物管理", filled)
  553. filled_without_template_period = _resolve_chapter_text_placeholders(
  554. "服务内容:%%服务内容%%服务要求:%%服务要求%%",
  555. {"服务内容": "内容", "服务要求": "要求"},
  556. )
  557. self.assertIn("内容。服务要求:要求。", filled_without_template_period)
  558. def test_direct_h1_supplement_does_not_replace_template_block_plan(self):
  559. chapter = Chapter(
  560. id="1", title="资格证明", chapter_type=ChapterType.BUSINESS,
  561. direct_scoring_bindings=["SC-01#1"],
  562. )
  563. outline = BidOutline(
  564. project_name="测试",
  565. chapters=[chapter],
  566. evaluation_index_entries=[{
  567. "entry_type": "scoring",
  568. "source_id": "SC-01#1",
  569. "display_name": "资格证明",
  570. "requirement": "提供有效证明",
  571. "final_heading_id": "1",
  572. }],
  573. )
  574. writer = FakeWriter([{"node_id": "1", "content": "证明材料真实有效。"}])
  575. with patch(
  576. "step4_writing._get_template_text_for_chapter",
  577. return_value="资格证明模板原文",
  578. ):
  579. _write_single_chapter(
  580. chapter=chapter,
  581. writer=writer,
  582. injector=_ContentInjector({}, []),
  583. analysis=analysis(),
  584. company_info=CompanyInfo(),
  585. template_structure=object(),
  586. cfg=SimpleNamespace(enforce_word_limit=False),
  587. outline=outline,
  588. placeholder_map={},
  589. )
  590. self.assertEqual(
  591. [block["block_type"] for block in chapter.content_blocks],
  592. ["template_base", "native_table_plan", "scoring_supplement"],
  593. )
  594. def test_template_without_direct_binding_uses_no_llm(self):
  595. chapter = Chapter(id="2", title="投标报价", chapter_type=ChapterType.BUSINESS)
  596. outline = BidOutline(project_name="测试", chapters=[chapter])
  597. writer = FakeWriter()
  598. with patch(
  599. "step4_writing._get_template_text_for_chapter",
  600. return_value="投标报价模板原文",
  601. ):
  602. _write_single_chapter(
  603. chapter=chapter,
  604. writer=writer,
  605. injector=_ContentInjector({}, []),
  606. analysis=analysis(),
  607. company_info=CompanyInfo(),
  608. template_structure=object(),
  609. cfg=SimpleNamespace(enforce_word_limit=False),
  610. outline=outline,
  611. placeholder_map={},
  612. )
  613. self.assertEqual(chapter.generated_content, "投标报价模板原文")
  614. self.assertEqual(writer.llm.calls, [])
  615. def test_business_and_technical_are_ordinary_but_index_is_deferred(self):
  616. business = Chapter(id="1", title="资格证明", chapter_type=ChapterType.BUSINESS)
  617. technical = Chapter(id="3", title="服务方案", chapter_type=ChapterType.TECHNICAL)
  618. index = Chapter(
  619. id="index", title="与评标有关的投标文件主要内容索引表",
  620. chapter_type=ChapterType.BUSINESS,
  621. )
  622. ordinary, deferred = _partition_step4_chapters(
  623. BidOutline(project_name="测试", chapters=[index, business, technical])
  624. )
  625. self.assertEqual({node.id for node in ordinary}, {"1", "3"})
  626. self.assertEqual([node.id for node in deferred], ["index"])
  627. def test_evaluation_index_keeps_template_without_generating_rows_or_pages(self):
  628. chapter = Chapter(
  629. id="index",
  630. title="与评标有关的投标文件主要内容索引表",
  631. chapter_type=ChapterType.BUSINESS,
  632. content_blocks=[{"block_type": "deferred_evaluation_index"}],
  633. )
  634. result = _defer_evaluation_index_chapter(
  635. chapter=chapter,
  636. template_text="索引表模板原文\n详细说明见投标文件页码:",
  637. placeholder_map={},
  638. company_info=CompanyInfo(),
  639. analysis=analysis(),
  640. )
  641. self.assertEqual(result.generated_content, "索引表模板原文\n详细说明见投标文件页码:")
  642. self.assertTrue(result.preserve_template_layout)
  643. self.assertEqual(
  644. [block["block_type"] for block in result.content_blocks],
  645. ["template_base", "deferred_evaluation_index"],
  646. )
  647. self.assertEqual(
  648. result.content_blocks[1]["action"],
  649. "build_rows_and_pages_after_final_merge_and_pagination",
  650. )
  651. def test_deferred_empty_index_passes_artifact_gate_with_deferred_status(self):
  652. ordinary = Chapter(
  653. id="1", title="资格证明", chapter_type=ChapterType.BUSINESS,
  654. generated_content="资格证明正文",
  655. )
  656. index = _defer_evaluation_index_chapter(
  657. chapter=Chapter(
  658. id="index",
  659. title="与评标有关的投标文件主要内容索引表",
  660. chapter_type=ChapterType.BUSINESS,
  661. ),
  662. template_text="",
  663. placeholder_map={},
  664. company_info=CompanyInfo(),
  665. analysis=analysis(),
  666. )
  667. outline = BidOutline(project_name="测试项目", chapters=[ordinary, index])
  668. with tempfile.TemporaryDirectory() as directory:
  669. paths = _persist_all_chapter_docs(outline, directory)
  670. manifest_path = os.path.join(
  671. directory, "chapters", "测试项目", "manifest.json"
  672. )
  673. with open(manifest_path, encoding="utf-8") as file:
  674. manifest = json.load(file)
  675. self.assertEqual(len(paths), 2)
  676. self.assertTrue(all(os.path.isfile(path) for path in paths))
  677. self.assertEqual(
  678. manifest["chapters"][1]["status"],
  679. "deferred_until_final_pagination",
  680. )
  681. def test_chapter_artifact_uses_final_outline_titles_and_order(self):
  682. reused = Chapter(
  683. id="3.1",
  684. title="一、服务定位与预期目标分析",
  685. level=2,
  686. template_original_title="一、服务目标定位",
  687. supplement_content="评分补充正文一",
  688. )
  689. inserted = Chapter(
  690. id="3.2",
  691. title="二、重点难点分析与应对措施",
  692. level=2,
  693. supplement_content="评分补充正文二",
  694. )
  695. chapter = Chapter(
  696. id="3",
  697. title="需求理解",
  698. children=[reused, inserted],
  699. generated_content=(
  700. "第三章:需求理解\n"
  701. "一、服务目标定位\n"
  702. "模板原有定位正文"
  703. ),
  704. )
  705. with tempfile.TemporaryDirectory() as directory:
  706. path = _save_chapter_docx(chapter, directory)
  707. artifact = Document(path)
  708. headings = [
  709. paragraph.text for paragraph in artifact.paragraphs
  710. if paragraph.style.name.startswith("Heading")
  711. ]
  712. all_text = "\n".join(paragraph.text for paragraph in artifact.paragraphs)
  713. self.assertEqual(
  714. headings,
  715. [
  716. "第三章 需求理解",
  717. "一、服务定位与预期目标分析",
  718. "二、重点难点分析与应对措施",
  719. ],
  720. )
  721. self.assertNotIn("一、服务目标定位", all_text)
  722. self.assertIn("模板原有定位正文", all_text)
  723. self.assertIn("评分补充正文一", all_text)
  724. self.assertIn("评分补充正文二", all_text)
  725. def test_native_chapter_excludes_next_part_marker_and_inserts_heading_before_section_break(self):
  726. import zipfile
  727. import xml.etree.ElementTree as ET
  728. child = Chapter(
  729. id="2.5",
  730. title="五、报价得分",
  731. level=2,
  732. supplement_content="报价得分补充正文",
  733. )
  734. chapter = Chapter(
  735. id="2",
  736. title="投标报价",
  737. template_chapter_id="2",
  738. children=[child],
  739. )
  740. with tempfile.TemporaryDirectory() as directory:
  741. template_path = os.path.join(directory, "template.docx")
  742. template = Document()
  743. template.add_paragraph("第二章:投标报价", style="Heading 1")
  744. template.add_paragraph("四、商务报价说明", style="Heading 2")
  745. template.add_paragraph("模板报价正文")
  746. template.add_paragraph("技术部分")
  747. template.add_paragraph("第三章:需求理解", style="Heading 1")
  748. template.save(template_path)
  749. path = _save_chapter_docx(
  750. chapter,
  751. directory,
  752. template_path=template_path,
  753. placeholder_map={},
  754. company_info=CompanyInfo(),
  755. analysis=analysis(),
  756. )
  757. artifact = Document(path)
  758. word_ns = (
  759. "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
  760. )
  761. with zipfile.ZipFile(path) as package:
  762. root = ET.fromstring(package.read("word/document.xml"))
  763. body = root.find(f"{{{word_ns}}}body")
  764. children = list(body)
  765. sect_indexes = [
  766. index for index, element in enumerate(children)
  767. if element.tag == f"{{{word_ns}}}sectPr"
  768. ]
  769. headings = [
  770. paragraph.text for paragraph in artifact.paragraphs
  771. if paragraph.style.name.startswith("Heading")
  772. ]
  773. all_text = "\n".join(paragraph.text for paragraph in artifact.paragraphs)
  774. self.assertEqual(
  775. headings,
  776. ["第二章 投标报价", "四、商务报价说明", "五、报价得分"],
  777. )
  778. self.assertNotIn("技术部分", all_text)
  779. self.assertIn("报价得分补充正文", all_text)
  780. self.assertTrue(sect_indexes)
  781. self.assertEqual(sect_indexes[-1], len(children) - 1)
  782. scoring_index = next(
  783. index for index, element in enumerate(children)
  784. if element.tag == f"{{{word_ns}}}p"
  785. and "五、报价得分" in "".join(
  786. node.text or "" for node in element.iter(f"{{{word_ns}}}t")
  787. )
  788. )
  789. self.assertLess(scoring_index, sect_indexes[-1])
  790. def test_duplicate_sibling_titles_anchor_to_own_parent_not_reinserted(self):
  791. section23 = Chapter(
  792. id="1.23",
  793. title="二十三、近三年类似项目业绩",
  794. level=2,
  795. template_original_title="二十三、近三年类似项目业绩",
  796. children=[Chapter(
  797. id="1.23.1",
  798. title="(一)国家税务总局上海市青浦区税务局",
  799. level=3,
  800. template_original_title="(一)国家税务总局上海市青浦区税务局",
  801. )],
  802. )
  803. section24 = Chapter(
  804. id="1.24",
  805. title="二十四、类似业绩业主评价",
  806. level=2,
  807. template_original_title="二十四、类似业绩业主评价",
  808. children=[
  809. Chapter(
  810. id="1.24.1",
  811. title="(一)国家税务总局上海市青浦区税务局",
  812. level=3,
  813. template_original_title="(一)国家税务总局上海市青浦区税务局",
  814. ),
  815. Chapter(
  816. id="1.24.10",
  817. title="(十)嘉定司法中心",
  818. level=3,
  819. template_original_title="(十)嘉定司法中心",
  820. ),
  821. ],
  822. )
  823. chapter = Chapter(
  824. id="1",
  825. title="投标人资格、资信证明",
  826. template_chapter_id="1",
  827. children=[section23, section24],
  828. )
  829. with tempfile.TemporaryDirectory() as directory:
  830. template_path = os.path.join(directory, "template.docx")
  831. template = Document()
  832. template.add_paragraph("第一章:投标人资格、资信证明", style="Heading 1")
  833. template.add_paragraph(
  834. "二十三、近三年类似项目业绩", style="Heading 2"
  835. )
  836. template.add_paragraph(
  837. "(一)国家税务总局上海市青浦区税务局", style="Heading 3"
  838. )
  839. template.add_paragraph(
  840. "二十四、类似业绩业主评价", style="Heading 2"
  841. )
  842. template.add_paragraph(
  843. "(一)国家税务总局上海市青浦区税务局", style="Heading 3"
  844. )
  845. template.add_paragraph("(十)嘉定司法中心", style="Heading 3")
  846. template.add_paragraph("第二章:其他章节", style="Heading 1")
  847. template.save(template_path)
  848. path = _save_chapter_docx(
  849. chapter,
  850. directory,
  851. template_path=template_path,
  852. placeholder_map={},
  853. company_info=CompanyInfo(),
  854. analysis=analysis(),
  855. )
  856. artifact = Document(path)
  857. headings = [
  858. paragraph.text for paragraph in artifact.paragraphs
  859. if paragraph.style.name.startswith("Heading")
  860. ]
  861. self.assertEqual(
  862. headings,
  863. [
  864. "第一章 投标人资格、资信证明",
  865. "二十三、近三年类似项目业绩",
  866. "(一)国家税务总局上海市青浦区税务局",
  867. "二十四、类似业绩业主评价",
  868. "(一)国家税务总局上海市青浦区税务局",
  869. "(十)嘉定司法中心",
  870. ],
  871. )
  872. self.assertEqual(headings.count("二十四、类似业绩业主评价"), 1)
  873. self.assertEqual(
  874. headings.count("(一)国家税务总局上海市青浦区税务局"),
  875. 2,
  876. )
  877. def test_rejection_supplements_are_visible_in_bound_artifact_section(self):
  878. commitment = Chapter(
  879. id="1.17",
  880. title="十七、要求承诺函",
  881. level=2,
  882. template_original_title="十七、要求承诺函",
  883. direct_rejection_bindings=["RI-01", "RI-02"],
  884. )
  885. chapter = Chapter(
  886. id="1",
  887. title="投标人资格、资信证明",
  888. chapter_type=ChapterType.BUSINESS,
  889. children=[commitment],
  890. )
  891. outline = BidOutline(
  892. project_name="测试",
  893. chapters=[chapter],
  894. evaluation_index_entries=[
  895. {
  896. "entry_id": "RI-01", "entry_type": "rejection",
  897. "source_id": "RI-01", "requirement": "投标文件必须签署",
  898. "final_heading_id": "1.17",
  899. },
  900. {
  901. "entry_id": "RI-02", "entry_type": "rejection",
  902. "source_id": "RI-02", "requirement": "证明材料必须有效",
  903. "final_heading_id": "1.17",
  904. },
  905. ],
  906. )
  907. tender_analysis = analysis()
  908. tender_analysis.rejection_items = [
  909. RejectionItem(
  910. id="RI-01", category="投标文件编制",
  911. description="投标文件必须签署,否则投标无效。",
  912. ),
  913. RejectionItem(
  914. id="RI-02", category="证明材料",
  915. description="证明材料必须有效,否则投标无效。",
  916. ),
  917. ]
  918. writer = FakeWriter()
  919. with patch(
  920. "step4_writing._get_template_text_for_chapter",
  921. return_value="十七、要求承诺函\n模板承诺原文",
  922. ):
  923. _write_single_chapter(
  924. chapter=chapter,
  925. writer=writer,
  926. injector=_ContentInjector({}, []),
  927. analysis=tender_analysis,
  928. company_info=CompanyInfo(),
  929. template_structure=object(),
  930. cfg=SimpleNamespace(enforce_word_limit=False),
  931. outline=outline,
  932. placeholder_map={},
  933. )
  934. self.assertEqual(writer.llm.calls, [])
  935. self.assertIn("投标文件必须签署", commitment.supplement_content)
  936. self.assertIn("证明材料必须有效", commitment.supplement_content)
  937. self.assertNotIn("我方已充分理解并严格响应该项", commitment.supplement_content)
  938. self.assertEqual(len(commitment.supplement_content.splitlines()), 3)
  939. with tempfile.TemporaryDirectory() as directory:
  940. path = _save_chapter_docx(chapter, directory)
  941. artifact = Document(path)
  942. all_text = "\n".join(paragraph.text for paragraph in artifact.paragraphs)
  943. self.assertIn("十七、要求承诺函", all_text)
  944. self.assertIn("模板承诺原文", all_text)
  945. self.assertIn("投标文件必须签署", all_text)
  946. self.assertIn("证明材料必须有效", all_text)
  947. def test_native_commitment_placeholder_receives_compact_rejection_block_once(self):
  948. commitment = Chapter(
  949. id="1.17",
  950. title="十七、要求承诺函",
  951. level=2,
  952. template_original_title="十七、要求承诺函",
  953. direct_rejection_bindings=["RI-01", "RI-02"],
  954. supplement_content=(
  955. "我方郑重承诺:严格遵守全部实质性要求。\n"
  956. "投标文件编制方面:投标文件完整签署。\n"
  957. "证明材料方面:证明材料真实有效。"
  958. ),
  959. )
  960. chapter = Chapter(
  961. id="1",
  962. title="投标人资格、资信证明",
  963. template_chapter_id="1",
  964. children=[commitment],
  965. )
  966. with tempfile.TemporaryDirectory() as directory:
  967. template_path = os.path.join(directory, "template.docx")
  968. template = Document()
  969. template.add_paragraph(
  970. "第一章:投标人资格、资信证明", style="Heading 1"
  971. )
  972. template.add_paragraph("十七、要求承诺函", style="Heading 2")
  973. template.add_paragraph("模板承诺原文")
  974. template.add_paragraph("%%承诺函%%")
  975. template.add_paragraph("如有违反,愿承担相应责任。")
  976. template.add_paragraph("投标人授权代表签字:")
  977. template.add_paragraph("第二章:投标报价", style="Heading 1")
  978. template.save(template_path)
  979. path = _save_chapter_docx(
  980. chapter,
  981. directory,
  982. template_path=template_path,
  983. placeholder_map={},
  984. company_info=CompanyInfo(),
  985. analysis=analysis(),
  986. )
  987. artifact = Document(path)
  988. lines = [paragraph.text for paragraph in artifact.paragraphs]
  989. all_text = "\n".join(lines)
  990. self.assertNotIn("%%承诺函%%", all_text)
  991. self.assertEqual(all_text.count("我方郑重承诺"), 1)
  992. self.assertLess(
  993. lines.index("证明材料方面:证明材料真实有效。"),
  994. lines.index("如有违反,愿承担相应责任。"),
  995. )
  996. self.assertLess(
  997. lines.index("如有违反,愿承担相应责任。"),
  998. lines.index("投标人授权代表签字:"),
  999. )
  1000. def test_native_template_numeric_conflict_uses_current_scoring_requirement(self):
  1001. child = Chapter(
  1002. id="4.2.6",
  1003. title="(六)人员流动率承诺",
  1004. level=3,
  1005. template_original_title="(六)人员稳定保障",
  1006. direct_scoring_criteria=["SC-11"],
  1007. supplement_content="我方承诺一年内项目人员流动率不超过20%。",
  1008. )
  1009. chapter = Chapter(
  1010. id="4",
  1011. title="基本服务方案",
  1012. template_chapter_id="4",
  1013. children=[child],
  1014. )
  1015. tender_analysis = analysis()
  1016. tender_analysis.scoring_criteria = [ScoringCriterion(
  1017. id="SC-11",
  1018. category="技术方案",
  1019. name="服务承诺",
  1020. description="承诺一年内项目人员流动率不超过20%的得分。",
  1021. max_score=1,
  1022. )]
  1023. with tempfile.TemporaryDirectory() as directory:
  1024. template_path = os.path.join(directory, "template.docx")
  1025. template = Document()
  1026. template.add_paragraph("第四章:基本服务方案", style="Heading 1")
  1027. template.add_paragraph("(六)人员稳定保障", style="Heading 3")
  1028. template.add_paragraph(
  1029. "公司制定人员稳定方案,确保一年内人员流动率低于15%。"
  1030. )
  1031. template.add_paragraph("第五章:其他", style="Heading 1")
  1032. template.save(template_path)
  1033. path = _save_chapter_docx(
  1034. chapter,
  1035. directory,
  1036. template_path=template_path,
  1037. placeholder_map={},
  1038. company_info=CompanyInfo(),
  1039. analysis=tender_analysis,
  1040. )
  1041. artifact = Document(path)
  1042. all_text = "\n".join(paragraph.text for paragraph in artifact.paragraphs)
  1043. self.assertNotIn("15%", all_text)
  1044. self.assertIn("人员流动率不超过20%", all_text)
  1045. def test_native_template_table_and_image_survive_step4_artifact(self):
  1046. chapter = Chapter(
  1047. id="3",
  1048. title="需求理解",
  1049. template_chapter_id="3",
  1050. children=[Chapter(
  1051. id="3.1",
  1052. title="一、服务定位与预期目标分析",
  1053. level=2,
  1054. template_original_title="一、服务目标定位",
  1055. supplement_content="评分补充正文",
  1056. )],
  1057. )
  1058. with tempfile.TemporaryDirectory() as directory:
  1059. image_path = os.path.join(directory, "quality.png")
  1060. with open(image_path, "wb") as file:
  1061. file.write(base64.b64decode(
  1062. "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwC"
  1063. "AAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
  1064. ))
  1065. template_path = os.path.join(directory, "template.docx")
  1066. template = Document()
  1067. template.add_paragraph("第三章:需求理解", style="Heading 1")
  1068. template.add_paragraph("一、服务目标定位", style="Heading 2")
  1069. template.add_paragraph("品质督导管理体系")
  1070. template.add_picture(image_path)
  1071. table = template.add_table(rows=1, cols=2)
  1072. table.cell(0, 0).text = "项目名称"
  1073. table.cell(0, 1).text = "%%项目名称%%"
  1074. template.add_paragraph("第四章:其他章节", style="Heading 1")
  1075. template.add_paragraph("不应进入第三章产物")
  1076. template.save(template_path)
  1077. path = _save_chapter_docx(
  1078. chapter,
  1079. directory,
  1080. template_path=template_path,
  1081. placeholder_map={"项目名称": "群众艺术馆项目"},
  1082. company_info=CompanyInfo(),
  1083. analysis=analysis(),
  1084. )
  1085. artifact = Document(path)
  1086. headings = [
  1087. paragraph.text for paragraph in artifact.paragraphs
  1088. if paragraph.style.name.startswith("Heading")
  1089. ]
  1090. all_text = "\n".join(paragraph.text for paragraph in artifact.paragraphs)
  1091. self.assertEqual(
  1092. headings,
  1093. ["第三章 需求理解", "一、服务定位与预期目标分析"],
  1094. )
  1095. self.assertEqual(len(artifact.tables), 1)
  1096. self.assertEqual(len(artifact.inline_shapes), 1)
  1097. self.assertEqual(
  1098. artifact.tables[0].cell(0, 1).text,
  1099. "群众艺术馆项目",
  1100. )
  1101. self.assertIn("品质督导管理体系", all_text)
  1102. self.assertIn("评分补充正文", all_text)
  1103. self.assertNotIn("不应进入第三章产物", all_text)
  1104. def test_step2_project_fields_fill_native_table_cells_and_table_placeholder(self):
  1105. chapter = Chapter(id="2", title="投标报价", template_chapter_id="2")
  1106. summary_table = ExtractedTable(
  1107. table_id="summary",
  1108. source_type="tender_pdf",
  1109. rows=2,
  1110. cols=2,
  1111. table_type="bid_summary_table",
  1112. col_widths=[0.35, 0.65],
  1113. cells=[
  1114. [
  1115. TableCell(text="项目", row=0, col=0, bold=True, alignment="center"),
  1116. TableCell(text="报价说明", row=0, col=1, bold=True, alignment="center"),
  1117. ],
  1118. [
  1119. TableCell(text="本项目", row=1, col=0),
  1120. TableCell(text="按招标文件报价", row=1, col=1),
  1121. ],
  1122. ],
  1123. )
  1124. detail_table = ExtractedTable(
  1125. table_id="reference-detail",
  1126. source_type="reference_docx",
  1127. rows=2,
  1128. cols=2,
  1129. table_type="bid_detail_table",
  1130. title_hint="投标报价分项明细表",
  1131. header_fields=["项目名称:历史物业项目", "项目编号:OLD-001"],
  1132. cells=[
  1133. [
  1134. TableCell(text="费用项目", row=0, col=0, bold=True),
  1135. TableCell(text="金额(元)", row=0, col=1, bold=True),
  1136. ],
  1137. [
  1138. TableCell(text="人员费用", row=1, col=0),
  1139. TableCell(text="12345", row=1, col=1),
  1140. ],
  1141. ],
  1142. )
  1143. tender_analysis = TenderAnalysis(
  1144. project_name="群众艺术馆项目",
  1145. agency_name="采购中心",
  1146. service_requirements=(
  1147. "### 服务范围与内容\n- 建筑物管理\n- 设施设备管理\n"
  1148. "### 服务标准\n- 按采购需求质量标准执行\n"
  1149. "- 接受采购人考核\n"
  1150. "- **服务期限**:自合同签订之日起三年"
  1151. ),
  1152. tender_tables=[summary_table],
  1153. )
  1154. project_data = ProjectData(
  1155. project_id="XM-001",
  1156. project_name="群众艺术馆项目",
  1157. tender_docs=[ParsedDocument(
  1158. file_path="tender.pdf",
  1159. file_name="tender.pdf",
  1160. content="服务期限:自合同签订之日起三年",
  1161. doc_type="tender",
  1162. )],
  1163. reference_tables=[detail_table],
  1164. )
  1165. placeholder_map = build_placeholder_map(
  1166. tender_analysis, CompanyInfo(), project_data
  1167. )
  1168. with tempfile.TemporaryDirectory() as directory:
  1169. template_path = os.path.join(directory, "template.docx")
  1170. template = Document()
  1171. template.add_paragraph("第二章:投标报价", style="Heading 1")
  1172. template.add_paragraph("%%项目名称%%")
  1173. template.add_paragraph("采购机构:%%采购机构名称%%")
  1174. opening = template.add_table(rows=1, cols=3)
  1175. opening.cell(0, 0).text = "%%服务内容%%"
  1176. opening.cell(0, 1).text = "%%服务要求%%"
  1177. opening.cell(0, 2).text = "%%服务期限%%"
  1178. template.add_paragraph("%%报价汇总表%%")
  1179. template.add_paragraph("%%报价明细表%%")
  1180. template.add_paragraph("第三章:其他", style="Heading 1")
  1181. template.save(template_path)
  1182. path = _save_chapter_docx(
  1183. chapter,
  1184. directory,
  1185. template_path=template_path,
  1186. placeholder_map=placeholder_map,
  1187. company_info=CompanyInfo(),
  1188. analysis=tender_analysis,
  1189. project_data=project_data,
  1190. )
  1191. artifact = Document(path)
  1192. all_text = "\n".join(
  1193. [paragraph.text for paragraph in artifact.paragraphs]
  1194. + [cell.text for table in artifact.tables for row in table.rows for cell in row.cells]
  1195. )
  1196. self.assertEqual(len(artifact.tables), 3)
  1197. self.assertNotIn("%%", all_text)
  1198. self.assertIn("采购中心", all_text)
  1199. self.assertIn("建筑物管理", all_text)
  1200. self.assertIn("按采购需求质量标准执行", all_text)
  1201. self.assertIn("自合同签订之日起三年", all_text)
  1202. self.assertIn("按招标文件报价", all_text)
  1203. self.assertIn("群众艺术馆项目", all_text)
  1204. self.assertIn("待投标报价确认", all_text)
  1205. self.assertNotIn("历史物业项目", all_text)
  1206. self.assertNotIn("12345", all_text)
  1207. def test_supplement_sanitizer_removes_tables_and_heading_numbering(self):
  1208. content = _sanitize_bound_block(
  1209. "## 分析\n一、项目特点\n正文\n| 列1 | 列2 |\n|---|---|"
  1210. )
  1211. self.assertNotIn("|", content)
  1212. self.assertNotIn("一、", content)
  1213. self.assertIn("项目特点:", content)
  1214. def test_scoring_block_is_inserted_after_image_before_next_heading(self):
  1215. doc = Document()
  1216. doc.add_paragraph("第四章:服务方案", style="Heading 1")
  1217. heading = doc.add_paragraph("一、需求分析", style="Heading 2")
  1218. doc.add_paragraph("模板正文")
  1219. image_para = doc.add_paragraph()
  1220. image_para.add_run()._element.append(OxmlElement("w:drawing"))
  1221. next_heading = doc.add_paragraph("二、下一模板节", style="Heading 2")
  1222. child = Chapter(
  1223. id="4.1", title="一、需求分析", level=2,
  1224. direct_scoring_criteria=["SC-01"],
  1225. supplement_content="评分补充正文",
  1226. structure_locked=True,
  1227. )
  1228. chapter = Chapter(id="4", title="服务方案", children=[child])
  1229. body = doc.element.body
  1230. region = list(body)[:-1]
  1231. inserted = _insert_locked_scoring_sections(
  1232. doc, body, region, chapter, {}, augment_existing=True
  1233. )
  1234. elements = list(body)
  1235. supplement = next(
  1236. paragraph._element for paragraph in doc.paragraphs
  1237. if paragraph.text == "评分补充正文"
  1238. )
  1239. self.assertEqual(inserted, 1)
  1240. self.assertLess(elements.index(heading._element), elements.index(image_para._element))
  1241. self.assertLess(elements.index(image_para._element), elements.index(supplement))
  1242. self.assertLess(elements.index(supplement), elements.index(next_heading._element))
  1243. class Step4HeaderFooterTests(unittest.TestCase):
  1244. def test_native_chapter_keeps_header_footer_and_replaces_project_name(self):
  1245. chapter = Chapter(
  1246. id="3",
  1247. title="需求理解",
  1248. template_chapter_id="3",
  1249. )
  1250. with tempfile.TemporaryDirectory() as directory:
  1251. template_path = os.path.join(directory, "template.docx")
  1252. template = Document()
  1253. header = template.sections[0].header
  1254. placeholder_para = header.paragraphs[0]
  1255. placeholder_para.add_run("%%项目名称%%项目投标文件")
  1256. legacy_para = header.add_paragraph()
  1257. legacy_para.add_run("上海市建筑工程学校")
  1258. legacy_para.add_run("物业管理服务项目投标文件")
  1259. footer = template.sections[0].footer
  1260. footer.paragraphs[0].text = "第 1 页"
  1261. template.add_paragraph("第三章:需求理解", style="Heading 1")
  1262. template.add_paragraph("模板正文")
  1263. template.add_paragraph("第四章:其他章节", style="Heading 1")
  1264. template.save(template_path)
  1265. path = _save_chapter_docx(
  1266. chapter,
  1267. directory,
  1268. template_path=template_path,
  1269. placeholder_map={
  1270. "项目名称": "上海市群众艺术馆物业管理服务",
  1271. },
  1272. company_info=CompanyInfo(),
  1273. analysis=analysis(),
  1274. )
  1275. artifact = Document(path)
  1276. header_text = "\n".join(
  1277. paragraph.text for paragraph in artifact.sections[0].header.paragraphs
  1278. )
  1279. self.assertIn(
  1280. "上海市群众艺术馆物业管理服务项目投标文件",
  1281. header_text,
  1282. )
  1283. self.assertNotIn("%%", header_text)
  1284. self.assertNotIn("上海市建筑工程学校", header_text)
  1285. footer_text = artifact.sections[0].footer.paragraphs[0].text
  1286. self.assertIn("第 1 页", footer_text)
  1287. body_text = "\n".join(
  1288. paragraph.text for paragraph in artifact.paragraphs
  1289. )
  1290. self.assertIn("模板正文", body_text)
  1291. self.assertNotIn("其他章节", body_text)
  1292. def test_fallback_index_chapter_has_header_and_footer(self):
  1293. chapter = Chapter(
  1294. id="index",
  1295. title="与评标有关的投标文件主要内容索引表",
  1296. chapter_type=ChapterType.BUSINESS,
  1297. )
  1298. with tempfile.TemporaryDirectory() as directory:
  1299. path = _save_chapter_docx(
  1300. chapter,
  1301. directory,
  1302. placeholder_map={"项目名称": "群众艺术馆项目"},
  1303. analysis=analysis(),
  1304. )
  1305. artifact = Document(path)
  1306. header_text = "\n".join(
  1307. paragraph.text for paragraph in artifact.sections[0].header.paragraphs
  1308. )
  1309. self.assertIn("群众艺术馆项目投标文件", header_text)
  1310. self.assertTrue(artifact.sections[0].footer.paragraphs)
  1311. def test_native_chapters_use_unified_first_section_header(self):
  1312. chapter1 = Chapter(
  1313. id="1",
  1314. title="投标人资格、资信证明",
  1315. template_chapter_id="1",
  1316. )
  1317. chapter4 = Chapter(
  1318. id="4",
  1319. title="基本服务方案",
  1320. template_chapter_id="4",
  1321. )
  1322. with tempfile.TemporaryDirectory() as directory:
  1323. template_path = os.path.join(directory, "template.docx")
  1324. document = Document()
  1325. first_section = document.sections[0]
  1326. first_section.header.is_linked_to_previous = False
  1327. first_section.header.paragraphs[0].text = "第一页眉"
  1328. document.add_paragraph("第一章:投标人资格、资信证明", style="Heading 1")
  1329. document.add_paragraph("第一章正文")
  1330. second_section = document.add_section(WD_SECTION.NEW_PAGE)
  1331. second_section.header.is_linked_to_previous = False
  1332. second_section.header.paragraphs[0].text = "第二页眉"
  1333. document.add_paragraph("第四章:基本服务方案", style="Heading 1")
  1334. document.add_paragraph("第四章正文")
  1335. document.save(template_path)
  1336. path1 = _save_chapter_docx(
  1337. chapter1,
  1338. directory,
  1339. template_path=template_path,
  1340. placeholder_map={},
  1341. company_info=CompanyInfo(),
  1342. analysis=analysis(),
  1343. )
  1344. path4 = _save_chapter_docx(
  1345. chapter4,
  1346. directory,
  1347. template_path=template_path,
  1348. placeholder_map={},
  1349. company_info=CompanyInfo(),
  1350. analysis=analysis(),
  1351. )
  1352. artifact1 = Document(path1)
  1353. artifact4 = Document(path4)
  1354. header1 = "\n".join(
  1355. paragraph.text for paragraph in artifact1.sections[0].header.paragraphs
  1356. )
  1357. header4 = "\n".join(
  1358. paragraph.text for paragraph in artifact4.sections[0].header.paragraphs
  1359. )
  1360. self.assertIn("第一页眉", header1)
  1361. self.assertIn("第一页眉", header4)
  1362. self.assertNotIn("第二页眉", header4)
  1363. def test_native_chapter_applies_section_header_to_internal_section_breaks(self):
  1364. chapter4 = Chapter(
  1365. id="4",
  1366. title="基本服务方案",
  1367. template_chapter_id="4",
  1368. )
  1369. with tempfile.TemporaryDirectory() as directory:
  1370. template_path = os.path.join(directory, "template.docx")
  1371. document = Document()
  1372. first_section = document.sections[0]
  1373. first_section.header.is_linked_to_previous = False
  1374. first_section.header.paragraphs[0].text = "第一页眉"
  1375. document.add_paragraph("第一章:投标人资格、资信证明", style="Heading 1")
  1376. document.add_paragraph("第一章正文")
  1377. second_section = document.add_section(WD_SECTION.NEW_PAGE)
  1378. second_section.header.is_linked_to_previous = False
  1379. second_section.header.paragraphs[0].text = "第二页眉"
  1380. document.add_paragraph("第四章:基本服务方案", style="Heading 1")
  1381. document.add_paragraph("第四章正文")
  1382. internal_section = document.add_section(WD_SECTION.NEW_PAGE)
  1383. internal_section.header.is_linked_to_previous = False
  1384. internal_section.header.paragraphs[0].text = ""
  1385. document.add_paragraph("内部分节正文")
  1386. document.add_paragraph("第五章:其他章节", style="Heading 1")
  1387. document.save(template_path)
  1388. path = _save_chapter_docx(
  1389. chapter4,
  1390. directory,
  1391. template_path=template_path,
  1392. placeholder_map={},
  1393. company_info=CompanyInfo(),
  1394. analysis=analysis(),
  1395. )
  1396. artifact = Document(path)
  1397. headers = [
  1398. "\n".join(paragraph.text for paragraph in section.header.paragraphs)
  1399. for section in artifact.sections
  1400. ]
  1401. self.assertTrue(headers)
  1402. for header_text in headers:
  1403. self.assertIn("第一页眉", header_text)
  1404. self.assertNotIn("第二页眉", header_text)
  1405. def test_native_index_chapter_uses_template_header(self):
  1406. chapter = Chapter(
  1407. id="index",
  1408. title="与评标有关的投标文件主要内容索引表",
  1409. chapter_type=ChapterType.BUSINESS,
  1410. )
  1411. with tempfile.TemporaryDirectory() as directory:
  1412. template_path = os.path.join(directory, "template.docx")
  1413. template = Document()
  1414. header = template.sections[0].header
  1415. header.is_linked_to_previous = False
  1416. header.paragraphs[0].text = "%%项目名称%%项目投标文件"
  1417. footer = template.sections[0].footer
  1418. footer.is_linked_to_previous = False
  1419. footer.paragraphs[0].text = "第 1 页"
  1420. template.add_paragraph("第一章:投标人资格、资信证明", style="Heading 1")
  1421. template.save(template_path)
  1422. path = _save_chapter_docx(
  1423. chapter,
  1424. directory,
  1425. template_path=template_path,
  1426. placeholder_map={"项目名称": "群众艺术馆项目"},
  1427. company_info=CompanyInfo(),
  1428. analysis=analysis(),
  1429. )
  1430. artifact = Document(path)
  1431. header_text = "\n".join(
  1432. paragraph.text for paragraph in artifact.sections[0].header.paragraphs
  1433. )
  1434. self.assertIn("群众艺术馆项目项目投标文件", header_text)
  1435. self.assertNotIn("%%", header_text)
  1436. footer_text = artifact.sections[0].footer.paragraphs[0].text
  1437. self.assertIn("第 1 页", footer_text)
  1438. self.assertTrue(artifact.paragraphs)
  1439. if __name__ == "__main__":
  1440. unittest.main()