wangxi преди 1 седмица
родител
ревизия
0aaccba9b9

+ 9 - 2
CHANGELOG.md

@@ -2,7 +2,14 @@
 
 本文件记录 Proposa AI 的正式发布与上线代码基线。日期采用 Asia/Shanghai 时区。
 
-## 2026-08-31
+## 2026-09-14(1.0.1)
+
+### 变更
+
+- 投标函内容中存在下划线丢失现象
+- 表格后存在部分重复内容
+
+## 2026-08-31(1.0.0)
 
 ### 变更
 
@@ -16,7 +23,7 @@
 - API 专项测试 30 项全部通过,覆盖首选目录、第二目录回退、两处同时存在和两处均缺失场景。
 - 完整快速回归 188 项全部通过。
 
-## 2026-08-28
+## 2026-08-28(1.0.0)
 
 发布基线:由 `wangxi` 提升至 `master`,并保持两个分支代码一致。
 

+ 1 - 1
pyproject.toml

@@ -1,6 +1,6 @@
 [project]
 name = "proposa-ai"
-version = "1.0.0"
+version = "1.0.1"
 description = "AI 驱动的投标书智能生成系统"
 requires-python = ">=3.12"
 dependencies = [

+ 18 - 2
scripts/test_step5.py

@@ -471,6 +471,12 @@ def _safe_copy_and_clean_chapter(source, target, has_packages, remove_keys=None,
                     body = element
                     break
             if body is not None:
+                from step5_reviewing.table_dedup import (
+                    clear_duplicate_signature_blocks, clear_table_repetitions,
+                )
+                clear_duplicate_signature_blocks(body, _TITLE_PREFIX_RE)
+                protected_postamble = set()
+                clear_table_repetitions(body, _TITLE_PREFIX_RE, protected_postamble)
                 seen = set()
                 seen_filled_labels = set()
                 for paragraph in list(body):
@@ -500,7 +506,7 @@ def _safe_copy_and_clean_chapter(source, target, has_packages, remove_keys=None,
                         seen = set()
                         seen_filled_labels = set()
                         continue
-                    if key in remove_keys:
+                    if key in remove_keys and paragraph not in protected_postamble:
                         for node in text_nodes:
                             node.text = ""
                         continue
@@ -509,7 +515,17 @@ def _safe_copy_and_clean_chapter(source, target, has_packages, remove_keys=None,
                         raw_label = label_match.group(1).strip()
                         label_value = label_match.group(2).strip()
                         label = _PROJECT_LABEL_ALIASES.get(raw_label, raw_label)
+                        if paragraph in protected_postamble:
+                            if raw_label in _PACKAGE_LABELS and not has_packages and not label_value:
+                                for node in text_nodes:
+                                    node.text = ""
+                            continue
                         if label_value:
+                            if key in seen:
+                                for node in text_nodes:
+                                    node.text = ""
+                                continue
+                            seen.add(key)
                             seen_filled_labels.add(label)
                             continue
                         if label in seen_filled_labels:
@@ -521,7 +537,7 @@ def _safe_copy_and_clean_chapter(source, target, has_packages, remove_keys=None,
                                 node.text = ""
                             continue
                         continue
-                    if len(key) >= 12 and key in seen:
+                    if len(key) >= 12 and key in seen and paragraph not in protected_postamble:
                         for node in text_nodes:
                             node.text = ""
                         continue

+ 88 - 0
scripts/tests/test_step4_underlines.py

@@ -0,0 +1,88 @@
+"""投标函字段填充不得把混合格式段落压平到第一个 run。"""
+import os
+import tempfile
+import unittest
+import xml.etree.ElementTree as ET
+
+from docx import Document
+from docx.enum.text import WD_UNDERLINE
+
+from models import Chapter
+from step4_writing import _fill_docx_paragraph_text, _save_chapter_docx, _w_tag
+
+
+class Step4UnderlineTests(unittest.TestCase):
+    def test_cross_run_placeholder_keeps_untouched_run_properties(self):
+        doc = Document()
+        p = doc.add_paragraph("参加")
+        for value in ("%%项", "目名称%%"):
+            run = p.add_run(value)
+            run.underline = WD_UNDERLINE.DOUBLE
+            run.bold = True
+        p.add_run(",承诺")
+        p.add_run("固定下划线文字").underline = True
+        p.add_run("。")
+        xml = ET.fromstring(p._p.xml)
+        properties = [ET.tostring(r.find(_w_tag("rPr")))
+                      if r.find(_w_tag("rPr")) is not None else None
+                      for r in xml.iter(_w_tag("r"))]
+        _fill_docx_paragraph_text(xml, "参加示例项目,承诺固定下划线文字。")
+        texts = [t.text or "" for t in xml.iter(_w_tag("t"))]
+        self.assertEqual(texts, ["参加", "示例项目", "", ",承诺", "固定下划线文字", "。"],
+                         "字段应在原下划线run内替换,禁止整段压平")
+        self.assertEqual(properties, [ET.tostring(r.find(_w_tag("rPr")))
+                         if r.find(_w_tag("rPr")) is not None else None
+                         for r in xml.iter(_w_tag("r"))])
+
+    def test_deletion_numeric_update_and_non_text_nodes_survive(self):
+        p = Document().add_paragraph("比例")
+        p.add_run("15%").underline = True
+        p.add_run(",包号:%%包号%%")
+        p.add_run().add_break()
+        p.add_run("签署:____").italic = True
+        xml = ET.fromstring(p._p.xml)
+        _fill_docx_paragraph_text(xml, "比例20%签署:____")
+        self.assertEqual("".join(t.text or "" for t in xml.iter(_w_tag("t"))),
+                         "比例20%签署:____")
+        self.assertEqual(len(list(xml.iter(_w_tag("br")))), 1)
+        underlined = [r for r in xml.iter(_w_tag("r"))
+                      if r.find(_w_tag("rPr") + '/' + _w_tag("u")) is not None]
+        self.assertEqual("".join(t.text or "" for r in underlined for t in r.iter(_w_tag("t"))), "20%")
+
+    def test_native_chapter_docx_preserves_letter_and_cell_underlines(self):
+        with tempfile.TemporaryDirectory() as directory:
+            template = Document()
+            template.add_paragraph("第一章 投标人资格、资信证明", style="Heading 1")
+            template.add_paragraph("投标函")
+            paragraphs = [template.add_paragraph(), template.add_table(rows=1, cols=1).cell(0, 0).paragraphs[0]]
+            for p in paragraphs:
+                p.add_run("参加")
+                p.add_run("%%项目").underline = True
+                p.add_run("名称%%").underline = True
+                p.add_run(",编号:")
+                p.add_run("%%项目编号%%").underline = WD_UNDERLINE.DOUBLE
+                p.add_run(";签署:")
+                p.add_run("    ").underline = True
+                p.add_run("。")
+            template.add_paragraph("第二章 报价", style="Heading 1")
+            template_path = os.path.join(directory, "template.docx")
+            template.save(template_path)
+            path = _save_chapter_docx(
+                Chapter(id="1", title="投标人资格、资信证明", template_chapter_id="1"),
+                directory, template_path=template_path,
+                placeholder_map={"项目名称": "示例项目", "项目编号": "TEST-001"},
+            )
+            result = Document(path)
+            paragraphs = [next(p for p in result.paragraphs if p.text.startswith("参加")),
+                          result.tables[0].cell(0, 0).paragraphs[0]]
+            for p in paragraphs:
+                self.assertEqual(p.text, "参加示例项目,编号:TEST-001;签署:    。")
+                self.assertEqual([(r.text, r.underline) for r in p.runs if r.text], [
+                    ("参加", None), ("示例项目", True), (",编号:", None),
+                    ("TEST-001", WD_UNDERLINE.DOUBLE), (";签署:", None),
+                    ("    ", True), ("。", None),
+                ], "原生段落/单元格填充必须保留字段及签署空白下划线")
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 219 - 0
scripts/tests/test_step5_table_dedup.py

@@ -0,0 +1,219 @@
+"""Synthetic DOCX regressions for response-table postambles (no LLM)."""
+
+import tempfile
+import sys
+import unittest
+import xml.etree.ElementTree as ET
+import zipfile
+from pathlib import Path
+
+from docx import Document
+from docx.oxml import OxmlElement
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+from scripts.test_step5 import _safe_copy_and_clean_chapter
+
+
+W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
+RESPONSE = "我方具备履行本项目合同所必需的设备和专业技术能力。"
+
+
+class TablePostambleTests(unittest.TestCase):
+    def add_signature(self, doc, company="", date="年月日", media=False):
+        doc.add_paragraph("投标人授权代表签字(盖章):")
+        p = doc.add_paragraph("投标人(公章):" + company)
+        if media:
+            p.add_run()._r.append(OxmlElement("w:drawing"))
+        doc.add_paragraph("日期:" + date)
+
+    def test_complete_blank_signature_block_yields_to_filled_block(self):
+        doc = Document()
+        for title in ("资格条件响应表", "实质性要求响应表"):
+            doc.add_heading(title, 2)
+            doc.add_table(rows=1, cols=1)
+            self.add_signature(doc)
+            doc.add_paragraph("")
+            doc.add_paragraph().add_run()._r.append(OxmlElement("w:drawing"))
+            self.add_signature(doc, "测试单位")
+        lines = self.clean(doc)
+        self.assertEqual(lines.count("投标人(公章):测试单位"), 2)
+        self.assertNotIn("投标人(公章):", lines,
+                         "Remove only the redundant blank complete block after each table")
+        self.assertEqual(lines.count("日期:年月日"), 2)
+
+    def test_conflicting_or_image_signature_blocks_are_preserved(self):
+        for conflict, media in ((True, False), (False, True)):
+            with self.subTest(conflict=conflict, media=media):
+                doc = Document()
+                doc.add_table(rows=1, cols=1)
+                self.add_signature(doc, "测试单位甲", media=media)
+                self.add_signature(doc, "测试单位乙" if conflict else "测试单位甲")
+                lines = self.clean(doc)
+                self.assertEqual(lines.count("日期:年月日"), 2)
+
+    def clean(self, doc, remove_keys=None):
+        with tempfile.TemporaryDirectory() as folder:
+            source = Path(folder) / "source.docx"
+            target = Path(folder) / "clean.docx"
+            again = Path(folder) / "again.docx"
+            doc.save(source)
+            _safe_copy_and_clean_chapter(source, target, False, remove_keys)
+            _safe_copy_and_clean_chapter(target, again, False, remove_keys)
+            with zipfile.ZipFile(source) as z:
+                original = ET.fromstring(z.read("word/document.xml"))
+            with zipfile.ZipFile(target) as z, zipfile.ZipFile(again) as a:
+                result = ET.fromstring(z.read("word/document.xml"))
+                self.assertEqual(z.read("word/document.xml"), a.read("word/document.xml"),
+                                 "Step5 cleanup must be idempotent")
+                for name in z.namelist():
+                    if name != "word/document.xml":
+                        with zipfile.ZipFile(source) as s:
+                            self.assertEqual(s.read(name), z.read(name))
+            self.assertEqual(
+                [ET.tostring(t) for t in original.iter(W + "tbl")],
+                [ET.tostring(t) for t in result.iter(W + "tbl")],
+                "Never change native table XML to remove postamble duplicates",
+            )
+            self.assertEqual([ET.tostring(t) for t in original.iter(W + "drawing")],
+                             [ET.tostring(t) for t in result.iter(W + "drawing")])
+            self.last_body = result.find(W + "body")
+            return ["".join(t.text or "" for t in p.iter(W + "t"))
+                    for p in result.find(W + "body") if p.tag == W + "p"]
+
+    def test_both_response_tables_remove_cell_and_numbered_echoes(self):
+        doc = Document()
+        for title in ("资格条件响应表", "实质性要求响应表"):
+            doc.add_heading(title, 2)
+            table = doc.add_table(rows=1, cols=1)
+            table.cell(0, 0).text = "1、" + RESPONSE
+            doc.add_paragraph("1、" + RESPONSE)
+            doc.add_paragraph("此处为独有的补充材料位置说明,应当完整保留。")
+        lines = self.clean(doc)
+        self.assertNotIn("1、" + RESPONSE, lines,
+                         "Include table text in dedup; numbered responses are not headings")
+        self.assertEqual(lines.count("此处为独有的补充材料位置说明,应当完整保留。"), 2)
+
+    def test_postamble_repeated_project_values_and_notes(self):
+        doc = Document()
+        doc.add_heading("资格条件响应表", 2)
+        doc.add_table(rows=1, cols=1).cell(0, 0).text = RESPONSE
+        for _ in range(2):
+            doc.add_paragraph("项目名称:测试")
+            doc.add_paragraph("项目编号:A1")
+            doc.add_paragraph("注:本表应附相关资格证明材料,具体以附件为准。")
+        lines = self.clean(doc)
+        self.assertEqual(lines.count("项目名称:测试"), 1,
+                         "Filled project labels must not bypass duplicate detection")
+        self.assertEqual(lines.count("项目编号:A1"), 1)
+        self.assertEqual(lines.count("注:本表应附相关资格证明材料,具体以附件为准。"), 1)
+
+    def test_signatures_notes_and_next_section_survive(self):
+        doc = Document()
+        doc.add_heading("资格条件响应表", 2)
+        note = "注:本表应附相关资格证明材料,具体以附件为准。"
+        doc.add_table(rows=1, cols=1).cell(0, 0).text = RESPONSE + "\n" + note
+        doc.add_paragraph(note)
+        for _ in range(2):
+            doc.add_paragraph("投标人(公章):测试单位")
+            doc.add_paragraph("日期:2026年9月11日")
+        doc.add_heading("另一节", 2)
+        doc.add_paragraph(RESPONSE)
+        lines = self.clean(doc)
+        self.assertIn(note, lines)
+        self.assertEqual(lines.count("投标人(公章):测试单位"), 2)
+        self.assertEqual(lines.count("日期:2026年9月11日"), 2)
+        self.assertIn(RESPONSE, lines, "Do not deduplicate across heading boundaries")
+
+    def test_new_table_replaces_corpus_and_keeps_different_response(self):
+        doc = Document()
+        doc.add_heading("资格条件响应表", 2)
+        doc.add_table(rows=1, cols=1).cell(0, 0).text = RESPONSE
+        doc.add_paragraph("我方不具备履行本项目合同所必需的设备和专业技术能力。")
+        doc.add_table(rows=1, cols=1).cell(0, 0).text = "另一张独立表格,载有不同的响应内容。"
+        doc.add_paragraph(RESPONSE)
+        lines = self.clean(doc)
+        self.assertIn(RESPONSE, lines)
+        self.assertTrue(any("不具备" in line for line in lines))
+
+    def test_bookmark_and_drawing_paragraphs_are_not_table_echoes(self):
+        doc = Document()
+        doc.add_table(rows=1, cols=1).cell(0, 0).text = RESPONSE
+        p = doc.add_paragraph(RESPONSE)
+        p._p.append(OxmlElement("w:bookmarkStart"))
+        p = doc.add_paragraph("配图说明文字属于原生图片块,不应因表内相同文字而删除。")
+        p.add_run()._r.append(OxmlElement("w:drawing"))
+        lines = self.clean(doc)
+        self.assertIn(RESPONSE, lines)
+
+    def test_equivalent_postamble_keeps_later_template_paragraph(self):
+        doc = Document()
+        doc.add_table(rows=1, cols=1)
+        note = "注:本表应附相关资格证明材料,具体以附件为准。"
+        for marker in ("11111111", "22222222"):
+            p = doc.add_paragraph(note)
+            p._p.set("{http://schemas.microsoft.com/office/word/2010/wordml}paraId", marker)
+        self.clean(doc, {note})
+        survivors = [p for p in self.last_body if ''.join(
+            n.text or '' for n in p.iter(W + 't')) == note]
+        self.assertEqual(len(survivors), 1, "LLM text removals must not delete the survivor")
+        self.assertEqual(survivors[0].get(
+            "{http://schemas.microsoft.com/office/word/2010/wordml}paraId"), "22222222")
+
+    def test_richer_postamble_survives_in_either_order(self):
+        short = "注:本表应附相关资格证明材料,具体以附件为准。"
+        rich = short + "证明材料须注明有效期,并提供查询方式。"
+        unique = "表格自带的独有说明,应同时提交纸质复印件。"
+        for values in ((short, rich), (rich, short)):
+            with self.subTest(values=values):
+                doc = Document()
+                doc.add_table(rows=1, cols=1)
+                doc.add_paragraph(values[0])
+                doc.add_paragraph(unique)
+                doc.add_paragraph(values[1])
+                lines = self.clean(doc, {rich, short, unique})
+                self.assertIn(rich, lines, "Retain all additional clauses, regardless of order")
+                self.assertNotIn(short, lines)
+                self.assertIn(unique, lines, "A unique source-table note must never be deleted")
+
+    def test_different_or_conflicting_sentences_are_not_length_ranked(self):
+        doc = Document()
+        doc.add_table(rows=1, cols=1)
+        values = ["我方提交全部资格证明材料。", "我方不提交全部资格证明材料。",
+                  "资格证明材料有效期为三年。", "资格证明材料有效期为五年,并包含附加证明。"]
+        for value in values:
+            doc.add_paragraph(value)
+        lines = self.clean(doc, set(values))
+        for value in values:
+            self.assertIn(value, lines, "Length or shared keywords are not proof of duplication")
+
+    def test_signature_keeps_later_location_and_complementary_values(self):
+        doc = Document()
+        doc.add_table(rows=1, cols=1)
+        self.add_signature(doc, "测试单位")
+        note = "源表独有说明:请保留本说明及随附证明。"
+        doc.add_paragraph(note)
+        self.add_signature(doc, date="2026年9月14日")
+        lines = self.clean(doc, {note})
+        self.assertEqual(lines.count("投标人授权代表签字(盖章):"), 1)
+        self.assertIn("投标人(公章):测试单位", lines)
+        self.assertIn("日期:2026年9月14日", lines)
+        self.assertGreater(lines.index("投标人(公章):测试单位"), lines.index(note),
+                           "Retain the template block's later position with all filled values")
+
+    def test_equal_short_project_fields_keep_later_and_empty_yields_to_filled(self):
+        doc = Document()
+        doc.add_table(rows=1, cols=1)
+        doc.add_paragraph("项目名称:")
+        doc.add_paragraph("项目名称:测试")
+        doc.add_paragraph("项目编号:A1")
+        doc.add_paragraph("此处为前后两份字段之间的独有说明。")
+        doc.add_paragraph("项目编号:A1")
+        lines = self.clean(doc, {"项目名称:测试", "项目编号:A1"})
+        self.assertNotIn("项目名称:", lines)
+        self.assertEqual(lines.count("项目名称:测试"), 1)
+        self.assertEqual(lines.count("项目编号:A1"), 1)
+        self.assertGreater(lines.index("项目编号:A1"),
+                           lines.index("此处为前后两份字段之间的独有说明。"))
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 45 - 0
scripts/tests/test_template_heading_levels.py

@@ -0,0 +1,45 @@
+import copy
+import unittest
+
+from models import BidOutline, Chapter, SkeletonParagraph, TenderAnalysis
+from step3_outlining.outline_generator import _OutlineGenerator
+from step3_outlining.outline_report import build_outline_report, validate_outline_gate
+from step3_outlining.scoring_structure import apply_scoring_structure
+from scripts.tests.test_scoring_structure_and_business_forms import criterion, FakeMappingLlm
+
+
+class TemplateHeadingLevelTests(unittest.TestCase):
+    def setUp(self):
+        self.parent = Chapter(id='4.13', title='十三、应急预案和紧急事件处置措施',
+                              level=2, from_template=True)
+        self.root = Chapter(id='4', title='基本服务方案', level=1,
+                            template_chapter_id='4', children=[self.parent])
+        self.skeletons = [SkeletonParagraph(para_idx=0, text=self.parent.title,
+                          similarity=1.0, inferred_level=1, is_heading=True)]
+
+    def test_reference_h1_cannot_override_template_h2_or_scoring_children(self):
+        template = BidOutline(project_name='测试', chapters=[copy.deepcopy(self.root)])
+        _OutlineGenerator.__new__(_OutlineGenerator)._apply_heading_levels([self.root], self.skeletons)
+        self.assertEqual(self.parent.level, 2, '参考骨架H1不得覆盖模板H2')
+        outline = BidOutline(project_name='测试', chapters=[self.root])
+        scoring = criterion('SC-01', '应急预案和紧急事件处置措施', '防汛应急预案')
+        apply_scoring_structure(outline, [scoring], llm=FakeMappingLlm())
+        child = self.parent.children[-1]
+        self.assertEqual(child.level, 3)
+        self.assertTrue(child.title.startswith('(一)'))
+        report, _ = build_outline_report(template, outline, TenderAnalysis(project_name='测试', scoring_criteria=[scoring]))
+        self.assertIn('H2 `4.13`', report)
+        self.assertIn(f'H3 `{child.id}`', report)
+
+    def test_gate_rejects_nested_h1_and_wrong_new_child_level(self):
+        template = BidOutline(project_name='测试', chapters=[copy.deepcopy(self.root)])
+        self.parent.level = 1
+        self.parent.children = [Chapter(id='4.13.1', title='错误子项', level=2)]
+        errors = validate_outline_gate(template, BidOutline(project_name='测试', chapters=[self.root]), TenderAnalysis(project_name='测试'))
+        self.assertTrue(any('4.13 H1,应为 H2' in error for error in errors))
+        self.assertTrue(any('4.13.1 H2,应为 H3' in error for error in errors))
+
+    def test_non_template_reference_inference_cannot_break_parent_tree(self):
+        self.parent.from_template = False
+        _OutlineGenerator.__new__(_OutlineGenerator)._apply_heading_levels([self.root], self.skeletons)
+        self.assertEqual(self.parent.level, 2)

+ 75 - 0
scripts/tests/test_template_text_reading.py

@@ -0,0 +1,75 @@
+import tempfile
+import unittest
+from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import patch
+
+from docx import Document
+from docx.enum.text import WD_BREAK
+from docx.oxml import OxmlElement
+from models import BidOutline, Chapter, CompanyInfo, TemplateSection, TemplateStructure, TenderAnalysis
+from step3_outlining.template_parser import (
+    _read_template_body_texts, get_template_text_for_chapter, get_template_text_for_section,
+)
+from step4_writing import _write_single_chapter
+from step4_writing.content_injector import _ContentInjector
+
+
+class TemplateTextReadingTests(unittest.TestCase):
+    def setUp(self):
+        self.directory = tempfile.TemporaryDirectory()
+        self.addCleanup(self.directory.cleanup)
+        self.path = Path(self.directory.name) / 'template.docx'
+        doc = Document()
+        doc.add_paragraph('第四章 服务方案')
+        doc.add_paragraph('')
+        doc.add_table(rows=1, cols=1).cell(0, 0).text = '表内文本不计入正文索引'
+        p = doc.add_paragraph('正文\t制表\n换行')
+        p.add_run().add_break(WD_BREAK.PAGE)
+        link = OxmlElement('w:hyperlink')
+        run = OxmlElement('w:r')
+        text = OxmlElement('w:t')
+        text.text = '链接'
+        run.append(text)
+        run.append(OxmlElement('w:noBreakHyphen'))
+        link.append(run)
+        p._p.append(link)
+        doc.add_paragraph('第五章 其他方案')
+        self.expected = [p.text for p in doc.paragraphs]
+        doc.save(self.path)
+        self.structure = TemplateStructure(file_path=str(self.path), sections=[
+            TemplateSection(name='第四章 服务方案', start_para=0, end_para=2),
+            TemplateSection(name='第五章 其他方案', start_para=3, end_para=99),
+        ])
+
+    def test_top_level_indices_and_visible_text_match_python_docx(self):
+        self.assertEqual(_read_template_body_texts(str(self.path)), self.expected)
+
+    def test_parallel_section_and_title_reads_do_not_use_native_parser(self):
+        expected = '\n'.join(t.strip() for t in self.expected[:3] if t.strip())
+        def read(index):
+            if index % 2:
+                return get_template_text_for_section(self.structure, '第四章 服务方案')
+            return get_template_text_for_chapter(self.structure, '第四章 服务方案', use_llm=False)
+        with patch('step3_outlining.template_parser.DocxDocument', side_effect=AssertionError(
+            '并发纯文本取文不得进入python-docx原生解析器'
+        )), ThreadPoolExecutor(max_workers=5) as pool:
+            self.assertEqual(list(pool.map(read, range(25))), [expected] * 25)
+        self.assertEqual(get_template_text_for_section(self.structure, '第五章 其他方案'), self.expected[3])
+        self.assertEqual(get_template_text_for_section(self.structure, '不存在'), '')
+
+    def test_step4_chapter_generation_uses_streamed_template_text(self):
+        chapter = Chapter(id='4', title='服务方案', template_section='第四章 服务方案')
+        outline = BidOutline(project_name='测试项目', chapters=[chapter])
+        with patch('step3_outlining.template_parser.DocxDocument', side_effect=TypeError(
+            "'CT_P' object is not callable"
+        )):
+            result = _write_single_chapter(
+                chapter=chapter, writer=SimpleNamespace(), injector=_ContentInjector({}, []),
+                analysis=TenderAnalysis(project_name='测试项目'), company_info=CompanyInfo(),
+                template_structure=self.structure, cfg=SimpleNamespace(enforce_word_limit=False),
+                outline=outline, placeholder_map={},
+            )
+        self.assertEqual(result.generated_content, '\n'.join(t for t in self.expected[:3] if t))
+        self.assertTrue(result.preserve_template_layout)

+ 1 - 1
src/step3_outlining/__init__.py

@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
 
 # 大纲缓存版本:Step 3 逻辑/字段变化时递增,强制旧缓存失效,
 # 避免测试或生产命中旧版大纲而绕过新优化(LLM 优化、依据映射等)。
-_OUTLINE_CACHE_VERSION = "v6.9-commitment-scoring"
+_OUTLINE_CACHE_VERSION = "v6.10-template-heading-levels"
 
 
 class OutlineGateError(RuntimeError):

+ 16 - 19
src/step3_outlining/outline_generator.py

@@ -854,14 +854,8 @@ class _OutlineGenerator:
     ) -> List[Chapter]:
         """利用参考文件的骨架段落推断模板中各章节的标题层级
 
-        模板 DOCX 缺少 Word Heading 样式,但参考投标文件中有。
-        通过匹配章节标题与骨架段落的文本,确定各章的 Heading 级别。
-
-        推断规则:
-          - 匹配到 Heading 1 骨架段落 → level=1(章)
-          - 匹配到 Heading 2 骨架段落 → level=2(节)
-          - 匹配到 Heading 3 骨架段落 → level=3(小节)
-          - 未匹配到 → 保持现有 level 不变
+        模板来源节点的层级已经由模板标题树确定,参考投书不得覆盖。
+        其他节点仅接受与已有父子树深度一致的推断,不通过修改 level 改树。
         """
         if not skeleton_paragraphs:
             return chapters
@@ -877,12 +871,24 @@ class _OutlineGenerator:
             return chapters
 
         updated_count = 0
-        for chapter in chapters:
+        def visit(nodes, depth=1):
+            for node in nodes:
+                yield node, depth
+                yield from visit(node.children or [], depth + 1)
+
+        for chapter, depth in visit(chapters):
+            if (
+                chapter.from_template
+                or chapter.template_chapter_id
+                or chapter.template_original_id
+                or chapter.template_original_title
+            ):
+                continue
             # 为每个章节在骨架段落中找最佳匹配
             best_match = self._find_matching_skeleton(
                 chapter.title, heading_skeletons
             )
-            if best_match and best_match.inferred_level > 0:
+            if best_match and best_match.inferred_level == depth:
                 old_level = chapter.level
                 chapter.level = best_match.inferred_level
                 if old_level != chapter.level:
@@ -893,15 +899,6 @@ class _OutlineGenerator:
                         f"(similarity={best_match.similarity:.2f})"
                     )
 
-            # 递归处理子章节
-            if chapter.children:
-                for child in chapter.children:
-                    child_match = self._find_matching_skeleton(
-                        child.title, heading_skeletons
-                    )
-                    if child_match and child_match.inferred_level > 0:
-                        child.level = child_match.inferred_level
-
         if updated_count > 0:
             logger.info(f"标题层级推断: {updated_count} 个章节更新了层级")
 

+ 5 - 0
src/step3_outlining/outline_report.py

@@ -128,6 +128,11 @@ def validate_outline_gate(
         else []
     )
 
+    errors.extend(
+        f"标题层级与父子树不一致: {node.id} H{node.level},应为 H{len(path)}"
+        for node, path in _walk_with_paths(enhanced_outline.chapters)
+        if node.level != len(path)
+    )
     top_titles: dict[str, list[str]] = {}
     for chapter in enhanced_outline.chapters:
         if chapter.level != 1 or not chapter.id.isdigit():

+ 48 - 8
src/step3_outlining/template_parser.py

@@ -386,6 +386,48 @@ def _build_sections(
 # ============================================================
 
 
+def _read_template_body_texts(file_path: str) -> List[str]:
+    """独立流式解析正文,保留 Document.paragraphs 的索引和文本语义。"""
+    import zipfile
+    import xml.etree.ElementTree as ET
+
+    w = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
+    texts = []
+    with zipfile.ZipFile(file_path) as archive, archive.open("word/document.xml") as stream:
+        depth = 0
+        body_depth = None
+        for event, element in ET.iterparse(stream, events=("start", "end")):
+            if event == "start":
+                depth += 1
+                if element.tag == w + "body":
+                    body_depth = depth
+                continue
+            if body_depth is not None and depth == body_depth + 1:
+                if element.tag == w + "p":
+                    parts = []
+                    for child in element:
+                        runs = [child] if child.tag == w + "r" else (
+                            child.findall(w + "r") if child.tag == w + "hyperlink" else []
+                        )
+                        for run in runs:
+                            for item in run:
+                                if item.tag == w + "t":
+                                    parts.append(item.text or "")
+                                elif item.tag in (w + "tab", w + "ptab"):
+                                    parts.append("\t")
+                                elif item.tag == w + "cr" or (
+                                    item.tag == w + "br"
+                                    and item.get(w + "type", "textWrapping") == "textWrapping"
+                                ):
+                                    parts.append("\n")
+                                elif item.tag == w + "noBreakHyphen":
+                                    parts.append("-")
+                    texts.append("".join(parts))
+                element.clear()
+            depth -= 1
+    return texts
+
+
 def get_template_text_for_chapter(
     template_structure: TemplateStructure,
     chapter_title: str,
@@ -399,8 +441,6 @@ def get_template_text_for_chapter(
       3. 同号章节多候选且分数接近(如模板内嵌"管理制度"与真正的技术章
          同名同号)→ 用 LLM 仲裁一次,提高准确率;LLM 失败则回退最高分。
     """
-    doc = DocxDocument(template_structure.file_path)
-
     candidates: List[Tuple[int, TemplateSection]] = []
     for section in template_structure.sections:
         score = _titles_match_score(section.name, chapter_title)
@@ -423,12 +463,13 @@ def get_template_text_for_chapter(
         if llm_section is not None:
             best_section = llm_section
 
+    paragraphs = _read_template_body_texts(template_structure.file_path)
     parts = []
     for i in range(
         best_section.start_para,
-        min(best_section.end_para + 1, len(doc.paragraphs)),
+        min(best_section.end_para + 1, len(paragraphs)),
     ):
-        text = doc.paragraphs[i].text.strip()
+        text = paragraphs[i].strip()
         if text:
             parts.append(text)
     return "\n".join(parts)
@@ -551,16 +592,15 @@ def get_template_text_for_section(
     """按 Step 3 持久化的区域名精确获取模板文本(避免再次标题匹配)"""
     if not section_name:
         return ""
-    doc = DocxDocument(template_structure.file_path)
-
     for section in template_structure.sections:
         if section.name.strip() == section_name.strip():
+            paragraphs = _read_template_body_texts(template_structure.file_path)
             parts = []
             for i in range(
                 section.start_para,
-                min(section.end_para + 1, len(doc.paragraphs)),
+                min(section.end_para + 1, len(paragraphs)),
             ):
-                text = doc.paragraphs[i].text.strip()
+                text = paragraphs[i].strip()
                 if text:
                     parts.append(text)
             return "\n".join(parts)

+ 35 - 3
src/step4_writing/__init__.py

@@ -776,7 +776,7 @@ def write_content(
                         f"({completed}/{total_ordinary})"
                     )
                 except Exception as e:
-                    logger.error(f"  [{ch.id}] 撰写失败: {e}")
+                    logger.exception(f"  [{ch.id}] 撰写失败: {e}")
                     failed_chapters.append(ch.id)
                     ch.generated_content = f"【撰写失败: {e}】"
 
@@ -814,7 +814,7 @@ def write_content(
                 words = len(ch.generated_content)
                 logger.info(f"  [{ch.id}] {ch.title[:30]}: {words:,} 字")
             except Exception as e:
-                logger.error(f"  [{ch.id}] 延迟章节撰写失败: {e}")
+                logger.exception(f"  [{ch.id}] 延迟章节撰写失败: {e}")
                 ch.generated_content = f"【撰写失败: {e}】"
 
     if failed_chapters:
@@ -1918,6 +1918,38 @@ def _replace_docx_paragraph_text(paragraph, text: str) -> None:
         node.text = ""
 
 
+def _fill_docx_paragraph_text(paragraph, text: str) -> None:
+    """只更新变化的文本范围,保留模板各 run 的格式和非文本节点。"""
+    from difflib import SequenceMatcher
+
+    nodes = list(paragraph.iter(_w_tag("t")))
+    if not nodes:
+        _replace_docx_paragraph_text(paragraph, text)
+        return
+    original = "".join(node.text or "" for node in nodes)
+    # 占位符作为整体匹配,避免值中的相同字符被匹配到占位符内部。
+    token_pattern = r"%%[^%\r\n]+%%|%[((][^%\r\n]+[))]%|[\s\S]"
+    before = re.findall(token_pattern, original)
+    after = re.findall(token_pattern, text)
+    offsets = [0]
+    for token in before:
+        offsets.append(offsets[-1] + len(token))
+    owners = [index for index, node in enumerate(nodes) for _ in (node.text or "")]
+    values = ["" for _ in nodes]
+    for kind, i, j, a, b in SequenceMatcher(None, before, after, autojunk=False).get_opcodes():
+        start, end = offsets[i], offsets[j]
+        if kind == "equal":
+            for pos in range(start, end):
+                values[owners[pos]] += original[pos]
+        elif kind in {"replace", "insert"}:
+            # 新值继承被替换范围起点的格式;末尾追加继承最后一个字符。
+            owner = owners[min(start, len(owners) - 1)] if owners else 0
+            values[owner] += "".join(after[a:b])
+    for node, value in zip(nodes, values):
+        node.text = value
+        node.set(f"{{{_XML_NS}}}space", "preserve")
+
+
 def _new_docx_paragraph(text: str, style_id: str = ""):
     paragraph = ET.Element(_w_tag("p"))
     if style_id:
@@ -2897,7 +2929,7 @@ def _save_native_template_chapter_docx(
                     # 同样覆盖模板历史值,不能只修正 generated_content。
                     filled = _override_rejection_conflicts(filled, analysis)
                 if filled != original:
-                    _replace_docx_paragraph_text(paragraph, filled)
+                    _fill_docx_paragraph_text(paragraph, filled)
 
             # 最终大纲是唯一标题树;模板原标题只负责找到原生内容锚点。
             all_nodes = list(_walk_nodes(chapter))

+ 226 - 0
src/step5_reviewing/table_dedup.py

@@ -0,0 +1,226 @@
+"""Conservative, section-local cleanup of text repeated after native tables."""
+
+import re
+
+
+W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
+_SIGNATURE = re.compile(
+    r"^\s*(投标人|法定代表人|授权代表|签署人|日期|签字|签章|盖章)"
+)
+_NOTE = re.compile(r"^\s*(注[::、.]|注\d|备注|说明|填写说明|填表说明)")
+_STRUCTURAL = {W + name for name in (
+    "drawing", "pict", "object", "sectPr", "fldChar", "instrText",
+    "bookmarkStart", "bookmarkEnd", "commentRangeStart", "commentRangeEnd",
+)}
+
+
+def _text(element):
+    return "".join(node.text or "" for node in element.iter(W + "t"))
+
+
+def _key(text):
+    return re.sub(r"[\s\u3000]+", "", text)
+
+
+def _signature_field(text):
+    label, sep, value = re.sub(r"\s+", "", text).partition(":")
+    if not sep:
+        label, sep, value = re.sub(r"\s+", "", text).partition(":")
+    if not sep:
+        return None
+    if re.fullmatch(r"投标人.*代表.*签[字章].*", label):
+        role = "signature"
+    elif re.fullmatch(r"投标人[((]公章[))]", label):
+        role = "company"
+    elif label == "日期":
+        role = "date"
+    else:
+        return None
+    if re.fullmatch(r"[\s__年月日()()盖章签字]*", value):
+        value = ""
+    return role, value
+
+
+def clear_duplicate_signature_blocks(body, title_prefix_re):
+    """Keep the most complete equivalent three-line signature block per table.
+
+    Only complete, contiguous signature/company/date groups qualify. Never
+    discard conflicting filled values, pictures, fields or bookmarks.
+    """
+    groups = []
+    pending = []
+    active = False
+    removed = 0
+
+    def flush():
+        nonlocal removed
+        if len(groups) < 2:
+            return
+        # The later block belongs to the template. Keep its location/format;
+        # carry over earlier filled fields only when the template field is empty.
+        keeper = groups[-1]
+        for group in reversed(groups[:-1]):
+            if not all(not a[2] or not b[2] or a[2] == b[2]
+                       for a, b in zip(keeper, group)):
+                continue
+            for index, (old, new) in enumerate(zip(group, keeper)):
+                old_p, _, old_value = old
+                new_p, _, new_value = new
+                if old_value and not new_value:
+                    body.remove(old_p)
+                    position = list(body).index(new_p)
+                    body.remove(new_p)
+                    body.insert(position, old_p)
+                    keeper[index] = old
+                else:
+                    body.remove(old_p)
+                removed += 1
+
+    for element in list(body):
+        text = _text(element).strip()
+        field = _signature_field(text) if element.tag == W + "p" else None
+        style = element.find(W + "pPr/" + W + "pStyle")
+        style_value = style.get(W + "val", "") if style is not None else ""
+        heading = bool(re.match(r"(?:Heading|标题)\s*[1-9]$", style_value, re.I)
+                       or element.find(W + "pPr/" + W + "outlineLvl") is not None
+                       or (not field and title_prefix_re.match(text)))
+        if element.tag != W + "p" or heading:
+            flush()
+            groups = []
+            pending = []
+            active = element.tag == W + "tbl"
+            continue
+        if not active:
+            continue
+        # Standalone signature images between complete groups stay in place.
+        if not text and not pending and groups and any(
+            node.tag in {W + "drawing", W + "pict"} for node in element.iter()
+        ) and not any(node.tag in _STRUCTURAL - {W + "drawing", W + "pict"}
+                      for node in element.iter()):
+            continue
+        if any(node.tag in _STRUCTURAL for node in element.iter()):
+            flush()
+            groups = []
+            pending = []
+            active = False
+            continue
+        if not text:
+            continue
+        if field is None:
+            # Unique table notes between two complete blocks remain untouched.
+            pending = []
+            continue
+        role, value = field
+        expected = ("signature", "company", "date")[len(pending)]
+        if role != expected:
+            pending = []
+            if role != "signature":
+                continue
+        pending.append((element, role, value))
+        if len(pending) == 3:
+            groups.append(pending)
+            pending = []
+    flush()
+    return removed
+
+
+_PROJECT_FIELD = re.compile(
+    r"^(项目名称|项目编号|招标编号|招标项目编号|包号|包件号|包件名称|包名|"
+    r"服务内容|服务要求|服务期限)[::](.*)$"
+)
+
+
+def _covers(richer, shorter):
+    """Prove information containment using whole clauses, never fuzzy similarity.
+
+    A longer sentence can negate or change a shorter one. Only identical
+    clauses plus additional clauses count as strictly richer information.
+    """
+    richer, shorter = _key(richer), _key(shorter)
+    if richer == shorter:
+        return True
+    a, b = _PROJECT_FIELD.fullmatch(richer), _PROJECT_FIELD.fullmatch(shorter)
+    if a and b:
+        return a[1] == b[1] and bool(a[2]) and not b[2]
+    a = {part for part in re.split(r"[。;;\n]+", richer) if part}
+    b = {part for part in re.split(r"[。;;\n]+", shorter) if part}
+    return bool(b) and b < a and all(len(part) >= 12 for part in b)
+
+
+def clear_table_repetitions(body, title_prefix_re, protected_paragraphs=None):
+    """Keep richer postambles, preferring the later paragraph on equal content.
+
+    A new table replaces the comparison corpus, and a heading ends it. Notes
+    can only repeat an earlier postamble, not merely text inside a table.
+    Signature paragraphs are retained even when their wording is identical.
+    Survivors are protected from subsequent text-only LLM removal directives.
+    """
+    table_keys = set()
+    postamble = []
+    active = False
+    removed = 0
+    for element in body:
+        if element.tag == W + "tbl":
+            table_keys = set()
+            for unit in element.iter():
+                if unit.tag in {W + "p", W + "tc", W + "tr"}:
+                    key = _key(_text(unit))
+                    if len(key) >= 12:
+                        table_keys.add(key)
+            postamble = []
+            active = True
+            continue
+        if element.tag != W + "p":
+            table_keys.clear()
+            postamble.clear()
+            active = False
+            continue
+        text = _text(element).strip()
+        key = _key(text)
+        style = element.find(W + "pPr/" + W + "pStyle")
+        style_value = style.get(W + "val", "") if style is not None else ""
+        explicit_heading = bool(
+            re.match(r"(?:Heading|标题)\s*[1-9]$", style_value, re.I)
+            or element.find(W + "pPr/" + W + "outlineLvl") is not None
+        )
+        duplicate = len(key) >= 12 and (
+            any(_key(_text(p)) == key for p in postamble)
+            or (key in table_keys and not _NOTE.match(text))
+        )
+        if explicit_heading or (title_prefix_re.match(text) and not duplicate):
+            table_keys.clear()
+            postamble.clear()
+            active = False
+            continue
+        if not active:
+            continue
+        if protected_paragraphs is not None:
+            protected_paragraphs.add(element)
+        if not text or _SIGNATURE.match(text):
+            continue
+        if any(node.tag in _STRUCTURAL for node in element.iter()):
+            continue
+        if len(key) >= 12 and key in table_keys and not _NOTE.match(text):
+            for node in element.iter(W + "t"):
+                node.text = ""
+            removed += 1
+            continue
+        if len(key) < 12 and not _PROJECT_FIELD.fullmatch(key):
+            continue
+        # Choose a survivor before clearing anything: an earlier richer
+        # paragraph must not lose its extra clauses to a later shorter one.
+        richer = next((p for p in postamble if _covers(_text(p), text)
+                       and not _covers(text, _text(p))), None)
+        if richer is not None:
+            for node in element.iter(W + "t"):
+                node.text = ""
+            removed += 1
+            continue
+        for previous in list(postamble):
+            if _covers(text, _text(previous)):
+                for node in previous.iter(W + "t"):
+                    node.text = ""
+                postamble.remove(previous)
+                removed += 1
+        postamble.append(element)
+    return removed