Agent 总结 Word 前,先确定哪一份正文
同一份 Word 可以被读成允许访问、不允许访问或混合措辞。先保存修订证据,再确定摘要采用的正文范围。
1. 一个否定词消失以后
把 Word 文档交给 Agent 总结,最容易忽略的一步,是决定“这份文档的正文”到底指哪种修订状态。文件可以同时保存准备删除的旧措辞和刚插入的新措辞。先把所有段落拼成字符串,再让模型理解上下文,模型看到的可能已经是第三种说法。
本文用一份自行生成的 DOCX 复现这个差异:把 always 放进删除修订,把 not 放进插入修订。在 python-docx 1.2.0 中,第一段的 paragraph.text 得到 Access is permitted.。否定词消失,句子却很通顺。如果摘要据此写成“允许访问”,后续再加一句“请忠实原文”也找不回没有进入输入的词。
完成实验后,你会有一个短生成器、一份只读证据报告和十项回归测试。报告指出修订位于哪个部件、哪个段落,并把总结范围留给人确认。实验不调用模型、不接受或拒绝修订,也不尝试实现通用 Word 正文引擎。
2. 先看三种提取结果
这个段落按 XML 顺序包含普通文本 Access is 、删除容器中的 always 、插入容器中的 not ,最后是普通文本 permitted.。这些空格都属于实际文本。生成器用 xml:space="preserve" 保留两个修订词后面的空格,避免把空白问题和修订问题混在一起。
在这个特别简单的例子里,沿着变更前的措辞理解,会读到 Access is always permitted.;沿着拟议修改理解,会读到 Access is not permitted.。前者表达一直允许,后者表达不允许。这只是帮助理解样本的人工推演,后面的程序没有据此写出“原版”或“最终版”。
第一条结果把插入和删除的两个容器都跳过,留下“允许访问”。第二条结果选择所有后代 w:t,包括插入容器中的 not ,于是碰巧与本例的拟议措辞相同。第三条再把 w:delText 加进来,输出“always not”。多抓一种文本节点并没有自然恢复一个可信版本。
这组对照适合放在 Agent 的输入准备阶段测试。它暴露的是提取策略改变了输入含义,不涉及模型是否聪明、是否遵循指令。本文没有测试任何模型的摘要质量,也没有把一条 XPath 当作修订接受算法。
3. 把实验范围定在可以解释的结构里
实验在 Python 3.12.14、python-docx 1.2.0、lxml 6.1.1 的预装环境运行,无须在本次环境安装依赖。你自己的解释器也需要可用的这两个库。先运行下面的版本检查;如果模块不存在或版本不同,先处理环境或记录差异,不要把本页预期当作你当前环境已经通过的结果。
python -c "import sys, docx; from lxml import etree; print(sys.version); print(docx.__version__); print(etree.LXML_VERSION)"接下来在空目录保存三个完整源码文件。生成器只创建 controlled.docx;同名文件存在就拒绝覆盖。检查器只读这个文件,输出 JSON 到标准输出;测试在临时目录中独立生成样本。它们不依赖研究目录、隐藏输入、现成下载文件或服务器。
这是一份用 python-docx 建立包、再手工加入 OOXML 修订节点的受控 fixture。它没有经历真实用户在 Word 里开启修订、修改、审核的流程。本文的图全部是原创结构示意,不是软件截图。没有在 Word 或 LibreOffice 中打开或渲染样本,也没有执行完整 Open XML schema 验证。
4. 生成一份包含分歧的文档
把以下内容保存为 make_fixture.py。第一段承载实验的语义分歧,第二段附一条普通评论,接着加入一行两列表格、默认页眉页脚,以及一个直接设置为隐藏的 run。后几项用于说明总结输入的范围,不改变第一段的比较。
代码使用 p._p 手工追加节点,这是为了明确控制实验结构而使用的底层入口。它不代表 python-docx 提供了一套完整的高层修订编辑 API。修订 ID、作者和日期均为样本值,不能证明真实身份、审批或生效时间。
"""Create this tutorial's synthetic DOCX; never reads an existing user file."""
from pathlib import Path
from docx import Document
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
def make_fixture(path):
path = Path(path)
if path.exists():
raise FileExistsError(f"Refusing to replace {path}")
doc = Document()
p = doc.add_paragraph("Access is ")
for kind, text, revision_id in [("del", "always ", "1"), ("ins", "not ", "2")]:
revision = OxmlElement(f"w:{kind}")
for key, value in {"id": revision_id, "author": "Fixture Reviewer",
"date": "2026-01-01T00:00:00Z"}.items():
revision.set(qn(f"w:{key}"), value)
run = OxmlElement("w:r")
word = OxmlElement("w:delText" if kind == "del" else "w:t")
word.set(qn("xml:space"), "preserve")
word.text = text
run.append(word)
revision.append(run)
p._p.append(revision) # Deliberate OOXML fixture construction.
p.add_run("permitted.")
scope = doc.add_paragraph("Scope: test environment only.")
doc.add_comment(scope.runs, text="Question: should this also cover staging?",
author="Fixture Reviewer", initials="FR")
table = doc.add_table(rows=1, cols=2)
table.cell(0, 0).text = "Limit"
table.cell(0, 1).text = "10 requests"
doc.sections[0].header.paragraphs[0].text = "DRAFT - review pending"
doc.sections[0].footer.paragraphs[0].text = "Fixture footer"
p = doc.add_paragraph("Note: ")
p.add_run("INTERNAL-DRAFT-NOTE").font.hidden = True
doc.save(path)
return path
if __name__ == "__main__":
print(make_fixture("controlled.docx"))
5. 只读检查器保留位置和待确认问题
把以下内容保存为 inspect_fixture.py。程序先读取文件字节,计算 SHA-256,再从同一份字节快照打开 python-docx 和 ZIP/XML。因此报告中的三种读取结果对应同一个输入。末尾再次读取文件计算哈希,用来核对这次读取过程没有改变磁盘文件;它没有将文档重新保存。
每条修订证据包含部件名、主体直接子段落序号、XML 路径、修订类型、ID 和逐字文本。这里的“段落 1”是 w:body 的第一个直接子 w:p,不是页码,也不表示整份文档所有容器中的第一个段落。XML 路径依赖这份包的结构,文件经过编辑后可能变化,所以应与文件哈希一起使用。
检查范围故意很窄:只检查主体直接子段落中的直接 w:ins / w:del。没有发现这两种节点也不会放行“这是最终正文”,返回值仍是 scope_not_established。代码中的 XML 解析选项不能让它成为处理恶意 Office 文件的安全扫描器;此处只运行自己生成的可信样本。
"""Read-only evidence probe for the tutorial fixture, not a DOCX security scanner."""
import hashlib
import json
import platform
from io import BytesIO
from pathlib import Path
from zipfile import ZipFile
import docx
from docx import Document
from lxml import etree
NS = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
W = "{" + NS["w"] + "}"
def inspect_fixture(path):
path = Path(path)
data = path.read_bytes()
digest = hashlib.sha256(data).hexdigest()
document = Document(BytesIO(data))
with ZipFile(BytesIO(data)) as package:
root = etree.fromstring(package.read("word/document.xml"),
etree.XMLParser(resolve_entities=False, no_network=True))
body = root.find("w:body", NS)
first = body.find("w:p", NS)
paragraphs = body.findall("w:p", NS) # Only direct body paragraphs.
evidence = []
for ordinal, paragraph in enumerate(paragraphs, 1):
# Deliberately limited to direct inline revisions in these paragraphs.
for revision in paragraph.xpath("./w:del | ./w:ins", namespaces=NS):
evidence.append({
"part": "word/document.xml",
"body_paragraph_ordinal": ordinal,
"xml_path": root.getroottree().getpath(revision),
"kind": etree.QName(revision).localname,
"id": revision.get(W + "id"),
"author_metadata": revision.get(W + "author"),
"date_metadata": revision.get(W + "date"),
"text": "".join(revision.xpath(
".//w:t/text() | .//w:delText/text()", namespaces=NS)),
})
report = {
"fixture_only": True,
"sha256": digest,
"versions": {"python": platform.python_version(), "python-docx": docx.__version__,
"lxml": ".".join(map(str, etree.LXML_VERSION[:3]))},
"extraction_comparison": {
"paragraph_text": document.paragraphs[0].text,
"descendant_w_t": "".join(first.xpath(".//w:t/text()", namespaces=NS)),
"descendant_w_t_and_delText": "".join(first.xpath(
".//w:t/text() | .//w:delText/text()", namespaces=NS)),
},
"revision_evidence": evidence,
"body_paragraphs": [p.text for p in document.paragraphs],
"body_block_types": [type(b).__name__ for b in document.iter_inner_content()],
"table_cells": [[[c.text for c in row.cells] for row in table.rows]
for table in document.tables],
"comments": [{"id": c.comment_id, "text": c.text} for c in document.comments],
"default_headers": [[p.text for p in s.header.paragraphs] for s in document.sections],
"default_footers": [[p.text for p in s.footer.paragraphs] for s in document.sections],
"direct_hidden_runs": [{"body_paragraph_ordinal": i, "text": r.text,
"direct_hidden": r.font.hidden}
for i, p in enumerate(document.paragraphs, 1)
for r in p.runs if r.font.hidden is True],
"decision": "needs_human_revision_scope" if evidence else "scope_not_established",
"question": "Should the summary cover pre-change wording, proposed wording, or unresolved changes?",
"scan_scope": "Direct w:ins/w:del in direct body paragraphs only; absence is not clearance.",
"read_preserved_bytes": digest == hashlib.sha256(path.read_bytes()).hexdigest(),
}
return report
if __name__ == "__main__":
# Fixed filename keeps this example focused on its own generated fixture.
print(json.dumps(inspect_fixture("controlled.docx"), ensure_ascii=False, indent=2))
6. 从空目录运行并核对结果
把以下测试保存为 test_intake.py。测试明确约束三条输出、两项修订证据、主体顺序和其他内容入口,同时验证读后字节不变以及生成器拒绝覆盖。两项负例另建普通文档、或把样本修订移到表格单元格中,确认检查范围内未发现修订时仍不宣称正文已确认。十项测试围绕这些受控样本,不覆盖任意 DOCX。
"""Narrow regression tests; does not establish Word rendering or full OOXML conformance."""
import tempfile
import unittest
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
from make_fixture import make_fixture
from inspect_fixture import inspect_fixture
class IntakeTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.path = make_fixture(Path(self.tmp.name) / "controlled.docx")
self.before = self.path.read_bytes()
self.report = inspect_fixture(self.path)
def test_three_different_sentences(self):
self.assertEqual(self.report["extraction_comparison"], {
"paragraph_text": "Access is permitted.",
"descendant_w_t": "Access is not permitted.",
"descendant_w_t_and_delText": "Access is always not permitted.",
})
def test_revision_locations_and_words(self):
evidence = self.report["revision_evidence"]
self.assertEqual([(e["kind"], e["id"], e["text"]) for e in evidence],
[("del", "1", "always "), ("ins", "2", "not ")])
self.assertTrue(all(e["part"] == "word/document.xml" and
e["body_paragraph_ordinal"] == 1 for e in evidence))
self.assertEqual([e["xml_path"] for e in evidence],
["/w:document/w:body/w:p[1]/w:del", "/w:document/w:body/w:p[1]/w:ins"])
def test_body_order_and_table(self):
self.assertEqual(self.report["body_block_types"], ["Paragraph", "Paragraph", "Table", "Paragraph"])
self.assertEqual(self.report["table_cells"], [[["Limit", "10 requests"]]])
self.assertNotIn("10 requests", "\n".join(self.report["body_paragraphs"]))
def test_separate_parts(self):
self.assertEqual(self.report["comments"], [{"id": 0, "text": "Question: should this also cover staging?"}])
self.assertEqual(self.report["default_headers"], [["DRAFT - review pending"]])
self.assertEqual(self.report["default_footers"], [["Fixture footer"]])
def test_hidden_is_still_extracted(self):
self.assertEqual(self.report["body_paragraphs"][2], "Note: INTERNAL-DRAFT-NOTE")
self.assertEqual(self.report["direct_hidden_runs"], [{"body_paragraph_ordinal": 3,
"text": "INTERNAL-DRAFT-NOTE", "direct_hidden": True}])
def test_decision_does_not_select_final_text(self):
self.assertEqual(self.report["decision"], "needs_human_revision_scope")
self.assertNotIn("final_text", self.report)
def test_no_revision_is_not_clearance(self):
plain = Document()
plain.add_paragraph("An ordinary paragraph.")
path = Path(self.tmp.name) / "plain.docx"
plain.save(path)
report = inspect_fixture(path)
self.assertEqual(report["revision_evidence"], [])
self.assertEqual(report["decision"], "scope_not_established")
self.assertNotIn("final_text", report)
def test_table_revisions_outside_scan_do_not_clear(self):
# Move both synthetic nodes outside the documented scan scope.
document = Document(self.path)
paragraph = document.paragraphs[0]._p
cell_paragraph = document.tables[0].cell(0, 0).paragraphs[0]._p
for kind in ("del", "ins"):
revision = paragraph.find(qn("w:" + kind))
self.assertIsNotNone(revision)
cell_paragraph.append(revision)
path = Path(self.tmp.name) / "table-revisions.docx"
document.save(path)
report = inspect_fixture(path)
self.assertEqual(report["revision_evidence"], [])
self.assertEqual(report["decision"], "scope_not_established")
self.assertNotIn("final_text", report)
def test_read_only(self):
self.assertTrue(self.report["read_preserved_bytes"])
self.assertEqual(self.path.read_bytes(), self.before)
def test_generator_refuses_overwrite(self):
with self.assertRaises(FileExistsError):
make_fixture(self.path)
self.assertEqual(self.path.read_bytes(), self.before)
if __name__ == "__main__":
unittest.main(verbosity=2)
三个文件保存在同一目录后,依次执行:
python make_fixture.py
python inspect_fixture.py > report.json
python test_intake.py前两条会创建实验 DOCX 和报告。第三条不读取这个已生成文件,会在临时目录另建样本。在上述版本中,测试输出如下,耗时会随环境变化:
test_body_order_and_table (__main__.IntakeTests.test_body_order_and_table) ... ok
test_decision_does_not_select_final_text (__main__.IntakeTests.test_decision_does_not_select_final_text) ... ok
test_generator_refuses_overwrite (__main__.IntakeTests.test_generator_refuses_overwrite) ... ok
test_hidden_is_still_extracted (__main__.IntakeTests.test_hidden_is_still_extracted) ... ok
test_no_revision_is_not_clearance (__main__.IntakeTests.test_no_revision_is_not_clearance) ... ok
test_read_only (__main__.IntakeTests.test_read_only) ... ok
test_revision_locations_and_words (__main__.IntakeTests.test_revision_locations_and_words) ... ok
test_separate_parts (__main__.IntakeTests.test_separate_parts) ... ok
test_table_revisions_outside_scan_do_not_clear (__main__.IntakeTests.test_table_revisions_outside_scan_do_not_clear) ... ok
test_three_different_sentences (__main__.IntakeTests.test_three_different_sentences) ... ok
----------------------------------------------------------------------
Ran 10 tests in 0.496s
OK
下面是一次实际运行的完整 JSON。SHA-256 绑定本次生成的文件,重新生成时 ZIP 时间信息等因素可能让哈希不同;不要把示例哈希设成固定验收值。应核对三条句子、修订位置、各集合内容以及读前读后的相等关系。
{
"fixture_only": true,
"sha256": "c3d94f83a05e6377a71cf895666c883fa97ec57f388c4efe6bf0343fde541e9e",
"versions": {
"python": "3.12.14",
"python-docx": "1.2.0",
"lxml": "6.1.1"
},
"extraction_comparison": {
"paragraph_text": "Access is permitted.",
"descendant_w_t": "Access is not permitted.",
"descendant_w_t_and_delText": "Access is always not permitted."
},
"revision_evidence": [
{
"part": "word/document.xml",
"body_paragraph_ordinal": 1,
"xml_path": "/w:document/w:body/w:p[1]/w:del",
"kind": "del",
"id": "1",
"author_metadata": "Fixture Reviewer",
"date_metadata": "2026-01-01T00:00:00Z",
"text": "always "
},
{
"part": "word/document.xml",
"body_paragraph_ordinal": 1,
"xml_path": "/w:document/w:body/w:p[1]/w:ins",
"kind": "ins",
"id": "2",
"author_metadata": "Fixture Reviewer",
"date_metadata": "2026-01-01T00:00:00Z",
"text": "not "
}
],
"body_paragraphs": [
"Access is permitted.",
"Scope: test environment only.",
"Note: INTERNAL-DRAFT-NOTE"
],
"body_block_types": [
"Paragraph",
"Paragraph",
"Table",
"Paragraph"
],
"table_cells": [
[
[
"Limit",
"10 requests"
]
]
],
"comments": [
{
"id": 0,
"text": "Question: should this also cover staging?"
}
],
"default_headers": [
[
"DRAFT - review pending"
]
],
"default_footers": [
[
"Fixture footer"
]
],
"direct_hidden_runs": [
{
"body_paragraph_ordinal": 3,
"text": "INTERNAL-DRAFT-NOTE",
"direct_hidden": true
}
],
"decision": "needs_human_revision_scope",
"question": "Should the summary cover pre-change wording, proposed wording, or unresolved changes?",
"scan_scope": "Direct w:ins/w:del in direct body paragraphs only; absence is not clearance.",
"read_preserved_bytes": true
}
7. 为什么增加一个文本查询还不够
Microsoft 的 InsertedRun 文档将插入容器中的内容定义为被跟踪的插入修订;DeletedRun 文档说明删除修订,示例采用 w:delText 保存删除文字。这说明两个词的容器具有不同含义,不能只按它们在 XML 中出现的顺序拼接。
python-docx 的 Document API 文档另外明确指出,位于修订标记中的整段不会出现在 Document.paragraphs 列表里。本例不是把整个段落包在修订里,而是在普通段落里嵌入两项行内修订。这里关于 paragraph.text 的三个精确结果来自安装版本的实测,不能仅凭那条“整段不出现”的说明推导。
对于真实文件,移动、嵌套修订、段落标记删除等结构可能改变文本连接的含义。本文的 .//w:t 查询没有处理这些情形,连制表符、换行和字段含义都没有建立统一文本模型。恰好拿到一句像拟议正文的话,只说明这个样本中被选中的节点如此排列。
因此,检查器把 XPath 留在对照实验和证据收集里,没有提供名为 accept_all 或 final_text 的接口。若后续任务确实需要定稿文本,应使用经过验证的流程处理修订,并由负责人确认结果,或者直接取得负责人提供的定稿副本。
8. 其他内容入口需要单独说明
本例的 Document.paragraphs 给出三段主体文本,没有把表格单元格、评论、页眉或页脚拼进去。它们并非全部无法读取:报告分别使用 Document.tables、Document.comments 和 Document.sections 的页眉页脚对象取得内容。
Document.iter_inner_content() 在本例返回 Paragraph、Paragraph、Table、Paragraph,可保留这几个顶层主体块的顺序。如果你先收集全部段落,再把表格追加到末尾,就会丢掉表格原本夹在中间的位置。Document API 同时说明顶层表格集合不包含嵌套表格,修订标记中的表格也有遗漏范围;本例的一行表格不足以验证复杂表格读取。
评论里问的是“是否也应覆盖 staging”。它是一条审阅问题,没有被确认成正文里的适用范围。python-docx 评论说明介绍了独立的评论内容和锚点,并说明其初始实现不支持回复线程与已解决状态。因此,读到一条评论不能代替读取完整的现代审阅讨论,更不能把问题改写成已批准的要求。
页眉的 DRAFT - review pending 可以作为这份样本的上下文。报告只覆盖单节的默认页眉页脚。Section API 文档还列出首页、偶数页等变体及其条件。本例没有测试多节继承,也没有证明某个页眉会显示在哪一页。把读取入口分开保存,可以让摘要准备阶段明确选择哪些上下文需要进入输入。
9. 表格文字重复,先看合并来源
前面的一行两列表格,只验证了普通单元格能够读出。把相同的 [c.text for c in row.cells] 用到一张有合并的表里,会遇到另一种输入失真:一个来源占据多个网格位置时,文字会重复出现;两个独立单元格的文字也可能恰好相同。
这份补充另外生成两张小表,不改动前面的修订样本。第一张模拟地区产品数量表:North 纵向占两行,TOTAL 横向占两列,Alpha 与 Beta 的数量都为 7。我们要保存这些值的位置与来源,再由具体任务决定如何理解它们。
本次实测得到 12 个展开后的有单元格槽位、10 个不同内容来源、9 种字符串,三项都包括表头。North 与 TOTAL 各重复一次,却没有增加内容来源。两个 7 的 XML 路径不同,按字符串去重则会把其中一个丢掉。这三种计数都不能直接当作业务记录数,也不构成自动去重规则。
报告把来源字段明确命名为 content_origin_xml_path。它记录 row.cells 返回的内容来源节点,不是所有物理 w:tc 节点:第一张表实际有 11 个直接单元格节点,纵向合并的延续节点仍然存在,但读取内容会回到上方来源。XML 路径应与文件快照的 SHA-256 一起看,编辑文件后不能把它当成永久 ID。
10. 缺少首列时,空字符串补不回位置
第二张表用 Record / Metric / Value 作表头。有一行省略首列,保留的两个真实单元格依次是 Latency 和空字符串。直接把 row.cells 与三个表头做 zip,会得到 {"Record": "Latency", "Metric": ""}:指标被放进了记录列,Value 这个键消失了。
本例先读 grid_cols_before 与 grid_cols_after,把缺位记为 kind: omitted,把真实单元格记为 kind: cell。展示用的矩阵才用 null 代表缺位,真实空单元格仍是 ""。第三行是无缺位的对照;第四行省略末列,用来确认补位不能只处理左边。这里恢复的是列位置,没有推断多级表头、继承含义或业务记录。
11. 运行独立的表格生成与只读诊断
2026-10-13 的补充实测环境为 Python 3.12.14、python-docx 1.2.0、lxml 6.1.1。先运行前文的版本检查,再把下面三个完整文件放在同一空目录。可复制代码,也可下载源码包;包里还保留了前文修订实验的三个文件,位于 original-revisions/。
保存第一份为 table_grid_probe.py。fixture() 在内存中生成 DOCX ZIP 字节;诊断从同一份字节读取两张表,输出 JSON,不接收用户文件,也不把文档重新保存。固定包内时间只是让这份样本在当前依赖版本下可重复,不保证跨版本字节一致。
官方 Table objects 文档列出了 cells、grid_cols_before、grid_cols_after 和 grid_span。本例另外使用 _tc、_tr、_element 等私有入口来构造缺位、记录来源和比较 XML;这些入口只用于版本固定的诊断,不是稳定 API 承诺。查阅当日官方页面标为 1.2.0,本次实际安装版本也为 1.2.0。
"""Controlled python-docx 1.2.0 table evidence experiment.
No user files, model, network, Word rendering, or general DOCX parser.
"""
import hashlib
import io
import json
import platform
import zipfile
from datetime import datetime, timezone
import docx
from docx import Document
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from lxml import etree
def snapshot(document):
"""Stable package for this fixture/version, not arbitrary DOCX canonicalization."""
document.core_properties.created = datetime(2026, 1, 1, tzinfo=timezone.utc)
document.core_properties.modified = datetime(2026, 1, 1, tzinfo=timezone.utc)
raw = io.BytesIO()
document.save(raw)
output = io.BytesIO()
with zipfile.ZipFile(raw) as source, zipfile.ZipFile(output, "w") as target:
for name in sorted(source.namelist()):
entry = zipfile.ZipInfo(name, (2026, 1, 1, 0, 0, 0))
entry.compress_type = zipfile.ZIP_STORED
target.writestr(entry, source.read(name))
return output.getvalue()
def fixture():
document = Document()
merged = document.add_table(rows=4, cols=3)
values = [
["Region", "Product", "Units"],
["", "Alpha", "7"],
["", "Beta", "7"],
["", "", "14"],
]
for row, texts in zip(merged.rows, values):
for cell, text in zip(row.cells, texts):
cell.text = text
merged.cell(1, 0).merge(merged.cell(2, 0)).text = "North"
merged.cell(3, 0).merge(merged.cell(3, 1)).text = "TOTAL"
omitted = document.add_table(rows=4, cols=3)
values = [
["Record", "Metric", "Value"],
["discard", "Latency", ""],
["r-2", "Latency", "20"],
["r-3", "Latency", "discard"],
]
for row, texts in zip(omitted.rows, values):
for cell, text in zip(row.cells, texts):
cell.text = text
# Controlled low-level fixture construction; these cells are absent, not empty.
for row_index, edge, tag in [(1, 0, "gridBefore"), (3, -1, "gridAfter")]:
row = omitted.rows[row_index]
row._tr.remove(row._tr.tc_lst[edge])
prop = OxmlElement("w:" + tag)
prop.set(qn("w:val"), "1")
row._tr.get_or_add_trPr().append(prop)
return snapshot(document)
def table_evidence(table):
"""Record expanded slots and their content-origin XML nodes.
Deliberately not a general physical-cell map: vertical continuation <w:tc>
nodes can resolve to an origin in an earlier row. Private _tc is used only
as a version-pinned diagnostic, and paths are valid only for this snapshot.
"""
grid_width = len(table.columns)
rows = []
for row_number, row in enumerate(table.rows):
cells = tuple(row.cells)
before, after = row.grid_cols_before, row.grid_cols_after
if min(before, after) < 0 or grid_width < 1 or before + len(cells) + after != grid_width:
raise ValueError("fixture grid shape outside supported experiment")
slots = []
for column in range(before):
slots.append({"column": column, "kind": "omitted"})
for index, cell in enumerate(cells, before):
slots.append({
"column": index,
"kind": "cell",
"text": cell.text,
"content_origin_xml_path": cell._tc.getroottree().getpath(cell._tc),
"horizontal_grid_span": cell.grid_span,
})
for column in range(grid_width - after, grid_width):
slots.append({"column": column, "kind": "omitted"})
rows.append({
"row": row_number, "grid_cols_before": before, "grid_cols_after": after,
"row_cells_text": [cell.text for cell in cells], "slots": slots,
})
return rows
def report():
data = fixture()
before_hash = hashlib.sha256(data).hexdigest()
document = Document(io.BytesIO(data))
xml_before = etree.tostring(document._element)
merged, omitted = [table_evidence(t) for t in document.tables]
occurrences = [s for r in merged for s in r["slots"] if s["kind"] == "cell"]
paths = {s["content_origin_xml_path"] for s in occurrences}
text_values = {s["text"] for s in occurrences}
headers = omitted[0]["row_cells_text"]
aligned_text = [
[s["text"] if s["kind"] == "cell" else None for s in row["slots"]]
for row in omitted
]
return {
"fixture_only": True,
"versions": {"python": platform.python_version(), "python-docx": docx.__version__,
"lxml": ".".join(map(str, etree.LXML_VERSION[:3]))},
"sha256": before_hash,
"document_xml_unchanged_by_inspection": xml_before == etree.tostring(document._element),
"decision": "table_scope_still_needs_review",
"merged": merged,
"counts": {"expanded_slots": len(occurrences),
"distinct_content_origins": len(paths),
"distinct_text_values": len(text_values)},
"omitted": omitted,
"omitted_aligned_text": aligned_text,
"naive_header_zip_row_1": dict(zip(headers, omitted[1]["row_cells_text"])),
"aligned_header_mapping_row_1": dict(zip(headers, aligned_text[1])),
}
def summary(result):
"""Small stable teaching output; the full report retains every slot/path."""
return {
"counts": result["counts"],
"merged_text_rows": [r["row_cells_text"] for r in result["merged"]],
"omitted_aligned_text": result["omitted_aligned_text"],
"naive_header_zip_row_1": result["naive_header_zip_row_1"],
"aligned_header_mapping_row_1": result["aligned_header_mapping_row_1"],
"document_xml_unchanged_by_inspection": result["document_xml_unchanged_by_inspection"],
"decision": result["decision"],
}
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--summary", action="store_true")
args = parser.parse_args()
result = report()
print(json.dumps(summary(result) if args.summary else result,
ensure_ascii=False, indent=2))
宽度条件只检查“行首缺位数 + 展开单元格数 + 行尾缺位数”是否等于表格网格宽度,并拒绝负缺位数。后面的负例会删掉一个单元格却不声明缺位,确认它抛错而非继续猜列。这个条件不足以验证任意 OOXML 文档。
12. 用固定预期区分重复与错位
保存第二份为 test_table_grid.py。预期行、计数、列号和来源路径都写成固定值,不从被测函数回填答案。15 项测试也核对了原始 XML 中的纵向延续节点、真实空单元格、普通行对照和只读状态。
"""Literal oracles for the small synthetic fixture, not a parser conformance suite."""
import io
import unittest
import zipfile
from docx import Document
from lxml import etree
from table_grid_probe import fixture, report, summary, table_evidence
EXPECTED = {
"counts": {"expanded_slots": 12, "distinct_content_origins": 10,
"distinct_text_values": 9},
"merged_text_rows": [["Region", "Product", "Units"], ["North", "Alpha", "7"],
["North", "Beta", "7"], ["TOTAL", "TOTAL", "14"]],
"omitted_aligned_text": [["Record", "Metric", "Value"], [None, "Latency", ""],
["r-2", "Latency", "20"], ["r-3", "Latency", None]],
"naive_header_zip_row_1": {"Record": "Latency", "Metric": ""},
"aligned_header_mapping_row_1": {"Record": None, "Metric": "Latency", "Value": ""},
"document_xml_unchanged_by_inspection": True,
"decision": "table_scope_still_needs_review",
}
class TableGridTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.result = report()
def test_fixed_summary(self):
self.assertEqual(summary(self.result), EXPECTED)
def test_package_repeats_byte_for_byte_here(self):
self.assertEqual(fixture(), fixture())
def test_horizontal_merged_text_repeats(self):
row = self.result["merged"][3]
self.assertEqual(row["row_cells_text"], ["TOTAL", "TOTAL", "14"])
self.assertEqual(row["slots"][0]["content_origin_xml_path"],
row["slots"][1]["content_origin_xml_path"])
self.assertEqual([s["horizontal_grid_span"] for s in row["slots"]], [2, 2, 1])
def test_vertical_merged_text_resolves_to_origin(self):
a, b = [self.result["merged"][r]["slots"][0] for r in (1, 2)]
self.assertEqual([a["text"], b["text"]], ["North", "North"])
self.assertEqual(a["content_origin_xml_path"], b["content_origin_xml_path"])
def test_equal_text_does_not_mean_same_cell(self):
a, b = [self.result["merged"][r]["slots"][2] for r in (1, 2)]
self.assertEqual([a["text"], b["text"]], ["7", "7"])
self.assertNotEqual(a["content_origin_xml_path"], b["content_origin_xml_path"])
def test_counts_are_different_objects(self):
self.assertEqual(self.result["counts"], EXPECTED["counts"])
def test_omitted_leading_cell_is_not_an_empty_cell(self):
slots = self.result["omitted"][1]["slots"]
self.assertEqual(slots[0], {"column": 0, "kind": "omitted"})
self.assertEqual(slots[2]["kind"], "cell")
self.assertEqual(slots[2]["text"], "")
self.assertEqual(self.result["omitted_aligned_text"][1], [None, "Latency", ""])
def test_leading_omission_shifts_naive_mapping(self):
self.assertEqual(self.result["naive_header_zip_row_1"],
{"Record": "Latency", "Metric": ""})
self.assertEqual(self.result["aligned_header_mapping_row_1"],
{"Record": None, "Metric": "Latency", "Value": ""})
def test_trailing_omission_preserved(self):
self.assertEqual(self.result["omitted_aligned_text"][3], ["r-3", "Latency", None])
self.assertEqual(self.result["omitted"][3]["grid_cols_after"], 1)
def test_uniform_negative_control_has_no_alignment_shift(self):
row = self.result["omitted"][2]
self.assertEqual(row["row_cells_text"], self.result["omitted_aligned_text"][2])
self.assertEqual([row["grid_cols_before"], row["grid_cols_after"]], [0, 0])
def test_malformed_width_stops_instead_of_guessing(self):
document = Document(io.BytesIO(fixture()))
table = document.tables[1]
table.rows[2]._tr.remove(table.rows[2]._tr.tc_lst[0])
with self.assertRaisesRegex(ValueError, "grid shape"):
table_evidence(table)
def test_diagnostic_keeps_review_state(self):
self.assertTrue(self.result["document_xml_unchanged_by_inspection"])
self.assertEqual(self.result["decision"], "table_scope_still_needs_review")
def test_grid_column_indices_are_preserved(self):
for table in ("merged", "omitted"):
for row in self.result[table]:
self.assertEqual([s["column"] for s in row["slots"]], [0, 1, 2])
def test_literal_origin_paths(self):
prefix = "/w:document/w:body/w:tbl[1]"
expected = [
["/w:tr[1]/w:tc[1]", "/w:tr[1]/w:tc[2]", "/w:tr[1]/w:tc[3]"],
["/w:tr[2]/w:tc[1]", "/w:tr[2]/w:tc[2]", "/w:tr[2]/w:tc[3]"],
["/w:tr[2]/w:tc[1]", "/w:tr[3]/w:tc[2]", "/w:tr[3]/w:tc[3]"],
["/w:tr[4]/w:tc[1]", "/w:tr[4]/w:tc[1]", "/w:tr[4]/w:tc[2]"],
]
actual = [[s["content_origin_xml_path"] for s in r["slots"]]
for r in self.result["merged"]]
self.assertEqual(actual, [[prefix + p for p in row] for row in expected])
def test_raw_vertical_node_is_not_content_origin(self):
with zipfile.ZipFile(io.BytesIO(fixture())) as package:
root = etree.fromstring(package.read("word/document.xml"))
ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
tables = root.xpath("./w:body/w:tbl", namespaces=ns)
cells = tables[0].xpath("./w:tr/w:tc", namespaces=ns)
self.assertEqual(len(cells), 11) # Includes the vertical continuation node.
continuation = tables[0].xpath("./w:tr[3]/w:tc[1]", namespaces=ns)[0]
self.assertEqual(len(continuation.xpath("./w:tcPr/w:vMerge", namespaces=ns)), 1)
self.assertNotEqual(root.getroottree().getpath(continuation),
self.result["merged"][2]["slots"][0]["content_origin_xml_path"])
if __name__ == "__main__":
unittest.main(verbosity=2)
运行下面三条命令。第一条是容易逐项核对的摘要,第二条保留完整槽位和 XML 来源路径,第三条运行断言。
python -B table_grid_probe.py --summary
python -B table_grid_probe.py > table-report.json
python -B -m unittest -v test_table_grid固定摘要如下;table-report.json 还包含实际版本与输入 SHA-256。缺位对齐前后的两个映射故意都保留,方便看见错误如何发生。
{
"counts": {"expanded_slots": 12, "distinct_content_origins": 10, "distinct_text_values": 9},
"merged_text_rows": [
["Region", "Product", "Units"],
["North", "Alpha", "7"],
["North", "Beta", "7"],
["TOTAL", "TOTAL", "14"]
],
"omitted_aligned_text": [
["Record", "Metric", "Value"],
[null, "Latency", ""],
["r-2", "Latency", "20"],
["r-3", "Latency", null]
],
"naive_header_zip_row_1": {"Record": "Latency", "Metric": ""},
"aligned_header_mapping_row_1": {"Record": null, "Metric": "Latency", "Value": ""},
"document_xml_unchanged_by_inspection": true,
"decision": "table_scope_still_needs_review"
}
本次 15 项测试全部通过,连续两次完整报告逐字节相同。原有修订实验的 10 项测试也重新通过;这没有扩大任一实验的验证范围。
13. 把错误实现真正跑一遍
只展示正确输出,可能掩盖测试并不敏感。第三份 mutation_check.py 会在临时目录中改写诊断源码,再运行同一套固定断言。它依次把槽位数当来源数、用字符串作身份、把缺位变成空串、直接配对表头、关闭宽度检查、提前宣告可总结,以及按字符串去重。每个错误版本都必须触发指定测试的失败;语法或导入错误不算成功拦截。
"""Run the actual regression suite against deliberately wrong source variants."""
import json
import subprocess
import sys
import tempfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
# Each edit has a named assertion it must break; syntax/import failures do not count.
MUTATIONS = [
("slots_as_origins", '"distinct_content_origins": len(paths)',
'"distinct_content_origins": len(occurrences)', "test_counts_are_different_objects"),
("text_as_identity", 'cell._tc.getroottree().getpath(cell._tc)',
'"text:" + cell.text', "test_equal_text_does_not_mean_same_cell"),
("omitted_as_empty", 's["text"] if s["kind"] == "cell" else None',
's["text"] if s["kind"] == "cell" else ""', "test_omitted_leading_cell_is_not_an_empty_cell"),
("naive_header_zip", 'dict(zip(headers, aligned_text[1]))',
'dict(zip(headers, omitted[1]["row_cells_text"]))', "test_leading_omission_shifts_naive_mapping"),
("no_width_guard", 'if min(before, after) < 0 or grid_width < 1 or before + len(cells) + after != grid_width:',
'if False:', "test_malformed_width_stops_instead_of_guessing"),
("premature_approval", '"decision": "table_scope_still_needs_review"',
'"decision": "ready_to_summarize"', "test_diagnostic_keeps_review_state"),
("string_dedup", 'paths = {s["content_origin_xml_path"] for s in occurrences}',
'occurrences = list({s["text"]: s for s in occurrences}.values())\n paths = {s["content_origin_xml_path"] for s in occurrences}',
"test_counts_are_different_objects"),
]
def run_suite(directory):
return subprocess.run([sys.executable, "-B", "-m", "unittest", "-v", "test_table_grid"],
cwd=directory, capture_output=True, text=True, timeout=60)
def main():
source = (HERE / "table_grid_probe.py").read_text(encoding="utf-8")
tests = (HERE / "test_table_grid.py").read_text(encoding="utf-8")
results = []
with tempfile.TemporaryDirectory() as temp:
folder = Path(temp)
(folder / "test_table_grid.py").write_text(tests, encoding="utf-8")
(folder / "table_grid_probe.py").write_text(source, encoding="utf-8")
baseline = run_suite(folder)
if baseline.returncode:
raise RuntimeError("Baseline failed:\n" + baseline.stderr)
for name, old, new, test in MUTATIONS:
if source.count(old) != 1:
raise RuntimeError("Mutation anchor changed: " + name)
(folder / "table_grid_probe.py").write_text(source.replace(old, new), encoding="utf-8")
run = run_suite(folder)
killed = run.returncode != 0 and "FAIL: " + test + " " in run.stderr
if not killed:
raise RuntimeError("Expected assertion did not reject " + name + ":\n" + run.stderr)
results.append({"mutation": name, "rejected_by": test})
print(json.dumps({"baseline": "15 tests passed", "rejected_mutants": results}, indent=2))
if __name__ == "__main__":
main()
python -B mutation_check.py本次输出为 baseline: 15 tests passed,rejected_mutants 中列出七个错误版本及其失败断言。普通行无需补位的对照仍然通过,说明实验检查的是合并来源与缺位,而不是要求所有行都制造差异。
14. 位置证据仍然不是批准后的正文
给 Agent 准备表格输入时,可以先保留三样具体证据:每个槽位的行列位置、它是缺位还是单元格、单元格文字来自哪个快照内的 XML 节点。等任务确实要求按产品汇总、解释合并表头或展开分组时,再确定业务规则。不要在读取阶段把“相同字符串”直接改写成“重复记录”。
本补充只验证自己生成的两张表中的普通单元格、简单横向与纵向合并、行首与行尾缺位。它没有测试嵌套表格、单元格修订或内容控件、多级表头、RTL 表格、旧式 hMerge、任意畸形文件、安全扫描或跨版本矩阵。没有使用真实用户文档,没有 Word/LibreOffice 渲染、完整 schema 验证、模型摘要评测,也没有接受或拒绝修订。
因此报告始终保留 decision: table_scope_still_needs_review。表格位置保住了,前文“摘要采用哪一种修订状态”的问题仍要另外回答。
截至 2026-10-13 查阅的依据:Working with Tables解释布局网格、合并展开和首尾缺位;Table objects给出本例相关的文档化属性;1.2.0 显示源码中的 _Row.cells显示横向按跨度返回、纵向延续回溯来源的实现。本页具体数值和错误对照来自上述合成实验。
15. 隐藏属性不等于可以猜出作者意图
最后一段通过 run.font.hidden = True 设置直接隐藏属性,重新打开后 paragraph.text 仍然返回 Note: INTERNAL-DRAFT-NOTE。报告将该 run 单列为 direct_hidden_runs,避免无声地把它当成普通句子的一部分。
Microsoft 的 Vanish 文档讨论的是显示时是否隐藏,并指出应用可以强制显示隐藏文字;有效值还可能来自样式层级。本文只测试直接设为 True 的 run,没有计算样式继承后的有效隐藏状态。
这些证据不足以推断“作者不想让你读到它”,也不足以断言所有阅读器都不显示它。总结要求若涉及屏幕可见文字,需要在指定阅读器和显示设置下另行验证。这里能可靠表达的是:提取获得了这段文字,同时它带有直接隐藏属性。
16. 把报告转成一个可以回答的问题
发现本例的两项修订以后,可以向文档负责人说明:“文件第一段将 always 标记为删除,将 not 标记为插入。你希望摘要描述修改前的措辞、拟议修改后的措辞,还是仍未决定的修改本身?”这三个选择会产生不同的摘要范围,应当在生成摘要前确认。
如果选择总结未决变更,可以据证据写出:“第一段提议将一直允许访问改成不允许访问,样本仍保留删除和插入记录。”这里描述的是修改内容。文件内存在作者、日期或修订 ID,不足以说明这项变更已经批准。
如果选择总结拟议措辞,后续输入应明确标为拟议状态,并按批准的范围准备文本。若用户要的是一份对外生效的定稿摘要,仅仅选择“拟议”仍不够,需要负责人确认修订处置或提供定稿文件。本例不会自动改写包、删除修订节点或覆盖原文件。
给 Agent 的上下文可以包括报告的文件哈希、定位信息、两段被修改的词,以及已经确认的总结范围。表格、评论和页眉页脚是否纳入,应根据具体摘要任务确定。这样回看一句结论时,能分清它来自哪段材料、采用了哪种范围,以及哪里仍然需要人决定。
17. 已验证的结果与尚未覆盖的文件
截至 2026 年 10 月 7 日,本地实测确认三种提取字符串不同,十项测试通过,检查器读前读后文件字节一致。完整源码已经在本页展开,配套 ZIP 只包含本文、草稿记录、源码、说明和三幅原创 SVG,没有用户文档或研究笔记。
没有验证真实 Word 或 LibreOffice 的显示、接受修订、拒绝修订或分页行为,没有做全量 schema 验证。移动和嵌套修订、段落标记修订、行列修订、格式修订、文本框、内容控件、脚注尾注、链接、字段、样式继承、多节页眉继承和现代评论均不在本例验证范围。也没有执行宏、外链、恶意文件测试或模型摘要评测。
当遇到这些结构,当前报告不能证明修订已经全部发现。这个实验的交付点很具体:先证明同一文件会被几种提取规则读成不同意思,再把原措辞、拟议措辞和未决变更的选择明确交回给负责确定范围的人。依靠一份流畅字符串直接宣称“这就是最终正文”,超出了本例证据。
paragraph.text 得到的是接受修订后的正文吗?
本例不是。它同时跳过删除的 always 和插入的 not,得到 Access is permitted.。不能由此定义最终正文。
遍历所有 w:t 就能得到最终版吗?
本例恰好得到拟议措辞,但该查询没有处理通用修订、字段和其他结构,不能作为通用最终文本算法。
检测不到修订是否可以直接总结?
本检查器只检查主体直接子段落中的直接插入和删除节点。未发现仍返回范围未确立,不能为任意 DOCX 放行。
python-docx 能否读取评论和页眉页脚?
能使用相应 API 读取本例内容。它们不在 Document.paragraphs 中;现代评论、显示条件和继承问题不属于本例验证范围。