| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936 |
- 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("<IMAGE>" if elem.findall(".//" + qn("w:drawing")) else text)
- elif elem.tag == qn("w:tbl"):
- order.append("<TABLE>")
- 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("<TABLE>"))
- self.assertLess(order.index("<TABLE>"), 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("<IMAGE>")
- 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("<TABLE>")
- 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()
|