import re import tempfile import unittest from pathlib import Path from types import SimpleNamespace from unittest.mock import PropertyMock, patch from docx import Document from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.shared import Pt from chapter_policy import ChapterEditMode, get_chapter_edit_mode from doc_reader.reader import read_docx_paragraphs_et from models import ( Chapter, ChapterType, ExtractedItem, ExtractedTable, RejectionItem, ProjectData, ScoringCriterion, TableCell, TenderAnalysis, TemplateSection, TemplateStructure, ) from step3_outlining.outline_generator import _OutlineGenerator from step3_outlining.template_parser import ( _is_top_level_chapter, paragraph_heading_level, ) from step4_writing import _build_dynamic_numeric_rules from step4_writing.chapter_writer import ( _ChapterWriter, _lock_content_to_template_structure, ) from step4_writing.content_injector import _ContentInjector from step6_exporting.docx_builder import ( _center_document_tables, _bounded_region_text, _detect_heading_level, _protect_native_context_blocks, _replace_chapter_content, _replace_doc_table_with_tender_table, _resolve_remaining_table_placeholders, _resolve_table_placeholders, _should_keep_template_layout, _text_similarity, _update_chapter_preserving_template_structure, ) from step6_exporting.format_applier import FormatApplier class ChapterPolicyTests(unittest.TestCase): def test_template_heading_styles_define_structure_without_copying_visual_format(self): doc = Document() h1 = doc.add_paragraph("第一章:需求理解", style="Heading 1") doc.add_table(rows=1, cols=1).cell(0, 0).text = "表内段落不得影响正文索引" h2 = doc.add_paragraph("服务目标定位", style="Heading 2") h2.runs[0].font.size = Pt(8) # 故意与最终标题格式不一致 h3 = doc.add_paragraph("项目管理理念", style="Heading 3") h4 = doc.add_paragraph("标准化实施路径", style="Heading 4") doc.add_paragraph("一、这是正文,不应因序号被重复识别") self.assertEqual(paragraph_heading_level(h1), 1) self.assertEqual(paragraph_heading_level(h2), 2) self.assertEqual(paragraph_heading_level(h3), 3) self.assertEqual(paragraph_heading_level(h4), 4) self.assertTrue(_is_top_level_chapter(h1)) self.assertFalse(_is_top_level_chapter(h3)) with tempfile.TemporaryDirectory() as temp_dir: path = Path(temp_dir) / "template.docx" doc.save(path) et_paragraphs = read_docx_paragraphs_et( str(path), top_level_only=True, ) self.assertEqual( [item["style_name"] for item in et_paragraphs[:4]], ["heading 1", "heading 2", "heading 3", "heading 4"], ) self.assertNotIn( "表内段落不得影响正文索引", [item["text"] for item in et_paragraphs], ) structure = TemplateStructure( file_path=str(path), sections=[TemplateSection( name="第一章:需求理解", start_para=0, end_para=4, )], ) chapter = Chapter(id="1", title="需求理解", level=1) # 大型模板路径不得重新访问 python-docx/lxml 的 Paragraph.style; # Windows 上该路径曾直接触发 0xC0000005。 with patch( "docx.text.paragraph.Paragraph.style", new_callable=PropertyMock, side_effect=AssertionError("禁止访问 Paragraph.style"), ): result = _OutlineGenerator()._extract_sub_sections( [chapter], structure )[0] self.assertEqual([node.title for node in result.children], ["服务目标定位"]) self.assertEqual(result.children[0].level, 2) self.assertEqual(result.children[0].children[0].title, "项目管理理念") self.assertEqual(result.children[0].children[0].level, 3) self.assertEqual( result.children[0].children[0].children[0].title, "标准化实施路径", ) self.assertEqual(result.children[0].children[0].children[0].level, 4) self.assertNotIn( "一、这是正文,不应因序号被重复识别", [node.title for node in result.children], ) def test_expected_chapter_modes(self): self.assertEqual( get_chapter_edit_mode("需求理解"), ChapterEditMode.RESTRUCTURE ) self.assertEqual( get_chapter_edit_mode("项目经理"), ChapterEditMode.PRESERVE_STRUCTURE, ) self.assertEqual( get_chapter_edit_mode("服务人员配置承诺"), ChapterEditMode.PRESERVE_STRUCTURE, ) self.assertEqual( get_chapter_edit_mode("基本服务方案"), ChapterEditMode.PRESERVE_STRUCTURE, ) def test_chapter_number_prevents_cross_chapter_mapping(self): doc = Document() doc.add_paragraph("第七章:主管人员配置") doc.add_paragraph("一、主管人员配置") doc.add_paragraph("第八章:服务人员配置承诺") doc.add_paragraph("一、服务人员配置承诺") with tempfile.TemporaryDirectory() as temp_dir: path = Path(temp_dir) / "template.docx" doc.save(path) structure = TemplateStructure( file_path=str(path), sections=[ TemplateSection( name="第七章:主管人员配置", start_para=0, end_para=1 ), TemplateSection( name="第八章:服务人员配置承诺", start_para=2, end_para=3 ), ], ) chapter = Chapter(id="8", title="服务人员配置承诺") generator = object.__new__(_OutlineGenerator) generator._extract_sub_sections([chapter], structure) self.assertEqual(chapter.template_section, "第八章:服务人员配置承诺") self.assertEqual([c.title for c in chapter.children], ["一、服务人员配置承诺"]) def test_same_chapter_number_maps_even_when_titles_differ(self): doc = Document() doc.add_paragraph("第一章:固定商务资料", style="Heading 1") doc.add_paragraph("营业执照及资质证书", style="Heading 2") doc.add_paragraph("二十一、体系认证证书", style="Heading 3") with tempfile.TemporaryDirectory() as temp_dir: path = Path(temp_dir) / "template.docx" doc.save(path) structure = TemplateStructure( file_path=str(path), sections=[TemplateSection( name="第一章:固定商务资料", start_para=0, end_para=2, )], ) chapter = Chapter(id="1", title="投标人资格、资信证明") generator = object.__new__(_OutlineGenerator) generator._extract_sub_sections([chapter], structure) self.assertEqual(chapter.template_section, "第一章:固定商务资料") self.assertEqual( [child.title for child in chapter.children], ["营业执照及资质证书"], ) self.assertEqual( chapter.children[0].children[0].title, "二十一、体系认证证书", ) def test_generate_uses_template_children_for_business_chapters(self): doc = Document() doc.add_paragraph("第一章:资格证明", style="Heading 1") doc.add_paragraph("一、要求承诺函", style="Heading 2") doc.add_paragraph("二、体系认证证书", style="Heading 2") doc.add_paragraph("第二章:投标报价", style="Heading 1") doc.add_paragraph("一、近三年类似项目业绩", style="Heading 2") doc.add_paragraph("第三章:需求理解", style="Heading 1") doc.add_paragraph("一、模板需求分析", style="Heading 2") with tempfile.TemporaryDirectory() as temp_dir: path = Path(temp_dir) / "template.docx" doc.save(path) structure = TemplateStructure( file_path=str(path), sections=[ TemplateSection( name="第一章:资格证明", start_para=0, end_para=2 ), TemplateSection( name="第二章:投标报价", start_para=3, end_para=4 ), TemplateSection( name="第三章:需求理解", start_para=5, end_para=6 ), ], ) generator = object.__new__(_OutlineGenerator) generator.cfg = SimpleNamespace( max_total_chapters=12, min_word_count=170000 ) outline = generator.generate( TenderAnalysis(project_name="测试"), ProjectData(project_id="test", project_name="测试"), template_titles=[ ("1", "资格证明"), ("2", "投标报价"), ("3", "需求理解"), ], template_structure=structure, ) chapter_one = next(c for c in outline.chapters if c.id == "1") chapter_two = next(c for c in outline.chapters if c.id == "2") self.assertEqual( [child.title for child in chapter_one.children], ["一、要求承诺函", "二、体系认证证书"], ) self.assertEqual( [child.title for child in chapter_two.children], ["一、近三年类似项目业绩"], ) def test_template_technical_chapters_ignore_generic_limit(self): generator = object.__new__(_OutlineGenerator) generator.cfg = SimpleNamespace(max_total_chapters=12) template_titles = {str(i): f"模板第{i}章" for i in range(3, 15)} chapters = generator._build_technical_chapters( TenderAnalysis(project_name="测试"), template_titles, max_tech=2, template_structure=None, ) self.assertEqual([ch.id for ch in chapters], [str(i) for i in range(3, 15)]) def test_understanding_bypasses_template_child_fallback(self): class FakeLlm: def __init__(self): self.prompts = [] def generate(self, **kwargs): self.prompts.append(kwargs["user_prompt"]) return "需求理解\n定位、目标、重点难点和针对性措施响应正文" def chat(self, **_kwargs): return "" writer = object.__new__(_ChapterWriter) writer.llm = FakeLlm() chapter = Chapter( id="3", title="需求理解", children=[ Chapter(id="3.1", title="一、模板原小节", from_template=True), Chapter( id="3.2", title="二、需求理解", level=2, description="评分要求:分析服务定位、目标、重点难点", related_criteria=["SC-02"], structure_locked=True, ), ], ) result = writer.write_chapter( chapter=chapter, analysis=TenderAnalysis(project_name="测试项目"), scoring_context="需求理解评分项:分析服务定位、目标、重点难点", rejection_context="RI-01:未实质性响应采购需求导致废标", template_text="一、模板原小节\n模板通用正文", ) self.assertEqual(len(writer.llm.prompts), 1) self.assertIn("二、需求理解", writer.llm.prompts[0]) self.assertNotIn("模板通用正文", writer.llm.prompts[0]) self.assertIn("定位、目标、重点难点", result.generated_content) self.assertEqual(result.children[0].generated_content, "") self.assertIn("定位、目标、重点难点", result.children[1].generated_content) def test_understanding_does_not_scatter_rejections_outside_chapter_one(self): injector = _ContentInjector({}, []) chapter = Chapter(id="3", title="需求理解") analysis = TenderAnalysis( project_name="测试项目", rejection_items=[ RejectionItem( id="RI-01", description="未对采购需求作出实质性响应的,投标无效", category="符合性审查", ), RejectionItem( id="RI-02", description="法定代表人授权书未签字或盖章", category="形式审查", ), ], ) context = injector.get_rejection_context(chapter, analysis) self.assertEqual(context, "") def test_parent_summary_does_not_become_direct_rejection_context(self): commitment = Chapter( id="1.17", title="十七、要求承诺函", level=2, direct_rejection_bindings=["RI-01"], ) parent = Chapter( id="1", title="资格证明", related_rejections=["RI-01"], children=[commitment], ) analysis = TenderAnalysis( project_name="测试", rejection_items=[RejectionItem( id="RI-01", category="符合性", description="必须签章", )], ) injector = _ContentInjector({}, []) self.assertEqual(injector.get_rejection_context(parent, analysis), "") self.assertIn( "必须签章", injector.get_rejection_context(commitment, analysis), ) def test_word_allocation_reads_descendant_direct_scoring_bindings(self): generator = object.__new__(_OutlineGenerator) generator.cfg = SimpleNamespace(min_word_count=100000) first = Chapter( id="3", title="技术一", chapter_type=ChapterType.TECHNICAL, children=[Chapter( id="3.1", title="一、评分小类一", level=2, direct_scoring_criteria=["SC-01"], )], ) second = Chapter( id="4", title="技术二", chapter_type=ChapterType.TECHNICAL, children=[Chapter( id="4.1", title="一、评分小类二", level=2, direct_scoring_criteria=["SC-02"], )], ) criteria = [ ScoringCriterion( id="SC-01", category="技术", name="一", description="要求一", max_score=10, ), ScoringCriterion( id="SC-02", category="技术", name="二", description="要求二", max_score=30, ), ] generator._allocate_word_counts([first, second], criteria) self.assertEqual(first.related_criteria, []) self.assertEqual(second.related_criteria, []) self.assertEqual(first.word_count_target, 21250) self.assertEqual(second.word_count_target, 63750) def test_other_chapter_supplements_related_scoring_and_rejection_only(self): injector = _ContentInjector({}, []) chapter = Chapter( id="4", title="基本服务方案", children=[Chapter( id="4.1", title="一、保洁服务管理方案", direct_scoring_criteria=["SC-01"], direct_scoring_bindings=["SC-01#1"], )], ) analysis = TenderAnalysis( project_name="测试项目", scoring_criteria=[ ScoringCriterion( id="SC-01", category="技术方案", name="保洁服务", description="保洁作业流程完整", max_score=5, ), ScoringCriterion( id="SC-02", category="人员配置", name="人员配置", description="项目经理经验", max_score=5, ), ], rejection_items=[ RejectionItem( id="RI-01", description="保洁服务未实质性响应的投标无效", category="符合性审查", ), RejectionItem( id="RI-02", description="项目负责人证书未提供", category="资格审查", ), ], ) scoring = injector.get_scoring_context(chapter, analysis) rejection = injector.get_rejection_context(chapter, analysis) self.assertIn("SC-01", scoring) self.assertNotIn("SC-02", scoring) self.assertEqual(rejection, "") def test_rejection_maps_all_items_to_chapter_one_commitment(self): commitment = Chapter(id="1", title="投标人资格、资信证明") demand = Chapter(id="3", title="需求理解") service = Chapter( id="4", title="基本服务方案", children=[Chapter(id="4.1", title="一、保洁服务管理方案")], ) generator = object.__new__(_OutlineGenerator) generator._map_rejections_to_chapters( [ RejectionItem( id="RI-clean", description="保洁服务方案未响应采购要求的投标无效", category="技术符合性", ) ], [commitment, demand, service], ) self.assertEqual(commitment.related_rejections, []) commitment_node = next( child for child in commitment.children if "要求承诺函" in child.title ) self.assertEqual(commitment_node.direct_rejection_bindings, ["RI-clean"]) self.assertNotIn("RI-clean", demand.related_rejections) self.assertNotIn("RI-clean", service.related_rejections) def test_non_restructure_output_cannot_add_or_rename_headings(self): template_text = "(一)原有标题\n模板正文\n1. 原有明细\n模板明细" generated_text = ( "(一)改名标题\n改写正文\n" "(二)新增标题\n新增标题下的正文\n" "1. 原有明细\n改写明细" ) locked = _lock_content_to_template_structure( template_text, generated_text, "一、现有子章节" ) locked_lines = locked.splitlines() self.assertNotIn("(一)改名标题", locked_lines) self.assertNotIn("(二)新增标题", locked_lines) self.assertIn("1. 原有明细", locked_lines) self.assertIn("改写正文", locked_lines) self.assertIn("新增标题下的正文", locked_lines) class DocxStructureGuardTests(unittest.TestCase): def test_explicit_template_layout_provenance_skips_similarity(self): with patch( "step6_exporting.docx_builder._text_similarity", side_effect=AssertionError("显式来源不得再执行相似度计算"), ): self.assertTrue( _should_keep_template_layout( "模板区域", "完全不同的生成内容", preserve_template_layout=True, ) ) self.assertFalse( _should_keep_template_layout( "模板区域", "与模板完全相同", preserve_template_layout=False, ) ) def test_legacy_layout_similarity_uses_bounded_samples(self): matcher = SimpleNamespace( ratio=lambda: 1.0, get_matching_blocks=lambda: [SimpleNamespace(size=1)], ) with patch("difflib.SequenceMatcher", return_value=matcher) as factory: self.assertEqual(_text_similarity("甲" * 100_000, "甲" * 100_000), 1.0) args, kwargs = factory.call_args self.assertLessEqual(len(args[1]), 12_000) self.assertLessEqual(len(args[2]), 12_000) self.assertTrue(kwargs["autojunk"]) doc = Document() doc.add_paragraph("乙" * 100_000) region = _bounded_region_text(list(doc.element.body.iterchildren()), 12_000) self.assertEqual(len(region), 12_000) @staticmethod def _body_order(doc): order = [] for elem in doc.element.body.iterchildren(): if elem.tag == qn("w:p"): text = "".join( node.text or "" for node in elem.iter(qn("w:t")) ).strip() order.append("" if elem.findall(".//" + qn("w:drawing")) else text) elif elem.tag == qn("w:tbl"): order.append("") return order def test_general_chapter_also_preserves_template_headings(self): doc = Document() doc.add_paragraph("第四章:基本服务方案") doc.add_paragraph("一、现有方案") doc.add_paragraph("旧正文") chapter = Chapter( id="4", title="基本服务方案", generated_content=( "一、现有方案\n新正文\n" "二、模型新增章节\n不得进入最终文档" ), ) all_paras = list(doc.paragraphs) _replace_chapter_content(doc, all_paras, 0, len(all_paras) - 1, chapter) final_text = "\n".join(p.text for p in doc.paragraphs) headings = [ p.text for p in doc.paragraphs if _detect_heading_level(p.text) > 0 ] # _detect_heading_level 只识别章内小节;章标题由单独逻辑保留。 self.assertEqual(headings, ["一、现有方案"]) self.assertTrue(doc.paragraphs[0].text.startswith("第四章")) self.assertIn("新正文", final_text) self.assertNotIn("模型新增章节", final_text) def test_native_table_replacement_requires_placeholder_authorization(self): doc = Document() table = doc.add_table(rows=1, cols=1) table.cell(0, 0).text = "模板原生表" replaced = _replace_doc_table_with_tender_table( doc, table_idx=0, tender_table=None, analysis=None, company_info=None, section_name="测试表", ) self.assertFalse(replaced) self.assertEqual(len(doc.tables), 1) self.assertEqual(doc.tables[0].cell(0, 0).text, "模板原生表") def test_preserve_mode_keeps_headings_and_native_table(self): doc = Document() doc.add_paragraph("第六章:项目经理") doc.add_paragraph("一、任职条件") doc.add_paragraph("旧任职条件正文") table = doc.add_table(rows=1, cols=2) table.cell(0, 0).text = "模板字段" table.cell(0, 1).text = "模板内容" doc.add_paragraph("(一)资格证书") doc.add_paragraph("旧资格证书正文") doc.add_paragraph("二、岗位职责") doc.add_paragraph("旧岗位职责正文") original_headings = [ p.text for p in doc.paragraphs if _detect_heading_level(p.text) > 0 ] chapter = Chapter( id="6", title="项目经理", generated_content=( "一、任职条件\n新任职条件正文\n" "(一)资格证书\n新资格证书正文\n" "二、岗位职责\n新岗位职责正文\n" "三、模型自行增加的结构\n该内容不得进入模板" ), ) all_paras = list(doc.paragraphs) _replace_chapter_content( doc, all_paras, 0, len(all_paras) - 1, chapter ) final_text = "\n".join(p.text for p in doc.paragraphs) final_headings = [ p.text for p in doc.paragraphs if _detect_heading_level(p.text) > 0 ] self.assertEqual(final_headings, original_headings) self.assertIn("新任职条件正文", final_text) self.assertIn("新资格证书正文", final_text) self.assertNotIn("旧任职条件正文", final_text) self.assertNotIn("模型自行增加的结构", final_text) self.assertEqual(len(doc.tables), 1) self.assertEqual(doc.tables[0].cell(0, 0).text, "模板字段") self.assertEqual(doc.tables[0].cell(0, 1).text, "模板内容") def test_preserve_mode_rewrites_text_blocks_around_native_table_in_place(self): doc = Document() doc.add_paragraph("第四章:基本服务方案") doc.add_paragraph("一、保洁服务") doc.add_paragraph("旧前段正文") doc.add_paragraph("如下表所示:") table = doc.add_table(rows=1, cols=1) table.cell(0, 0).text = "模板原生表格" doc.add_paragraph("表后说明:该表为模板依据。") doc.add_paragraph("旧后段正文") chapter = Chapter( id="4", title="基本服务方案", generated_content="一、保洁服务\n新前段正文\n新后段正文", ) all_paras = list(doc.paragraphs) _replace_chapter_content(doc, all_paras, 0, len(all_paras) - 1, chapter) order = self._body_order(doc) self.assertLess(order.index("新前段正文"), order.index("如下表所示:")) self.assertLess(order.index("如下表所示:"), order.index("
")) self.assertLess(order.index("
"), order.index("表后说明:该表为模板依据。")) self.assertLess(order.index("表后说明:该表为模板依据。"), order.index("新后段正文")) self.assertEqual(doc.tables[0].cell(0, 0).text, "模板原生表格") def test_signature_image_and_context_remain_atomic(self): doc = Document() doc.add_paragraph("第六章:项目经理") doc.add_paragraph("一、承诺与签署") doc.add_paragraph("旧可编辑正文") doc.add_paragraph("签署说明一") doc.add_paragraph("签署说明二") doc.add_paragraph("法定代表人签字:") image_para = doc.add_paragraph() image_para.add_run()._r.append(OxmlElement("w:drawing")) doc.add_paragraph("日期:2026年8月20日") chapter = Chapter( id="6", title="项目经理", generated_content="一、承诺与签署\n评分项补充正文", ) all_paras = list(doc.paragraphs) _replace_chapter_content(doc, all_paras, 0, len(all_paras) - 1, chapter) order = self._body_order(doc) label_index = order.index("法定代表人签字:") image_index = order.index("") date_index = order.index("日期:2026年8月20日") self.assertLess(label_index, image_index) self.assertLess(image_index, date_index) self.assertNotIn("评分项补充正文", order[label_index + 1:date_index]) self.assertIn("评分项补充正文", order) image_p_pr = image_para._p.find(qn("w:pPr")) self.assertIsNotNone(image_p_pr) self.assertIsNotNone(image_p_pr.find(qn("w:keepNext"))) def test_native_media_block_cannot_capture_or_cross_section_break(self): doc = Document() doc.add_paragraph("第三章:需求理解") section_para = doc.add_paragraph() section_p_pr = OxmlElement("w:pPr") section_p_pr.append(OxmlElement("w:sectPr")) section_para._p.insert(0, section_p_pr) image_para = doc.add_paragraph() image_para.add_run()._r.append(OxmlElement("w:drawing")) doc.add_paragraph("图后说明") region = list(doc.element.body)[:-1] _protected, _signature_groups, media_blocks = ( _protect_native_context_blocks(region, doc) ) section_index = region.index(section_para._p) self.assertTrue(media_blocks) self.assertTrue(all(section_index not in block for block in media_blocks)) self.assertTrue( all( not (min(block) < section_index < max(block)) for block in media_blocks ) ) chapter = Chapter( id="3", title="需求理解", generated_content="一、重构后的需求分析\n新的需求理解正文", ) all_paras = list(doc.paragraphs) _replace_chapter_content( doc, all_paras, 0, len(all_paras) - 1, chapter ) body = list(doc.element.body) self.assertLess(body.index(section_para._p), body.index(image_para._p)) def test_demand_augmentation_preserves_template_context_and_adds_scoring_section(self): doc = Document() doc.add_paragraph("第三章:需求理解") doc.add_paragraph("一、项目定位分析") doc.add_paragraph("项目定位依据如下表:") table = doc.add_table(rows=1, cols=1) table.cell(0, 0).text = "项目定位依据" doc.add_paragraph("表后说明:定位依据来自采购需求。") scoring = Chapter( id="3.1", title="二、需求理解(6分)", level=2, description="评分要求:服务定位、预期目标和重点难点分析", generated_content="针对本项目形成独特的需求理解响应。", related_criteria=["SC-02"], structure_locked=True, ) chapter = Chapter( id="3", title="需求理解", children=[scoring], generated_content="二、需求理解(6分)\n针对本项目形成独特的需求理解响应。", ) all_paras = list(doc.paragraphs) _replace_chapter_content(doc, all_paras, 0, len(all_paras) - 1, chapter) order = self._body_order(doc) self.assertIn("一、项目定位分析", order) heading_index = order.index("二、需求理解(6分)") caption_index = order.index("项目定位依据如下表:") table_index = order.index("
") note_index = order.index("表后说明:定位依据来自采购需求。") body_index = order.index("针对本项目形成独特的需求理解响应。") self.assertLess(caption_index, table_index) self.assertLess(table_index, note_index) self.assertLess(note_index, heading_index) self.assertLess(heading_index, body_index) self.assertEqual(doc.tables[0].cell(0, 0).text, "项目定位依据") def test_demand_augmentation_rejects_unauthorized_generated_structure(self): doc = Document() doc.add_paragraph("第三章:需求理解") doc.add_paragraph("一、模板原小节") doc.add_paragraph("模板正文") chapter = Chapter( id="3", title="需求理解", generated_content=( "第三章 需求理解\n" "一、评分需求分析\n新增正文\n" "第四章 模型越界章节\n越界标题后的普通正文\n" "| 指标 | 响应 |\n| --- | --- |\n| A | B |" ), ) _replace_chapter_content( doc, list(doc.paragraphs), 0, len(doc.paragraphs) - 1, chapter ) final_text = "\n".join(p.text for p in doc.paragraphs) self.assertEqual(len(doc.tables), 0) self.assertEqual(final_text.count("第三章"), 1) self.assertNotIn("第四章", final_text) self.assertNotIn("一、评分需求分析", final_text) self.assertIn("一、模板原小节", final_text) self.assertIn("模板正文", final_text) def test_c2_does_not_consume_table_placeholder_authorization(self): doc = Document() doc.add_paragraph("%%实质性要求响应表%%") _resolve_table_placeholders(doc, object()) self.assertEqual(doc.paragraphs[0].text, "%%实质性要求响应表%%") def test_formatting_skips_protected_native_table_xml(self): doc = Document() table = doc.add_table(rows=1, cols=1) table.cell(0, 0).text = "签名:模板原文" before = table._element.xml applier = FormatApplier() applier._fix_table_format( doc, protected_table_elements={table._element} ) self.assertEqual(table._element.xml, before) def test_step6_centers_table_objects_without_changing_cell_alignment(self): doc = Document() first = doc.add_table(rows=1, cols=2) first.cell(0, 0).paragraphs[0].alignment = 0 # left first.cell(0, 1).paragraphs[0].alignment = 2 # right second = doc.add_table(rows=1, cols=1) second.alignment = 0 # left first_cell_xml = [ p._element.xml for cell in first.rows[0].cells for p in cell.paragraphs ] self.assertEqual(_center_document_tables(doc), 2) self.assertEqual(_center_document_tables(doc), 0) for table in doc.tables: jc = table._element.tblPr.find(qn("w:jc")) self.assertIsNotNone(jc) self.assertEqual(jc.get(qn("w:val")), "center") self.assertEqual( [p._element.xml for cell in first.rows[0].cells for p in cell.paragraphs], first_cell_xml, ) def test_template_derived_chapter_does_not_reinsert_table_placeholder(self): doc = Document() doc.add_paragraph("第一章:投标人资格、资信证明") doc.add_paragraph("一、资格条件响应表") doc.add_paragraph("项目名称:%%项目名称%%") doc.add_paragraph("%%资格条件响应表%%") doc.add_paragraph("投标人授权代表签字:") doc.add_paragraph("二、实质性要求响应表") chapter = Chapter( id="1", title="投标人资格、资信证明", chapter_type=ChapterType.BUSINESS, generated_content="\n".join(p.text for p in doc.paragraphs), ) updated = _update_chapter_preserving_template_structure( doc, list(doc.paragraphs), 0, len(doc.paragraphs) - 1, chapter ) self.assertEqual(updated, 0) self.assertEqual( sum("%%资格条件响应表%%" in p.text for p in doc.paragraphs), 1 ) def test_step6_copies_step1_table_context_xml_and_protects_it(self): source = Document() source.add_paragraph("5.实质性要求响应表") preamble = source.add_paragraph("项目名称:") preamble.paragraph_format.space_after = 120 source_table = source.add_table(rows=2, cols=2) source_table.cell(0, 0).text = "项目内容" source_table.cell(0, 1).text = "投标人响应" source_table.cell(1, 0).text = "不得转包" source_table.cell(1, 1).text = "" postamble = source.add_paragraph("说明:逐项响应。") postamble.paragraph_format.left_indent = 240 table_data = ExtractedTable( table_id="T-substantive", rows=2, cols=2, cells=[ [TableCell(text="项目内容"), TableCell(text="投标人响应")], [TableCell(text="不得转包"), TableCell(text="完全响应")], ], title_hint="5.实质性要求响应表", header_fields=["项目名称:"], postamble="说明:逐项响应。", table_type="substantive_response", ) with tempfile.TemporaryDirectory() as temp_dir: artifact = Path(temp_dir) / "5.实质性要求响应表.docx" source.save(artifact) item = ExtractedItem( name="5.实质性要求响应表", item_type="表", preamble="项目名称:", postamble="说明:逐项响应。", source_table=table_data, artifact_path=str(artifact), ) table_data.artifact_path = str(artifact) target = Document() target.add_paragraph("第一章:投标人资格、资信证明") target.add_paragraph("二、实质性要求响应表") target.add_paragraph("模板旧表前文字") target.add_paragraph("%%实质性要求响应表%%") target.add_paragraph("模板旧表后文字") target.add_paragraph("三、投标函") protected_tables = set() protected_paragraphs = set() replaced = _resolve_remaining_table_placeholders( target, TenderAnalysis( project_name="测试项目", tender_tables=[table_data], ), extracted_items=[item], protected_table_elements=protected_tables, protected_paragraph_elements=protected_paragraphs, ) self.assertEqual(replaced, 1) self.assertEqual(len(target.tables), 1) self.assertEqual( target.tables[0]._tbl.tblPr.xml, source_table._tbl.tblPr.xml ) self.assertIsNone(target.tables[0]._tbl.tblPr.find(qn("w:jc"))) self.assertIn("不得转包", target.tables[0].cell(1, 0).text) texts = [p.text for p in target.paragraphs] self.assertNotIn("模板旧表前文字", texts) self.assertNotIn("模板旧表后文字", texts) self.assertIn("项目名称:测试项目", texts) self.assertIn("说明:逐项响应。", texts) self.assertIn(target.tables[0]._element, protected_tables) self.assertEqual(len(protected_paragraphs), 2) class NumericRuleIdempotencyTests(unittest.TestCase): @staticmethod def _apply_rules(text, rules): result = text for rule in rules: result = rule["conflict_re"].sub(rule["canonical"], result) return result def test_dynamic_numeric_rule_is_idempotent(self): rules = _build_dynamic_numeric_rules( ["本项目的人员流动率不超过20%"] ) once = self._apply_rules("本项目的人员流动率不超过30%", rules) twice = self._apply_rules(once, rules) self.assertEqual(once, twice) self.assertNotIn("本项目的本项目的", twice) if __name__ == "__main__": unittest.main()