test_step6_assembler.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. """Step 6 聚合装配的快速回归测试(不调用 LLM)。"""
  2. import os
  3. import tempfile
  4. import unittest
  5. import zipfile
  6. import xml.etree.ElementTree as ET
  7. from docx import Document as DocxDocument
  8. from models import BidOutline
  9. from step6_exporting.assembler import (
  10. _HeadingRecord,
  11. _build_index_table_from_template,
  12. _load_table_template,
  13. assemble_step5_document,
  14. )
  15. _W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
  16. def _w(tag: str) -> str:
  17. return f"{{{_W_NS}}}{tag}"
  18. def _element_text(element) -> str:
  19. return "".join(node.text or "" for node in element.iter(_w("t")))
  20. def _make_chapter(path: str, title: str, sub_headings) -> None:
  21. doc = DocxDocument()
  22. section = doc.sections[0]
  23. section.header.paragraphs[0].text = "章节原生页眉"
  24. section.footer.paragraphs[0].text = "章节原生页脚"
  25. doc.add_paragraph(title, style="Heading 1")
  26. for heading in sub_headings:
  27. doc.add_paragraph(heading, style="Heading 2")
  28. doc.add_paragraph(f"这是 {heading} 的响应正文,包含数字 123 和英文 English。")
  29. doc.save(path)
  30. def _make_template(path: str) -> None:
  31. doc = DocxDocument()
  32. section = doc.sections[0]
  33. section.different_first_page_header_footer = True
  34. default_header = section.header
  35. default_header.paragraphs[0].text = "默认页眉"
  36. first_header = section.first_page_header
  37. first_header.paragraphs[0].text = "首页页眉"
  38. footer = section.footer
  39. footer.paragraphs[0].text = "页脚"
  40. doc.add_paragraph("模板占位正文")
  41. doc.save(path)
  42. def _make_index_table_template(path: str) -> None:
  43. doc = DocxDocument()
  44. table = doc.add_table(rows=2, cols=4)
  45. header = ["序号", "主要内容概述", "章节", "详细内容所在投标文件页次"]
  46. for col, text in enumerate(header):
  47. table.cell(0, col).text = text
  48. for col, text in enumerate(["1", "示例", "第一章", "P1"]):
  49. table.cell(1, col).text = text
  50. doc.save(path)
  51. class Step6AssemblerTests(unittest.TestCase):
  52. def test_assemble_produces_cover_toc_index_and_chapters(self):
  53. temp_dir = tempfile.mkdtemp()
  54. template_path = os.path.join(temp_dir, "template.docx")
  55. _make_template(template_path)
  56. ch1 = os.path.join(temp_dir, "1.docx")
  57. ch2 = os.path.join(temp_dir, "2.docx")
  58. ch3 = os.path.join(temp_dir, "3.docx")
  59. _make_chapter(ch1, "第一章 投标人资格、资信证明", ["十七、“★” 要求承诺函"])
  60. _make_chapter(ch2, "第二章 投标报价", ["五、报价得分"])
  61. _make_chapter(ch3, "第三章 需求理解", ["一、服务目标定位"])
  62. outline = BidOutline(project_name="测试项目")
  63. outline.evaluation_index_entries = [
  64. {
  65. "entry_type": "scoring",
  66. "source_id": "SC-01#1",
  67. "criterion_id": "SC-01",
  68. "display_name": "报价得分",
  69. "requirement": "报价得分=报价分值×(评标基准价/评审价)",
  70. "score": 10.0,
  71. "final_heading_id": "2.5",
  72. "final_heading_title": "五、报价得分",
  73. "final_heading_path": "投标报价 → 五、报价得分",
  74. },
  75. {
  76. "entry_type": "rejection",
  77. "source_id": "RI-01",
  78. "criterion_id": "",
  79. "display_name": "资格条件",
  80. "requirement": "投标人不满足《中华人民共和国政府采购法》第二十二条规定。",
  81. "score": None,
  82. "final_heading_id": "1.17",
  83. "final_heading_title": "十七、“★” 要求承诺函",
  84. "final_heading_path": "投标人资格、资信证明 → 十七、“★” 要求承诺函",
  85. },
  86. ]
  87. output_path = os.path.join(temp_dir, "aggregated.docx")
  88. records = [
  89. {"id": "1", "title": "投标人资格、资信证明", "artifact_path": ch1, "status": "complete"},
  90. {"id": "2", "title": "投标报价", "artifact_path": ch2, "status": "complete"},
  91. {"id": "3", "title": "需求理解", "artifact_path": ch3, "status": "complete"},
  92. ]
  93. report = assemble_step5_document(
  94. records,
  95. outline,
  96. output_path,
  97. template_path=template_path,
  98. project_name="测试项目",
  99. )
  100. self.assertTrue(os.path.isfile(output_path))
  101. self.assertEqual(report.index_row_count, 1)
  102. self.assertTrue(report.toc_inserted)
  103. self.assertEqual(report.chapter_count, 3)
  104. with zipfile.ZipFile(output_path, "r") as output_zip:
  105. names = set(output_zip.namelist())
  106. self.assertIn("word/document.xml", names)
  107. document_root = ET.fromstring(output_zip.read("word/document.xml"))
  108. body = document_root.find(_w("body"))
  109. self.assertIsNotNone(body)
  110. body_text = _element_text(body)
  111. self.assertIn("测试项目", body_text)
  112. self.assertIn("目 录", body_text)
  113. self.assertIn("第一章 投标人资格、资信证明", body_text)
  114. self.assertIn("第二章 投标报价", body_text)
  115. self.assertIn("第三章 需求理解", body_text)
  116. self.assertIn("商务部分", body_text)
  117. self.assertIn("技术部分", body_text)
  118. self.assertIn("主要内容概述", body_text)
  119. instr_texts = [node.text or "" for node in body.iter(_w("instrText"))]
  120. combined = " ".join(instr_texts)
  121. self.assertIn("TOC", combined)
  122. self.assertIn("PAGEREF", combined)
  123. self.assertNotIn("第二十二条", body_text)
  124. header1 = output_zip.read("word/header1.xml")
  125. header2 = output_zip.read("word/header2.xml")
  126. footer1 = output_zip.read("word/footer1.xml")
  127. self.assertIn("章节原生页眉".encode("utf-8"), header1)
  128. self.assertIn("章节原生页脚".encode("utf-8"), footer1)
  129. # 正文 run 应统一为宋体 + Times New Roman。
  130. for run in body.iter(_w("r")):
  131. if _element_text(run).startswith("这是"):
  132. r_pr = run.find(_w("rPr"))
  133. self.assertIsNotNone(r_pr)
  134. fonts = r_pr.find(_w("rFonts"))
  135. self.assertIsNotNone(fonts)
  136. self.assertEqual(fonts.get(_w("eastAsia")), "宋体")
  137. self.assertEqual(fonts.get(_w("ascii")), "Times New Roman")
  138. break
  139. else:
  140. self.fail("未找到正文 run 以验证字体")
  141. def test_index_table_reuses_extracted_template(self):
  142. temp_dir = tempfile.mkdtemp()
  143. table_template_path = os.path.join(temp_dir, "index_table.docx")
  144. _make_index_table_template(table_template_path)
  145. template_tbl = _load_table_template(table_template_path)
  146. self.assertIsNotNone(template_tbl)
  147. records = [
  148. _HeadingRecord(chapter_title="投标报价", text="第二章 投标报价", level=1, bookmark="CodexIdx1"),
  149. _HeadingRecord(chapter_title="投标报价", text="五、报价得分", level=2, bookmark="CodexIdx2"),
  150. ]
  151. entries = [
  152. {
  153. "entry_type": "scoring",
  154. "display_name": "报价得分",
  155. "score": 10.0,
  156. "final_heading_path": "投标报价 → 五、报价得分",
  157. }
  158. ]
  159. table, row_count = _build_index_table_from_template(template_tbl, entries, records)
  160. self.assertEqual(row_count, 1)
  161. rows = table.findall(_w("tr"))
  162. self.assertEqual(len(rows), 2)
  163. header_cells = [_element_text(tc).strip() for tc in rows[0].findall(_w("tc"))]
  164. self.assertEqual(header_cells, ["序号", "主要内容概述", "章节", "详细内容所在投标文件页次"])
  165. data_cells = [_element_text(tc).strip() for tc in rows[1].findall(_w("tc"))]
  166. self.assertEqual(data_cells[0], "1")
  167. self.assertIn("报价得分", data_cells[1])
  168. self.assertEqual(data_cells[2], "第二章 投标报价 → 五、报价得分")
  169. if __name__ == "__main__":
  170. unittest.main()