test_step1_postamble.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import tempfile
  2. import unittest
  3. from pathlib import Path
  4. from unittest.mock import patch
  5. from docx import Document
  6. from models import ExtractedTable, TableCell, TenderAnalysis
  7. from pdf_table_to_docx.table_parser import CellInfo, TableInfo
  8. from step6_exporting.docx_builder import _generate_table_data_from_tender
  9. from step1_parsing.document_parser import (
  10. _extract_items_from_pdf,
  11. _write_items_to_docx,
  12. )
  13. from step1_parsing.table_extractor import (
  14. _build_reference_doc_stream,
  15. _build_reference_fragment_impl,
  16. _clean_reference_table_artifacts,
  17. _write_fragment_docx,
  18. extract_reference_tables_with_llm,
  19. extract_tables_from_template_docx,
  20. write_reference_tables_to_docx,
  21. )
  22. class Step1PostambleTests(unittest.TestCase):
  23. def _make_bid_detail_docx(self, path: str) -> None:
  24. """构造“三、投标报价分项明细表 + 明细1/明细2 两张表”的参考文档。"""
  25. doc = Document()
  26. doc.add_paragraph("三、投标报价分项明细表")
  27. doc.add_paragraph("明细1:人员费用")
  28. table1 = doc.add_table(rows=2, cols=3)
  29. table1.cell(0, 0).text = "编号"
  30. table1.cell(0, 1).text = "内容"
  31. table1.cell(0, 2).text = "金额"
  32. table1.cell(1, 0).text = "1"
  33. table1.cell(1, 1).text = "人员费用"
  34. table1.cell(1, 2).text = "4930728"
  35. doc.add_paragraph("明细2:办公费用")
  36. table2 = doc.add_table(rows=2, cols=3)
  37. table2.cell(0, 0).text = "编号"
  38. table2.cell(0, 1).text = "内容"
  39. table2.cell(0, 2).text = "金额"
  40. table2.cell(1, 0).text = "1"
  41. table2.cell(1, 1).text = "办公杂费"
  42. table2.cell(1, 2).text = "3600"
  43. doc.save(path)
  44. def test_reference_table_entry_marks_source_provenance(self):
  45. table = ExtractedTable(table_id="REF-T01", source_type="template_docx")
  46. with (
  47. patch(
  48. "step1_parsing.table_extractor.extract_tables_from_template_docx",
  49. return_value=[table],
  50. ),
  51. patch(
  52. "step1_parsing.table_extractor.enrich_reference_tables_with_llm",
  53. side_effect=lambda tables, docx_path: tables,
  54. ),
  55. ):
  56. result = extract_reference_tables_with_llm("reference.docx")
  57. self.assertEqual(result[0].source_type, "reference_docx")
  58. def test_pdf_table_postamble_survives_model_and_docx_output(self):
  59. postamble = (
  60. "说明:(1)所有价格均系用人民币表示。\n"
  61. "(2)投标人应按照招标需求报价。"
  62. )
  63. llm_items = [{
  64. "name": "3.投标报价汇总表格式",
  65. "type": "表",
  66. "pages": [1],
  67. "preamble": "项目名称:",
  68. "postamble": postamble,
  69. "headers": ["序号", "分项名称"],
  70. "rows": [["1", "人员费用"]],
  71. }]
  72. table = TableInfo(
  73. page_num=0,
  74. rows=2,
  75. cols=2,
  76. cells=[
  77. [CellInfo(text="序号"), CellInfo(text="分项名称")],
  78. [CellInfo(text="1"), CellInfo(text="人员费用")],
  79. ],
  80. title_hint="3.投标报价汇总表格式",
  81. original_pages=[1],
  82. )
  83. with patch(
  84. "step1_parsing.document_parser._read_pdf_pages",
  85. return_value=[(1, "PDF 正文")],
  86. ), patch(
  87. "step1_parsing.document_parser._llm_extract_all_items",
  88. return_value=llm_items,
  89. ), patch(
  90. "pdf_table_to_docx.extractor.extract_tables_from_pdf",
  91. return_value=[table],
  92. ):
  93. items = _extract_items_from_pdf("tender.pdf", "招标文件", object())
  94. self.assertEqual(len(items), 1)
  95. self.assertEqual(items[0].postamble, postamble)
  96. self.assertEqual(items[0].source_table.postamble, postamble)
  97. with tempfile.TemporaryDirectory() as temp_dir:
  98. _write_items_to_docx(items, temp_dir)
  99. output = next(Path(temp_dir).glob("*.docx"))
  100. doc = Document(output)
  101. self.assertEqual(items[0].artifact_path, str(output.resolve()))
  102. self.assertEqual(
  103. items[0].source_table.artifact_path, str(output.resolve())
  104. )
  105. texts = [p.text for p in doc.paragraphs if p.text.strip()]
  106. self.assertEqual(texts[-2:], postamble.splitlines())
  107. def test_step6_table_payload_keeps_tender_postamble(self):
  108. table = ExtractedTable(
  109. table_id="T-1",
  110. rows=2,
  111. cols=2,
  112. cells=[
  113. [TableCell(text="序号"), TableCell(text="分项名称")],
  114. [TableCell(text="1"), TableCell(text="人员费用")],
  115. ],
  116. title_hint="投标报价汇总表",
  117. table_type="bid_summary_table",
  118. postamble="说明:合计应与开标一览表报价相等。",
  119. )
  120. payload = _generate_table_data_from_tender(
  121. "投标报价汇总表",
  122. {"bid_summary_table": [table]},
  123. TenderAnalysis(project_name="测试项目"),
  124. None,
  125. )
  126. self.assertEqual(payload[3], table.postamble)
  127. def test_reference_table_artifact_clones_native_merge_xml(self):
  128. with tempfile.TemporaryDirectory() as temp_dir:
  129. source_path = Path(temp_dir) / "reference.docx"
  130. output_dir = Path(temp_dir) / "tables"
  131. source = Document()
  132. source.add_paragraph("设备维修记录")
  133. table = source.add_table(rows=4, cols=4)
  134. table.cell(0, 0).merge(table.cell(2, 1)).text = "跨行跨列"
  135. table.cell(0, 2).merge(table.cell(0, 3)).text = "横向合并"
  136. table.cell(1, 2).text = "内容"
  137. source.save(source_path)
  138. extracted = extract_tables_from_template_docx(str(source_path))
  139. written = write_reference_tables_to_docx(
  140. extracted, str(output_dir)
  141. )
  142. self.assertEqual(written, 1)
  143. self.assertTrue(extracted[0].artifact_path)
  144. artifact = Document(extracted[0].artifact_path)
  145. self.assertEqual(len(artifact.tables), 1)
  146. xml = artifact.tables[0]._tbl.xml
  147. self.assertIn("w:gridSpan", xml)
  148. self.assertIn("w:vMerge", xml)
  149. self.assertIn("跨行跨列", xml)
  150. def test_reference_doc_stream_runs_in_isolated_worker(self):
  151. with tempfile.TemporaryDirectory() as temp_dir:
  152. source_path = Path(temp_dir) / "reference.docx"
  153. source = Document()
  154. source.add_paragraph("表格前说明")
  155. table = source.add_table(rows=1, cols=2)
  156. table.cell(0, 0).text = "字段一"
  157. table.cell(0, 1).text = "字段二"
  158. source.add_paragraph("表格后说明")
  159. source.save(source_path)
  160. stream = _build_reference_doc_stream(str(source_path))
  161. self.assertEqual([item["kind"] for item in stream], ["p", "tbl", "p"])
  162. self.assertEqual(stream[0]["text"], "表格前说明")
  163. self.assertIn("字段一", stream[1]["text"])
  164. self.assertEqual(stream[2]["text"], "表格后说明")
  165. def test_reference_doc_stream_native_crash_degrades_to_empty_context(self):
  166. with patch(
  167. "step1_parsing.table_extractor._run_reference_xml_worker",
  168. return_value=None,
  169. ):
  170. self.assertEqual(_build_reference_doc_stream("large.docx"), [])
  171. def test_bid_detail_fragment_preserves_paragraphs_and_multiple_tables(self):
  172. with tempfile.TemporaryDirectory() as temp_dir:
  173. source_path = Path(temp_dir) / "reference.docx"
  174. self._make_bid_detail_docx(str(source_path))
  175. fragment_xml = _build_reference_fragment_impl(
  176. str(source_path), 0, 4
  177. )
  178. self.assertEqual(len(fragment_xml), 5)
  179. output_path = Path(temp_dir) / "投标报价分项明细表.docx"
  180. _write_fragment_docx(fragment_xml, str(output_path))
  181. doc = Document(output_path)
  182. texts = [paragraph.text for paragraph in doc.paragraphs]
  183. self.assertIn("三、投标报价分项明细表", texts)
  184. self.assertIn("明细1:人员费用", texts)
  185. self.assertIn("明细2:办公费用", texts)
  186. self.assertEqual(len(doc.tables), 2)
  187. self.assertIn("人员费用", doc.tables[0].cell(1, 1).text)
  188. self.assertIn("办公杂费", doc.tables[1].cell(1, 1).text)
  189. def test_bid_detail_fragment_written_as_single_named_docx(self):
  190. with tempfile.TemporaryDirectory() as temp_dir:
  191. source_path = Path(temp_dir) / "reference.docx"
  192. self._make_bid_detail_docx(str(source_path))
  193. fragment_xml = _build_reference_fragment_impl(
  194. str(source_path), 0, 4
  195. )
  196. output_dir = Path(temp_dir) / "tables"
  197. output_dir.mkdir()
  198. stale_numbered = output_dir / "08_旧表.docx"
  199. stale_numbered.write_bytes(b"old")
  200. stale_named = output_dir / "投标报价分项明细表.docx"
  201. stale_named.write_bytes(b"old")
  202. fragment = ExtractedTable(
  203. table_id="FRAGMENT",
  204. source_type="reference_docx",
  205. title_hint="投标报价分项明细表",
  206. caption="投标报价分项明细表",
  207. table_type="bid_detail_table",
  208. )
  209. fragment.is_fragment = True
  210. fragment.fragment_xml = fragment_xml
  211. normal = ExtractedTable(
  212. table_id="NORMAL",
  213. source_type="reference_docx",
  214. title_hint="其他表",
  215. table_type="other",
  216. rows=1,
  217. cols=1,
  218. cells=[[TableCell(text="内容")]],
  219. )
  220. written = write_reference_tables_to_docx(
  221. [fragment, normal], str(output_dir)
  222. )
  223. self.assertEqual(written, 2)
  224. self.assertFalse(stale_numbered.exists())
  225. self.assertTrue((output_dir / "投标报价分项明细表.docx").exists())
  226. self.assertTrue(
  227. fragment.artifact_path.endswith("投标报价分项明细表.docx")
  228. )
  229. self.assertTrue(
  230. fragment.fragment_docx_path.endswith("投标报价分项明细表.docx")
  231. )
  232. artifact = Document(
  233. output_dir / "投标报价分项明细表.docx"
  234. )
  235. self.assertEqual(len(artifact.tables), 2)
  236. artifacts = sorted(path.name for path in output_dir.glob("*.docx"))
  237. self.assertEqual(artifacts.count("投标报价分项明细表.docx"), 1)
  238. self.assertEqual(len(artifacts), 2)
  239. self.assertTrue(any(name.startswith("02_") for name in artifacts))
  240. def test_clean_reference_table_artifacts_removes_numbered_and_named_stale_files(self):
  241. with tempfile.TemporaryDirectory() as temp_dir:
  242. output_dir = Path(temp_dir)
  243. (output_dir / "01_旧表.docx").write_bytes(b"old")
  244. (output_dir / "投标报价分项明细表.docx").write_bytes(b"old")
  245. (output_dir / "keep.txt").write_text("keep")
  246. removed = _clean_reference_table_artifacts(str(output_dir))
  247. self.assertEqual(removed, 2)
  248. self.assertFalse((output_dir / "01_旧表.docx").exists())
  249. self.assertFalse((output_dir / "投标报价分项明细表.docx").exists())
  250. self.assertTrue((output_dir / "keep.txt").exists())
  251. def test_extract_reference_tables_with_llm_builds_bid_detail_fragment(self):
  252. with tempfile.TemporaryDirectory() as temp_dir:
  253. source_path = Path(temp_dir) / "reference.docx"
  254. self._make_bid_detail_docx(str(source_path))
  255. output_dir = Path(temp_dir) / "tables"
  256. with patch(
  257. "step1_parsing.table_extractor.enrich_reference_tables_with_llm",
  258. side_effect=lambda tables, docx_path: tables,
  259. ):
  260. result = extract_reference_tables_with_llm(
  261. str(source_path), output_dir=str(output_dir)
  262. )
  263. self.assertEqual(len(result), 1)
  264. fragment = result[0]
  265. self.assertTrue(getattr(fragment, "is_fragment", False))
  266. self.assertEqual(fragment.title_hint, "投标报价分项明细表")
  267. self.assertEqual(fragment.fragment_source_indices, [0, 1])
  268. self.assertTrue(
  269. (output_dir / "投标报价分项明细表.docx").exists()
  270. )
  271. artifact = Document(
  272. output_dir / "投标报价分项明细表.docx"
  273. )
  274. self.assertEqual(len(artifact.tables), 2)
  275. texts = [paragraph.text for paragraph in artifact.paragraphs]
  276. self.assertIn("三、投标报价分项明细表", texts)
  277. self.assertIn("明细2:办公费用", texts)
  278. if __name__ == "__main__":
  279. unittest.main()