import base64 import json import os import tempfile import unittest from types import SimpleNamespace from unittest.mock import patch from docx import Document from docx.enum.section import WD_SECTION from docx.oxml import OxmlElement from models import ( BidOutline, Chapter, ChapterType, CompanyInfo, ExtractedTable, ParsedDocument, ProjectData, RejectionItem, ScoringCriterion, FormatReconciliation, ReconciliationDecision, ReconciliationReport, TableCell, TenderAnalysis, ) from step4_writing import ( _defer_evaluation_index_chapter, _partition_step4_chapters, _persist_all_chapter_docs, _resolve_chapter_text_placeholders, _sanitize_bound_block, _save_chapter_docx, _select_placeholder_table, _write_single_chapter, ) from step4_writing.content_injector import _ContentInjector from step4_writing.placeholder_engine import build_placeholder_map from step6_exporting.docx_builder import _insert_locked_scoring_sections class FakeBlockLlm: def __init__(self, blocks=None): self.blocks = blocks or [] self.calls = [] def extract_json(self, **kwargs): self.calls.append(kwargs) return {"blocks": self.blocks} class FakeWriter: def __init__(self, blocks=None): self.llm = FakeBlockLlm(blocks) def write_chapter(self, **_kwargs): raise AssertionError("模板章不得进入整章 LLM 攄写路径") def analysis(): return TenderAnalysis( project_name="群众艺术馆项目", buyer_name="采购人甲", tender_project_id="XM-001", ) class Step4BlockGenerationTests(unittest.TestCase): def test_table_source_priority_rejects_reconciliation_type_mismatch(self): wrong_scoring = ExtractedTable( table_id="tender-scoring", source_file="tender.pdf", source_type="tender_pdf", table_type="scoring", rows=8, cols=5, title_hint="客观分评审因素响应情况表", ) tender_qualification = ExtractedTable( table_id="tender-qualification", source_file="tender.pdf", source_type="tender_pdf", table_type="qualification_response", rows=5, cols=5, title_hint="资格条件响应表", ) reference_qualification = ExtractedTable( table_id="reference-qualification", source_file="reference.docx", source_type="reference_docx", table_type="qualification_response", rows=5, cols=5, title_hint="资格条件响应表", ) report = ReconciliationReport(reconciliations=[FormatReconciliation( section_name="资格条件响应表", template_table=ExtractedTable(table_type="qualification_response"), best_match=wrong_scoring, replace_with_tender_table=wrong_scoring, decision=ReconciliationDecision.REPLACE_WITH_TENDER, )]) project_data = ProjectData( project_id="test", project_name="测试项目", tender_docs=[ParsedDocument( file_path="tender.pdf", file_name="tender.pdf", content="", doc_type="tender", )], reference_tables=[reference_qualification], metadata={"reconciliation_report": report}, ) analysis = TenderAnalysis(project_name="测试项目", tender_tables=[ wrong_scoring, tender_qualification, ]) selected = _select_placeholder_table( "资格条件响应表", analysis, project_data ) self.assertIs(selected, tender_qualification) def test_qualification_placeholder_uses_current_tender_rows_not_wrong_match(self): def cells(rows): return [ [TableCell(text=value, row=ri, col=ci) for ci, value in enumerate(row)] for ri, row in enumerate(rows) ] wrong_scoring = ExtractedTable( table_id="scoring", source_file="tender.pdf", source_type="tender_pdf", table_type="scoring", rows=2, cols=5, cells=cells([ ["序号", "名称", "是否响应", "响应情况", "页码"], ["1", "历史错表", "", "", ""], ]), ) current_qualification = ExtractedTable( table_id="qualification", source_file="tender.pdf", source_type="tender_pdf", table_type="qualification_response", rows=2, cols=5, title_hint="资格条件响应表", cells=cells([ ["项目内容", "具备的条件说明(要求)", "投标检查项(响应内容说明(是/否))", "详细内容所对应电子投标文件名称", "备注"], ["当前资格", "当前招标资格要求", "", "", ""], ]), ) report = ReconciliationReport(reconciliations=[FormatReconciliation( section_name="资格条件响应表", template_table=ExtractedTable(table_type="qualification_response"), best_match=wrong_scoring, replace_with_tender_table=wrong_scoring, decision=ReconciliationDecision.REPLACE_WITH_TENDER, )]) project_data = ProjectData( project_id="test", project_name="当前项目", tender_docs=[ParsedDocument( file_path="tender.pdf", file_name="tender.pdf", content="", doc_type="tender", )], metadata={"reconciliation_report": report}, ) analysis = TenderAnalysis( project_name="当前项目", tender_tables=[wrong_scoring, current_qualification], ) chapter = Chapter(id="1", title="投标人资格、资信证明", level=1) with tempfile.TemporaryDirectory() as directory: template_path = os.path.join(directory, "template.docx") template = Document() template.add_paragraph("第一章:投标人资格、资信证明", style="Heading 1") template.add_paragraph("%%资格条件响应表%%") template.add_paragraph("第二章:投标报价", style="Heading 1") template.save(template_path) output_path = _save_chapter_docx( chapter, directory, template_path=template_path, analysis=analysis, project_data=project_data, ) artifact = Document(output_path) values = [ cell.text for table in artifact.tables for row in table.rows for cell in row.cells ] self.assertIn("当前招标资格要求", values) self.assertIn("是", values) self.assertIn("投标文件对应章节", values) self.assertNotIn("历史错表", values) def test_table_source_priority_is_tender_then_procurement_then_reference(self): def table(table_id, path, source_type): return ExtractedTable( table_id=table_id, source_file=path, source_type=source_type, table_type="qualification_response", title_hint="资格条件响应表", ) tender = table("tender", "tender.pdf", "tender_pdf") procurement = table("procurement", "procurement.docx", "procurement_docx") reference = table("reference", "reference.docx", "reference_docx") project_data = ProjectData( project_id="test", project_name="测试项目", tender_docs=[ParsedDocument( file_path="tender.pdf", file_name="tender.pdf", content="", doc_type="tender" )], procurement_docs=[ParsedDocument( file_path="procurement.docx", file_name="procurement.docx", content="", doc_type="procurement", )], reference_tables=[reference], ) selected = _select_placeholder_table( "资格条件响应表", TenderAnalysis( project_name="测试项目", tender_tables=[reference, procurement, tender], ), project_data, ) self.assertIs(selected, tender) selected_without_tender = _select_placeholder_table( "资格条件响应表", TenderAnalysis( project_name="测试项目", tender_tables=[reference, procurement], ), project_data, ) self.assertIs(selected_without_tender, procurement) selected_reference_only = _select_placeholder_table( "资格条件响应表", TenderAnalysis(project_name="测试项目", tender_tables=[]), project_data, ) self.assertIs(selected_reference_only, reference) def test_generic_table_name_uses_same_priority_without_known_type(self): def table(table_id, path, source_type, title): return ExtractedTable( table_id=table_id, source_file=path, source_type=source_type, table_type="other", title_hint=title, ) tender = table("tender", "tender.pdf", "tender_pdf", "拟投入设备清单表") procurement = table( "procurement", "procurement.docx", "procurement_docx", "拟投入设备清单表" ) reference = table( "reference", "reference.docx", "reference_docx", "拟投入设备清单表" ) wrong = table("wrong", "tender.pdf", "tender_pdf", "人员配置表") project_data = ProjectData( project_id="test", project_name="测试项目", tender_docs=[ParsedDocument( file_path="tender.pdf", file_name="tender.pdf", content="", doc_type="tender", )], procurement_docs=[ParsedDocument( file_path="procurement.docx", file_name="procurement.docx", content="", doc_type="procurement", )], reference_tables=[reference], metadata={"reconciliation_report": ReconciliationReport( reconciliations=[FormatReconciliation( section_name="拟投入设备清单表", template_table=ExtractedTable(title_hint="拟投入设备清单表"), best_match=wrong, replace_with_tender_table=wrong, decision=ReconciliationDecision.REPLACE_WITH_TENDER, )], )}, ) selected = _select_placeholder_table( "拟投入设备清单表", TenderAnalysis( project_name="测试项目", tender_tables=[wrong, reference, procurement, tender], ), project_data, ) self.assertIs(selected, tender) def test_template_fill_precedes_scoring_supplement_block(self): child = Chapter( id="4.1", title="一、需求分析", level=2, direct_scoring_criteria=["SC-01"], direct_scoring_bindings=["SC-01#1"], content_generation_mode="template_project_scoring", ) chapter = Chapter( id="4", title="服务方案", level=1, chapter_type=ChapterType.TECHNICAL, children=[child], ) outline = BidOutline( project_name="群众艺术馆项目", chapters=[chapter], evaluation_index_entries=[{ "entry_type": "scoring", "source_id": "SC-01#1", "criterion_id": "SC-01", "display_name": "需求分析", "requirement": "结合项目特点分析需求", "final_heading_id": "4.1", }], ) writer = FakeWriter([{ "node_id": "4.1", "content": "围绕群众艺术馆开放特点形成针对性服务安排。", }]) with patch( "step4_writing._get_template_text_for_chapter", return_value="项目名称:%%项目名称%%\n一、需求分析\n模板原文\n[图片块保持原位]", ): result = _write_single_chapter( chapter=chapter, writer=writer, injector=_ContentInjector({}, [], ["招标要求"]), analysis=analysis(), company_info=CompanyInfo(full_name="测试公司"), template_structure=object(), cfg=SimpleNamespace(enforce_word_limit=False), outline=outline, placeholder_map={"项目名称": "群众艺术馆项目"}, table_fill_planned=True, ) self.assertTrue(result.preserve_template_layout) self.assertTrue(result.template_fill_completed) self.assertIn("项目名称:群众艺术馆项目", result.generated_content) self.assertIn("[图片块保持原位]", result.generated_content) self.assertNotIn("针对性服务安排", result.generated_content) self.assertIn("针对性服务安排", child.supplement_content) self.assertEqual( [block["block_type"] for block in result.content_blocks], ["template_base", "native_table_plan", "scoring_supplement"], ) self.assertEqual(child.content_blocks[0]["order"], 2) def test_missing_scoring_blocks_are_retried_for_full_coverage(self): child1 = Chapter( id="3.1", title="一、服务定位及预期目标", level=2, direct_scoring_bindings=["SC-02#1"], ) child2 = Chapter( id="3.4", title="四、重点难点分析及应对措施", level=2, direct_scoring_bindings=["SC-02#2"], ) child3 = Chapter( id="3.5", title="五、服务保障措施", level=2, direct_scoring_bindings=["SC-02#3"], ) chapter = Chapter( id="3", title="需求理解", children=[child1, child2, child3], ) outline = BidOutline( project_name="测试项目", chapters=[chapter], evaluation_index_entries=[ { "entry_type": "scoring", "source_id": "SC-02#1", "criterion_id": "SC-02", "display_name": "服务定位及预期目标", "requirement": "服务定位分析及目标合理性", "final_heading_id": "3.1", }, { "entry_type": "scoring", "source_id": "SC-02#2", "criterion_id": "SC-02", "display_name": "重点难点分析及应对措施", "requirement": "重点难点分析深度及应对措施", "final_heading_id": "3.4", }, { "entry_type": "scoring", "source_id": "SC-02#3", "criterion_id": "SC-02", "display_name": "服务保障措施", "requirement": "服务保障措施要求", "final_heading_id": "3.5", }, ], ) class PartialLlm: def __init__(self, responses): self.responses = list(responses) self.calls = [] def extract_json(self, **kwargs): self.calls.append(kwargs) return self.responses.pop(0) class PartialWriter: def __init__(self, responses): self.llm = PartialLlm(responses) writer = PartialWriter([ {"blocks": [{"node_id": "3.1", "content": "服务定位正文"}]}, {"blocks": [{"node_id": "3.1", "content": "服务定位正文"}]}, {"blocks": [{"node_id": "3.1", "content": "服务定位正文"}]}, {"blocks": [{"node_id": "3.1", "content": "服务定位正文"}]}, {"blocks": [ {"node_id": "3.4", "content": "重点难点正文"}, {"node_id": "3.5", "content": "服务保障正文"}, ]}, ]) with patch( "step4_writing._get_template_text_for_chapter", return_value="第三章模板原文", ): result = _write_single_chapter( chapter=chapter, writer=writer, injector=_ContentInjector({}, [], ["招标要求"]), analysis=analysis(), company_info=CompanyInfo(), template_structure=object(), cfg=SimpleNamespace(enforce_word_limit=False), outline=outline, placeholder_map={}, ) self.assertEqual(len(writer.llm.calls), 5) self.assertEqual( [call["max_tokens"] for call in writer.llm.calls], [32768, 65536, 65536, 65536, 65536], ) self.assertEqual(child1.supplement_content, "服务定位正文") self.assertEqual(child2.supplement_content, "重点难点正文") self.assertEqual(child3.supplement_content, "服务保障正文") self.assertTrue(result.preserve_template_layout) def test_scoring_supplement_prompt_includes_existing_template_content(self): child = Chapter( id="1.18", title="十八、保险购买承诺", level=2, template_original_title="十八、保险购买承诺", direct_scoring_bindings=["SC-14#9"], ) chapter = Chapter( id="1", title="投标人资格、资信证明", children=[child], ) outline = BidOutline( project_name="测试项目", chapters=[chapter], evaluation_index_entries=[{ "entry_type": "scoring", "source_id": "SC-14#9", "criterion_id": "SC-14", "display_name": "保险购买承诺", "requirement": "承诺购买足额保险", "final_heading_id": "1.18", }], ) writer = FakeWriter([{ "node_id": "1.18", "content": "补充正文", }]) with patch( "step4_writing._get_template_text_for_chapter", return_value=( "十八、保险购买承诺\n" "我方已承诺购买足额雇主责任险和公众责任险。\n" "模板其他原文" ), ): _write_single_chapter( chapter=chapter, writer=writer, injector=_ContentInjector({}, []), analysis=analysis(), company_info=CompanyInfo(), template_structure=object(), cfg=SimpleNamespace(enforce_word_limit=False), outline=outline, placeholder_map={}, ) self.assertEqual(len(writer.llm.calls), 1) self.assertIn( "我方已承诺购买足额雇主责任险和公众责任险。", writer.llm.calls[0]["user_prompt"], ) self.assertEqual(child.supplement_content, "补充正文") def test_invalid_chapter_reference_is_sanitized(self): child = Chapter( id="8.2", title="二、服务人员专业素质与职业资格证书", level=2, direct_scoring_bindings=["SC-10#3"], ) chapter = Chapter( id="8", title="服务人员配置承诺", children=[child], ) outline = BidOutline( project_name="测试项目", chapters=[chapter], evaluation_index_entries=[{ "entry_type": "scoring", "source_id": "SC-10#3", "criterion_id": "SC-10", "display_name": "服务人员专业素质与职业资格证书", "requirement": "提供人员专业素质与资格证书材料", "final_heading_id": "8.2", }], ) writer = FakeWriter([{ "node_id": "8.2", "content": ( "具体证明材料及证书复印件,详见投标文件" "第十二章“项目服务人员配置”相应表单及附件。" ), }]) with patch( "step4_writing._get_template_text_for_chapter", return_value="八、服务人员配置承诺\n模板原文", ): _write_single_chapter( chapter=chapter, writer=writer, injector=_ContentInjector({}, []), analysis=analysis(), company_info=CompanyInfo(), template_structure=object(), cfg=SimpleNamespace(enforce_word_limit=False), outline=outline, placeholder_map={}, ) self.assertNotIn("第十二章", child.supplement_content) self.assertIn("本投标文件相应章节及附件", child.supplement_content) def test_placeholder_replacement_collapses_duplicate_periods(self): filled = _resolve_chapter_text_placeholders( "服务内容:%%服务内容%%。", {"服务内容": "包括建筑物管理、设施设备管理、保安、保洁等内容。"}, ) self.assertNotIn("。。", filled) self.assertTrue(filled.endswith("。")) self.assertIn("包括建筑物管理", filled) filled_without_template_period = _resolve_chapter_text_placeholders( "服务内容:%%服务内容%%服务要求:%%服务要求%%", {"服务内容": "内容", "服务要求": "要求"}, ) self.assertIn("内容。服务要求:要求。", filled_without_template_period) def test_direct_h1_supplement_does_not_replace_template_block_plan(self): chapter = Chapter( id="1", title="资格证明", chapter_type=ChapterType.BUSINESS, direct_scoring_bindings=["SC-01#1"], ) outline = BidOutline( project_name="测试", chapters=[chapter], evaluation_index_entries=[{ "entry_type": "scoring", "source_id": "SC-01#1", "display_name": "资格证明", "requirement": "提供有效证明", "final_heading_id": "1", }], ) writer = FakeWriter([{"node_id": "1", "content": "证明材料真实有效。"}]) with patch( "step4_writing._get_template_text_for_chapter", return_value="资格证明模板原文", ): _write_single_chapter( chapter=chapter, writer=writer, injector=_ContentInjector({}, []), analysis=analysis(), company_info=CompanyInfo(), template_structure=object(), cfg=SimpleNamespace(enforce_word_limit=False), outline=outline, placeholder_map={}, ) self.assertEqual( [block["block_type"] for block in chapter.content_blocks], ["template_base", "native_table_plan", "scoring_supplement"], ) def test_template_without_direct_binding_uses_no_llm(self): chapter = Chapter(id="2", title="投标报价", chapter_type=ChapterType.BUSINESS) outline = BidOutline(project_name="测试", chapters=[chapter]) writer = FakeWriter() with patch( "step4_writing._get_template_text_for_chapter", return_value="投标报价模板原文", ): _write_single_chapter( chapter=chapter, writer=writer, injector=_ContentInjector({}, []), analysis=analysis(), company_info=CompanyInfo(), template_structure=object(), cfg=SimpleNamespace(enforce_word_limit=False), outline=outline, placeholder_map={}, ) self.assertEqual(chapter.generated_content, "投标报价模板原文") self.assertEqual(writer.llm.calls, []) def test_business_and_technical_are_ordinary_but_index_is_deferred(self): business = Chapter(id="1", title="资格证明", chapter_type=ChapterType.BUSINESS) technical = Chapter(id="3", title="服务方案", chapter_type=ChapterType.TECHNICAL) index = Chapter( id="index", title="与评标有关的投标文件主要内容索引表", chapter_type=ChapterType.BUSINESS, ) ordinary, deferred = _partition_step4_chapters( BidOutline(project_name="测试", chapters=[index, business, technical]) ) self.assertEqual({node.id for node in ordinary}, {"1", "3"}) self.assertEqual([node.id for node in deferred], ["index"]) def test_evaluation_index_keeps_template_without_generating_rows_or_pages(self): chapter = Chapter( id="index", title="与评标有关的投标文件主要内容索引表", chapter_type=ChapterType.BUSINESS, content_blocks=[{"block_type": "deferred_evaluation_index"}], ) result = _defer_evaluation_index_chapter( chapter=chapter, template_text="索引表模板原文\n详细说明见投标文件页码:", placeholder_map={}, company_info=CompanyInfo(), analysis=analysis(), ) self.assertEqual(result.generated_content, "索引表模板原文\n详细说明见投标文件页码:") self.assertTrue(result.preserve_template_layout) self.assertEqual( [block["block_type"] for block in result.content_blocks], ["template_base", "deferred_evaluation_index"], ) self.assertEqual( result.content_blocks[1]["action"], "build_rows_and_pages_after_final_merge_and_pagination", ) def test_deferred_empty_index_passes_artifact_gate_with_deferred_status(self): ordinary = Chapter( id="1", title="资格证明", chapter_type=ChapterType.BUSINESS, generated_content="资格证明正文", ) index = _defer_evaluation_index_chapter( chapter=Chapter( id="index", title="与评标有关的投标文件主要内容索引表", chapter_type=ChapterType.BUSINESS, ), template_text="", placeholder_map={}, company_info=CompanyInfo(), analysis=analysis(), ) outline = BidOutline(project_name="测试项目", chapters=[ordinary, index]) with tempfile.TemporaryDirectory() as directory: paths = _persist_all_chapter_docs(outline, directory) manifest_path = os.path.join( directory, "chapters", "测试项目", "manifest.json" ) with open(manifest_path, encoding="utf-8") as file: manifest = json.load(file) self.assertEqual(len(paths), 2) self.assertTrue(all(os.path.isfile(path) for path in paths)) self.assertEqual( manifest["chapters"][1]["status"], "deferred_until_final_pagination", ) def test_chapter_artifact_uses_final_outline_titles_and_order(self): reused = Chapter( id="3.1", title="一、服务定位与预期目标分析", level=2, template_original_title="一、服务目标定位", supplement_content="评分补充正文一", ) inserted = Chapter( id="3.2", title="二、重点难点分析与应对措施", level=2, supplement_content="评分补充正文二", ) chapter = Chapter( id="3", title="需求理解", children=[reused, inserted], generated_content=( "第三章:需求理解\n" "一、服务目标定位\n" "模板原有定位正文" ), ) with tempfile.TemporaryDirectory() as directory: path = _save_chapter_docx(chapter, directory) artifact = Document(path) headings = [ paragraph.text for paragraph in artifact.paragraphs if paragraph.style.name.startswith("Heading") ] all_text = "\n".join(paragraph.text for paragraph in artifact.paragraphs) self.assertEqual( headings, [ "第三章 需求理解", "一、服务定位与预期目标分析", "二、重点难点分析与应对措施", ], ) self.assertNotIn("一、服务目标定位", all_text) self.assertIn("模板原有定位正文", all_text) self.assertIn("评分补充正文一", all_text) self.assertIn("评分补充正文二", all_text) def test_native_chapter_excludes_next_part_marker_and_inserts_heading_before_section_break(self): import zipfile import xml.etree.ElementTree as ET child = Chapter( id="2.5", title="五、报价得分", level=2, supplement_content="报价得分补充正文", ) chapter = Chapter( id="2", title="投标报价", template_chapter_id="2", children=[child], ) with tempfile.TemporaryDirectory() as directory: template_path = os.path.join(directory, "template.docx") template = Document() template.add_paragraph("第二章:投标报价", style="Heading 1") template.add_paragraph("四、商务报价说明", style="Heading 2") template.add_paragraph("模板报价正文") template.add_paragraph("技术部分") template.add_paragraph("第三章:需求理解", style="Heading 1") template.save(template_path) path = _save_chapter_docx( chapter, directory, template_path=template_path, placeholder_map={}, company_info=CompanyInfo(), analysis=analysis(), ) artifact = Document(path) word_ns = ( "http://schemas.openxmlformats.org/wordprocessingml/2006/main" ) with zipfile.ZipFile(path) as package: root = ET.fromstring(package.read("word/document.xml")) body = root.find(f"{{{word_ns}}}body") children = list(body) sect_indexes = [ index for index, element in enumerate(children) if element.tag == f"{{{word_ns}}}sectPr" ] headings = [ paragraph.text for paragraph in artifact.paragraphs if paragraph.style.name.startswith("Heading") ] all_text = "\n".join(paragraph.text for paragraph in artifact.paragraphs) self.assertEqual( headings, ["第二章 投标报价", "四、商务报价说明", "五、报价得分"], ) self.assertNotIn("技术部分", all_text) self.assertIn("报价得分补充正文", all_text) self.assertTrue(sect_indexes) self.assertEqual(sect_indexes[-1], len(children) - 1) scoring_index = next( index for index, element in enumerate(children) if element.tag == f"{{{word_ns}}}p" and "五、报价得分" in "".join( node.text or "" for node in element.iter(f"{{{word_ns}}}t") ) ) self.assertLess(scoring_index, sect_indexes[-1]) def test_duplicate_sibling_titles_anchor_to_own_parent_not_reinserted(self): section23 = Chapter( id="1.23", title="二十三、近三年类似项目业绩", level=2, template_original_title="二十三、近三年类似项目业绩", children=[Chapter( id="1.23.1", title="(一)国家税务总局上海市青浦区税务局", level=3, template_original_title="(一)国家税务总局上海市青浦区税务局", )], ) section24 = Chapter( id="1.24", title="二十四、类似业绩业主评价", level=2, template_original_title="二十四、类似业绩业主评价", children=[ Chapter( id="1.24.1", title="(一)国家税务总局上海市青浦区税务局", level=3, template_original_title="(一)国家税务总局上海市青浦区税务局", ), Chapter( id="1.24.10", title="(十)嘉定司法中心", level=3, template_original_title="(十)嘉定司法中心", ), ], ) chapter = Chapter( id="1", title="投标人资格、资信证明", template_chapter_id="1", children=[section23, section24], ) with tempfile.TemporaryDirectory() as directory: template_path = os.path.join(directory, "template.docx") template = Document() template.add_paragraph("第一章:投标人资格、资信证明", style="Heading 1") template.add_paragraph( "二十三、近三年类似项目业绩", style="Heading 2" ) template.add_paragraph( "(一)国家税务总局上海市青浦区税务局", style="Heading 3" ) template.add_paragraph( "二十四、类似业绩业主评价", style="Heading 2" ) template.add_paragraph( "(一)国家税务总局上海市青浦区税务局", style="Heading 3" ) template.add_paragraph("(十)嘉定司法中心", style="Heading 3") template.add_paragraph("第二章:其他章节", style="Heading 1") template.save(template_path) path = _save_chapter_docx( chapter, directory, template_path=template_path, placeholder_map={}, company_info=CompanyInfo(), analysis=analysis(), ) artifact = Document(path) headings = [ paragraph.text for paragraph in artifact.paragraphs if paragraph.style.name.startswith("Heading") ] self.assertEqual( headings, [ "第一章 投标人资格、资信证明", "二十三、近三年类似项目业绩", "(一)国家税务总局上海市青浦区税务局", "二十四、类似业绩业主评价", "(一)国家税务总局上海市青浦区税务局", "(十)嘉定司法中心", ], ) self.assertEqual(headings.count("二十四、类似业绩业主评价"), 1) self.assertEqual( headings.count("(一)国家税务总局上海市青浦区税务局"), 2, ) def test_rejection_supplements_are_visible_in_bound_artifact_section(self): commitment = Chapter( id="1.17", title="十七、要求承诺函", level=2, template_original_title="十七、要求承诺函", direct_rejection_bindings=["RI-01", "RI-02"], ) chapter = Chapter( id="1", title="投标人资格、资信证明", chapter_type=ChapterType.BUSINESS, children=[commitment], ) outline = BidOutline( project_name="测试", chapters=[chapter], evaluation_index_entries=[ { "entry_id": "RI-01", "entry_type": "rejection", "source_id": "RI-01", "requirement": "投标文件必须签署", "final_heading_id": "1.17", }, { "entry_id": "RI-02", "entry_type": "rejection", "source_id": "RI-02", "requirement": "证明材料必须有效", "final_heading_id": "1.17", }, ], ) tender_analysis = analysis() tender_analysis.rejection_items = [ RejectionItem( id="RI-01", category="投标文件编制", description="投标文件必须签署,否则投标无效。", ), RejectionItem( id="RI-02", category="证明材料", description="证明材料必须有效,否则投标无效。", ), ] writer = FakeWriter() with patch( "step4_writing._get_template_text_for_chapter", return_value="十七、要求承诺函\n模板承诺原文", ): _write_single_chapter( chapter=chapter, writer=writer, injector=_ContentInjector({}, []), analysis=tender_analysis, company_info=CompanyInfo(), template_structure=object(), cfg=SimpleNamespace(enforce_word_limit=False), outline=outline, placeholder_map={}, ) self.assertEqual(writer.llm.calls, []) self.assertIn("投标文件必须签署", commitment.supplement_content) self.assertIn("证明材料必须有效", commitment.supplement_content) self.assertNotIn("我方已充分理解并严格响应该项", commitment.supplement_content) self.assertEqual(len(commitment.supplement_content.splitlines()), 3) with tempfile.TemporaryDirectory() as directory: path = _save_chapter_docx(chapter, directory) artifact = Document(path) all_text = "\n".join(paragraph.text for paragraph in artifact.paragraphs) self.assertIn("十七、要求承诺函", all_text) self.assertIn("模板承诺原文", all_text) self.assertIn("投标文件必须签署", all_text) self.assertIn("证明材料必须有效", all_text) def test_native_commitment_placeholder_receives_compact_rejection_block_once(self): commitment = Chapter( id="1.17", title="十七、要求承诺函", level=2, template_original_title="十七、要求承诺函", direct_rejection_bindings=["RI-01", "RI-02"], supplement_content=( "我方郑重承诺:严格遵守全部实质性要求。\n" "投标文件编制方面:投标文件完整签署。\n" "证明材料方面:证明材料真实有效。" ), ) chapter = Chapter( id="1", title="投标人资格、资信证明", template_chapter_id="1", children=[commitment], ) with tempfile.TemporaryDirectory() as directory: template_path = os.path.join(directory, "template.docx") template = Document() template.add_paragraph( "第一章:投标人资格、资信证明", style="Heading 1" ) template.add_paragraph("十七、要求承诺函", style="Heading 2") template.add_paragraph("模板承诺原文") template.add_paragraph("%%承诺函%%") template.add_paragraph("如有违反,愿承担相应责任。") template.add_paragraph("投标人授权代表签字:") template.add_paragraph("第二章:投标报价", style="Heading 1") template.save(template_path) path = _save_chapter_docx( chapter, directory, template_path=template_path, placeholder_map={}, company_info=CompanyInfo(), analysis=analysis(), ) artifact = Document(path) lines = [paragraph.text for paragraph in artifact.paragraphs] all_text = "\n".join(lines) self.assertNotIn("%%承诺函%%", all_text) self.assertEqual(all_text.count("我方郑重承诺"), 1) self.assertLess( lines.index("证明材料方面:证明材料真实有效。"), lines.index("如有违反,愿承担相应责任。"), ) self.assertLess( lines.index("如有违反,愿承担相应责任。"), lines.index("投标人授权代表签字:"), ) def test_native_template_numeric_conflict_uses_current_scoring_requirement(self): child = Chapter( id="4.2.6", title="(六)人员流动率承诺", level=3, template_original_title="(六)人员稳定保障", direct_scoring_criteria=["SC-11"], supplement_content="我方承诺一年内项目人员流动率不超过20%。", ) chapter = Chapter( id="4", title="基本服务方案", template_chapter_id="4", children=[child], ) tender_analysis = analysis() tender_analysis.scoring_criteria = [ScoringCriterion( id="SC-11", category="技术方案", name="服务承诺", description="承诺一年内项目人员流动率不超过20%的得分。", max_score=1, )] with tempfile.TemporaryDirectory() as directory: template_path = os.path.join(directory, "template.docx") template = Document() template.add_paragraph("第四章:基本服务方案", style="Heading 1") template.add_paragraph("(六)人员稳定保障", style="Heading 3") template.add_paragraph( "公司制定人员稳定方案,确保一年内人员流动率低于15%。" ) template.add_paragraph("第五章:其他", style="Heading 1") template.save(template_path) path = _save_chapter_docx( chapter, directory, template_path=template_path, placeholder_map={}, company_info=CompanyInfo(), analysis=tender_analysis, ) artifact = Document(path) all_text = "\n".join(paragraph.text for paragraph in artifact.paragraphs) self.assertNotIn("15%", all_text) self.assertIn("人员流动率不超过20%", all_text) def test_native_template_table_and_image_survive_step4_artifact(self): chapter = Chapter( id="3", title="需求理解", template_chapter_id="3", children=[Chapter( id="3.1", title="一、服务定位与预期目标分析", level=2, template_original_title="一、服务目标定位", supplement_content="评分补充正文", )], ) with tempfile.TemporaryDirectory() as directory: image_path = os.path.join(directory, "quality.png") with open(image_path, "wb") as file: file.write(base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwC" "AAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" )) template_path = os.path.join(directory, "template.docx") template = Document() template.add_paragraph("第三章:需求理解", style="Heading 1") template.add_paragraph("一、服务目标定位", style="Heading 2") template.add_paragraph("品质督导管理体系") template.add_picture(image_path) table = template.add_table(rows=1, cols=2) table.cell(0, 0).text = "项目名称" table.cell(0, 1).text = "%%项目名称%%" template.add_paragraph("第四章:其他章节", style="Heading 1") template.add_paragraph("不应进入第三章产物") template.save(template_path) path = _save_chapter_docx( chapter, directory, template_path=template_path, placeholder_map={"项目名称": "群众艺术馆项目"}, company_info=CompanyInfo(), analysis=analysis(), ) artifact = Document(path) headings = [ paragraph.text for paragraph in artifact.paragraphs if paragraph.style.name.startswith("Heading") ] all_text = "\n".join(paragraph.text for paragraph in artifact.paragraphs) self.assertEqual( headings, ["第三章 需求理解", "一、服务定位与预期目标分析"], ) self.assertEqual(len(artifact.tables), 1) self.assertEqual(len(artifact.inline_shapes), 1) self.assertEqual( artifact.tables[0].cell(0, 1).text, "群众艺术馆项目", ) self.assertIn("品质督导管理体系", all_text) self.assertIn("评分补充正文", all_text) self.assertNotIn("不应进入第三章产物", all_text) def test_step2_project_fields_fill_native_table_cells_and_table_placeholder(self): chapter = Chapter(id="2", title="投标报价", template_chapter_id="2") summary_table = ExtractedTable( table_id="summary", source_type="tender_pdf", rows=2, cols=2, table_type="bid_summary_table", col_widths=[0.35, 0.65], cells=[ [ TableCell(text="项目", row=0, col=0, bold=True, alignment="center"), TableCell(text="报价说明", row=0, col=1, bold=True, alignment="center"), ], [ TableCell(text="本项目", row=1, col=0), TableCell(text="按招标文件报价", row=1, col=1), ], ], ) detail_table = ExtractedTable( table_id="reference-detail", source_type="reference_docx", rows=2, cols=2, table_type="bid_detail_table", title_hint="投标报价分项明细表", header_fields=["项目名称:历史物业项目", "项目编号:OLD-001"], cells=[ [ TableCell(text="费用项目", row=0, col=0, bold=True), TableCell(text="金额(元)", row=0, col=1, bold=True), ], [ TableCell(text="人员费用", row=1, col=0), TableCell(text="12345", row=1, col=1), ], ], ) tender_analysis = TenderAnalysis( project_name="群众艺术馆项目", agency_name="采购中心", service_requirements=( "### 服务范围与内容\n- 建筑物管理\n- 设施设备管理\n" "### 服务标准\n- 按采购需求质量标准执行\n" "- 接受采购人考核\n" "- **服务期限**:自合同签订之日起三年" ), tender_tables=[summary_table], ) project_data = ProjectData( project_id="XM-001", project_name="群众艺术馆项目", tender_docs=[ParsedDocument( file_path="tender.pdf", file_name="tender.pdf", content="服务期限:自合同签订之日起三年", doc_type="tender", )], reference_tables=[detail_table], ) placeholder_map = build_placeholder_map( tender_analysis, CompanyInfo(), project_data ) with tempfile.TemporaryDirectory() as directory: template_path = os.path.join(directory, "template.docx") template = Document() template.add_paragraph("第二章:投标报价", style="Heading 1") template.add_paragraph("%%项目名称%%") template.add_paragraph("采购机构:%%采购机构名称%%") opening = template.add_table(rows=1, cols=3) opening.cell(0, 0).text = "%%服务内容%%" opening.cell(0, 1).text = "%%服务要求%%" opening.cell(0, 2).text = "%%服务期限%%" template.add_paragraph("%%报价汇总表%%") template.add_paragraph("%%报价明细表%%") template.add_paragraph("第三章:其他", style="Heading 1") template.save(template_path) path = _save_chapter_docx( chapter, directory, template_path=template_path, placeholder_map=placeholder_map, company_info=CompanyInfo(), analysis=tender_analysis, project_data=project_data, ) artifact = Document(path) all_text = "\n".join( [paragraph.text for paragraph in artifact.paragraphs] + [cell.text for table in artifact.tables for row in table.rows for cell in row.cells] ) self.assertEqual(len(artifact.tables), 3) self.assertNotIn("%%", all_text) self.assertIn("采购中心", all_text) self.assertIn("建筑物管理", all_text) self.assertIn("按采购需求质量标准执行", all_text) self.assertIn("自合同签订之日起三年", all_text) self.assertIn("按招标文件报价", all_text) self.assertIn("群众艺术馆项目", all_text) self.assertIn("待投标报价确认", all_text) self.assertNotIn("历史物业项目", all_text) self.assertNotIn("12345", all_text) def test_supplement_sanitizer_removes_tables_and_heading_numbering(self): content = _sanitize_bound_block( "## 分析\n一、项目特点\n正文\n| 列1 | 列2 |\n|---|---|" ) self.assertNotIn("|", content) self.assertNotIn("一、", content) self.assertIn("项目特点:", content) def test_scoring_block_is_inserted_after_image_before_next_heading(self): doc = Document() doc.add_paragraph("第四章:服务方案", style="Heading 1") heading = doc.add_paragraph("一、需求分析", style="Heading 2") doc.add_paragraph("模板正文") image_para = doc.add_paragraph() image_para.add_run()._element.append(OxmlElement("w:drawing")) next_heading = doc.add_paragraph("二、下一模板节", style="Heading 2") child = Chapter( id="4.1", title="一、需求分析", level=2, direct_scoring_criteria=["SC-01"], supplement_content="评分补充正文", structure_locked=True, ) chapter = Chapter(id="4", title="服务方案", children=[child]) body = doc.element.body region = list(body)[:-1] inserted = _insert_locked_scoring_sections( doc, body, region, chapter, {}, augment_existing=True ) elements = list(body) supplement = next( paragraph._element for paragraph in doc.paragraphs if paragraph.text == "评分补充正文" ) self.assertEqual(inserted, 1) self.assertLess(elements.index(heading._element), elements.index(image_para._element)) self.assertLess(elements.index(image_para._element), elements.index(supplement)) self.assertLess(elements.index(supplement), elements.index(next_heading._element)) class Step4HeaderFooterTests(unittest.TestCase): def test_native_chapter_keeps_header_footer_and_replaces_project_name(self): chapter = Chapter( id="3", title="需求理解", template_chapter_id="3", ) with tempfile.TemporaryDirectory() as directory: template_path = os.path.join(directory, "template.docx") template = Document() header = template.sections[0].header placeholder_para = header.paragraphs[0] placeholder_para.add_run("%%项目名称%%项目投标文件") legacy_para = header.add_paragraph() legacy_para.add_run("上海市建筑工程学校") legacy_para.add_run("物业管理服务项目投标文件") footer = template.sections[0].footer footer.paragraphs[0].text = "第 1 页" template.add_paragraph("第三章:需求理解", style="Heading 1") template.add_paragraph("模板正文") template.add_paragraph("第四章:其他章节", style="Heading 1") template.save(template_path) path = _save_chapter_docx( chapter, directory, template_path=template_path, placeholder_map={ "项目名称": "上海市群众艺术馆物业管理服务", }, company_info=CompanyInfo(), analysis=analysis(), ) artifact = Document(path) header_text = "\n".join( paragraph.text for paragraph in artifact.sections[0].header.paragraphs ) self.assertIn( "上海市群众艺术馆物业管理服务项目投标文件", header_text, ) self.assertNotIn("%%", header_text) self.assertNotIn("上海市建筑工程学校", header_text) footer_text = artifact.sections[0].footer.paragraphs[0].text self.assertIn("第 1 页", footer_text) body_text = "\n".join( paragraph.text for paragraph in artifact.paragraphs ) self.assertIn("模板正文", body_text) self.assertNotIn("其他章节", body_text) def test_fallback_index_chapter_has_header_and_footer(self): chapter = Chapter( id="index", title="与评标有关的投标文件主要内容索引表", chapter_type=ChapterType.BUSINESS, ) with tempfile.TemporaryDirectory() as directory: path = _save_chapter_docx( chapter, directory, placeholder_map={"项目名称": "群众艺术馆项目"}, analysis=analysis(), ) artifact = Document(path) header_text = "\n".join( paragraph.text for paragraph in artifact.sections[0].header.paragraphs ) self.assertIn("群众艺术馆项目投标文件", header_text) self.assertTrue(artifact.sections[0].footer.paragraphs) def test_native_chapters_use_unified_first_section_header(self): chapter1 = Chapter( id="1", title="投标人资格、资信证明", template_chapter_id="1", ) chapter4 = Chapter( id="4", title="基本服务方案", template_chapter_id="4", ) with tempfile.TemporaryDirectory() as directory: template_path = os.path.join(directory, "template.docx") document = Document() first_section = document.sections[0] first_section.header.is_linked_to_previous = False first_section.header.paragraphs[0].text = "第一页眉" document.add_paragraph("第一章:投标人资格、资信证明", style="Heading 1") document.add_paragraph("第一章正文") second_section = document.add_section(WD_SECTION.NEW_PAGE) second_section.header.is_linked_to_previous = False second_section.header.paragraphs[0].text = "第二页眉" document.add_paragraph("第四章:基本服务方案", style="Heading 1") document.add_paragraph("第四章正文") document.save(template_path) path1 = _save_chapter_docx( chapter1, directory, template_path=template_path, placeholder_map={}, company_info=CompanyInfo(), analysis=analysis(), ) path4 = _save_chapter_docx( chapter4, directory, template_path=template_path, placeholder_map={}, company_info=CompanyInfo(), analysis=analysis(), ) artifact1 = Document(path1) artifact4 = Document(path4) header1 = "\n".join( paragraph.text for paragraph in artifact1.sections[0].header.paragraphs ) header4 = "\n".join( paragraph.text for paragraph in artifact4.sections[0].header.paragraphs ) self.assertIn("第一页眉", header1) self.assertIn("第一页眉", header4) self.assertNotIn("第二页眉", header4) def test_native_chapter_applies_section_header_to_internal_section_breaks(self): chapter4 = Chapter( id="4", title="基本服务方案", template_chapter_id="4", ) with tempfile.TemporaryDirectory() as directory: template_path = os.path.join(directory, "template.docx") document = Document() first_section = document.sections[0] first_section.header.is_linked_to_previous = False first_section.header.paragraphs[0].text = "第一页眉" document.add_paragraph("第一章:投标人资格、资信证明", style="Heading 1") document.add_paragraph("第一章正文") second_section = document.add_section(WD_SECTION.NEW_PAGE) second_section.header.is_linked_to_previous = False second_section.header.paragraphs[0].text = "第二页眉" document.add_paragraph("第四章:基本服务方案", style="Heading 1") document.add_paragraph("第四章正文") internal_section = document.add_section(WD_SECTION.NEW_PAGE) internal_section.header.is_linked_to_previous = False internal_section.header.paragraphs[0].text = "" document.add_paragraph("内部分节正文") document.add_paragraph("第五章:其他章节", style="Heading 1") document.save(template_path) path = _save_chapter_docx( chapter4, directory, template_path=template_path, placeholder_map={}, company_info=CompanyInfo(), analysis=analysis(), ) artifact = Document(path) headers = [ "\n".join(paragraph.text for paragraph in section.header.paragraphs) for section in artifact.sections ] self.assertTrue(headers) for header_text in headers: self.assertIn("第一页眉", header_text) self.assertNotIn("第二页眉", header_text) def test_native_index_chapter_uses_template_header(self): chapter = Chapter( id="index", title="与评标有关的投标文件主要内容索引表", chapter_type=ChapterType.BUSINESS, ) with tempfile.TemporaryDirectory() as directory: template_path = os.path.join(directory, "template.docx") template = Document() header = template.sections[0].header header.is_linked_to_previous = False header.paragraphs[0].text = "%%项目名称%%项目投标文件" footer = template.sections[0].footer footer.is_linked_to_previous = False footer.paragraphs[0].text = "第 1 页" template.add_paragraph("第一章:投标人资格、资信证明", style="Heading 1") template.save(template_path) path = _save_chapter_docx( chapter, directory, template_path=template_path, placeholder_map={"项目名称": "群众艺术馆项目"}, company_info=CompanyInfo(), analysis=analysis(), ) artifact = Document(path) header_text = "\n".join( paragraph.text for paragraph in artifact.sections[0].header.paragraphs ) self.assertIn("群众艺术馆项目项目投标文件", header_text) self.assertNotIn("%%", header_text) footer_text = artifact.sections[0].footer.paragraphs[0].text self.assertIn("第 1 页", footer_text) self.assertTrue(artifact.paragraphs) if __name__ == "__main__": unittest.main()