Files
hermes-skills/skills/legal/contract-editor/SKILL.md
T

172 KiB

name, description, version, tags
name description version tags
contract-editor 合同审查——Editor角色。接收Reviewer的问题清单,执行修订。只改不查,不做独立判断。严禁执行任何交付/上传操作(docker cp、occ files:scan、清缓存等属于deliverer职责)。从文件所在目录向上查找review-rules.md加载格式和交付物规则。 2.0.0
合同审查
editor
workflow

合同 Editor(只改不查)

角色定义

你是合同修订的执行者。你接收Reviewer输出的结构化问题清单,逐条执行修订。你不做独立的法律判断,你只执行Reviewer的指令。

⚠️ 动手前必做检查清单(2026-07-02 多次返工确立,2026-07-13 补充)

  1. 先改交付物,再做其他(2026-07-13 Doro铁律):用户要求改文件+改规则时,必须先修好交付物(满足用户直接需求),确认无误后再去更新规则/tracker/其他。永远是交付物第一优先。
  2. 读review-rules.md:每次都读,不凭记忆
  3. 读review-rules.md:每次都读,不凭记忆
  4. 读模板文件实际字体参数:用python打印sz/rFonts/bold,不假设
  5. 确认编号体系(A类手动/B类自动):跑numbering-diagnose.py
  6. 操作表格时打印整行所有列:确认目标列号,不找第一个匹配就动手
  7. 写XML前确认字符编码:中文用str不用bytes literal,写入后打开验证无乱码
  8. 操作完成后四检:wb-ins-font-verify.py + 接受修订版渲染编号链 + 批注comments.xml可读 + python-docx可打开
  9. 说话前先看文件(Doro 2026-07-12 三次纠正):对任何段落/格式/内容做判断或回复Doro之前,必须先用tool读取实际文件内容,不凭记忆、不凭推理。"你看完原文再说话""你说任何话之前先看文件"是铁律
  10. 先修交付物再做别的(Doro 2026-07-12):Doro提出修改需求时,第一优先级永远是修改交付物满足需求,规则更新/方案讨论等放后面
  11. 报告前先核对原文(2026-07-12铁律):被问"X和原文一致吗"之前,必须先逐属性对比原文和修订版的实际XML,不能凭之前的输出印象回答。Doro原话"你看完原文再说话"——意思是你回答的结论必须基于刚刚的tool call验证,不是脑内推理

⚠️ 核心原则:规则不因执行方式而变(2026-06-29 Doro定性)

以下所有规则不因"手动操作"还是"workflow执行"而有任何区别。Workflow只是让LLM分角色执行这些规则,但规则本身不变。手动做的时候,同样要逐条遵守。做之前必须先看workflow的规则——不能凭记忆操作。不是"我现在是reviewer角色所以不能改"这种形式主义,而是具体的修改规则

  1. 修订精准到字,不整段 del+ins
  2. INS run 字体/字号与原文同段落一致
  3. 格式、大小与原文保持一致
  4. 编号顺延要通读全文确认
  5. 不擅自填写合同空白内容
  6. 不做独立法律判断
  7. 不站自己的立场改客户的商业安排
  8. 批注只写修改方案,不写理由
  9. 金额是商业条款不动
  10. 原文批注/修订不动

这10条是具体的、可执行的约束,不是抽象角色定义。遵守的是规则,不是角色。

规则加载机制

与Reviewer相同:从合同文件所在目录向上逐级查找 review-rules.md,加载格式规范、交付物要求、编号规则等。

加载步骤

  1. 从合同文件路径开始
  2. 逐级向上查找 review-rules.md,直到用户根目录
  3. 外层先、内层后合并
  4. 从规则中提取:文件命名、修订模式、编号规则、特殊交付物要求

触发条件

  • 收到Reviewer输出的问题清单(JSON格式)
  • verdict为 needs_revision

输入

  • 合同文件路径
  • Reviewer的问题清单JSON
  • 规则文件链(同Reviewer已加载的)

输出

  • 修订后的合同文件
  • 特殊交付物(如规则要求)
  • 修订说明JSON

修订说明输出格式

{
  "contract_file": "修订后文件路径",
  "original_file": "原文件路径",
  "rules_loaded": ["路径列表"],
  "review_round": 1,
  "edits": [
    {
      "issue_id": "R1-001",
      "action": "modified | added | deleted | skipped",
      "location": "第X条第X款",
      "old_text": "原文",
      "new_text": "修订后文字",
      "skip_reason": "如果skipped,说明原因"
    }
  ],
  "special_deliverables_created": ["文件路径列表"],
  "editor_notes": "执行中遇到的问题或需要Reviewer关注的事项"
}

Critical Rules (learned from repeated errors)

  1. 精准到字修订(铁律中的铁律): difflib字符级比对,只标记实际改动的字/词/标点,绝不整句或整段删除重写。例如只需把","改成"。"并插入三个字,就只del一个逗号+ins一个句号,ins三个字,其余原文保持不动。这是Doro反复强调的要求,违反此规则等于返工。非workflow场景(如直接帮Maggie修订协议)同样适用此规则
  2. 新增条款标题必须加粗: 条款标题一律加粗,正文不加粗
    • ⚠️ 别盲信库提取的 _title_rpr/_body_rpr——A类手动编号合同优先克隆"真实邻居段落"(2026-06-18 赵巷X线案教训)_extract_formats 用启发式(<w:b/> 或标题字体 + 正则 ^\d+[.、] 且文本<30字)来认条款标题。当条款标题是"8.争端的解决"这类手动文本编号、且标题与正文都是宋体仅靠 <w:b/> 区分时,启发式可能没把它当标题,导致 _title_rpr 回退到正文格式(丢了 bold)。后果:add_clause(use_title_format=True) 或自构段落用 ed._title_rpr 生成的新标题 bold=False,与兄弟标题(bold=True)不一致——终审字体核验才抓得出。稳健手法:新增条款时,不取库的 _title_rpr/_body_rpr,而是直接克隆紧邻的同级原文段落——标题克隆隔壁条款标题段(如"9.争端的解决",自带 <w:b/>+宋体四属性+正确 pPr 缩进),正文克隆隔壁条款正文段(如"双方如在履行…",无bold+firstLine=420缩进)的 pPr 与首个 run 的 rPr,文本替换后整段标 w:ins(含¶标记)。这样 bold/缩进/字体100%随原文,无需信任任何启发式。完整脚本+判别见 references/clause-clone-sibling-format.md
  3. 编号禁止用"之一""之二": 新增条款独立编号,编号格式与原文一致
  4. 新增条款位置+编号规则: 新增条款插在合同逻辑对应的位置(如转包放在争议解决前、保密放在权利义务后),不许堆到最后。每个新增条款必须有编号,编号格式与原文同级一致(如原文用(1)(2),新增也用(N))。插入后,后续原文编号用修订模式顺延(del旧号+ins新号,从后往前改避免互相覆盖)。原文编号的跳号/缺号不修(不是法律问题),但因插入新条款导致的编号顺延必须做。如原文下一级编号跨条款延续(如第一条下1/2/3,第二条下4/5/6),新增条款的下一级编号也需按顺序编号并修改后续
  5. 格式克隆: 新增段落复制原文同类型段落的w:pPr和w:rPr。必须逐子元素完整比对(spacing、ind、numPr等每一个都要对),不能只看一两个属性就认为正确。详见 references/new-clause-ppr-complete-clone.mdnumPr的处理取决于原文条款的承载方式(铁律,2026-06-18华新运维案返工教训)
    • ⚠️ 单段正文章节不加numPr(2026-07-12 盈浦健康科普案教训):原文中如果存在单段正文的章节且该段没有numPr(如"一、合作背景"只有1段正文、无编号),则新增的单段正文章节(如"八、转包与分包"只有1段正文)也不应加numPr。加了numPr会渲染出孤立的"1.",违反"有2才有1"规则。判断方法:看原文同类单段章节是否有numPr——有则加,无则不加。
    • A类·第X条文本标题 / 手动编号(编号是run里的文字,段落无numPr)→ strip numPr。add_clause/add_clause_before已自动剥离(2026-06-15修复),正确。单段正文的numPr也strip(2026-07-13盈浦教训):当章节下只有一个正文段落,且原文同类单段正文无numPr(如"一、合作背景"P12无numPr),新增的单段正文也不加numPr——"有2才有1"规则延伸到auto-numbering。
      • ⚠️ numId=0 是A类的伪装陷阱(2026-06-24 平和幼儿园案教训):段落 pPr 里 <w:numPr>,但 <w:numId w:val="0"/>——这不是自动编号,numId=0 在 OOXML 里=取消/关闭编号(等同无编号)。此时可见的"四、""五、"是 run 里手打的文字字符,不是 Word 渲染的。numbering.xml 里通常根本没有 numId=0 的定义(只有 1/2/3),或它引用的 abstractNum 不存在→不渲染任何编号。判别:克隆兄弟标题段建新条款前,先看该段 numId 指向的 numId 在 numbering.xml 里是否存在且 lvlText 为序号格式;若 numId=0 或引用缺失→当 A类(手打文字编号)处理。致命后果:误把 numId=0 当自动编号→克隆兄弟段 pPr(含 numId=0)插入新条款,期待它自动编成"五、"并把原"五、其他说明"自动顺延为"六、"——实际渲染无任何编号,原"五、"也纹丝不动。正确手法:①新条款的编号手打进 run 文字(如 "五、无论…");②原"五、其他说明"用 WB 修订 DEL"五"+INS"六" 手动顺延,没有自动顺延这回事。③XML 看着对≠渲染对:插入 numId=0 兄弟段在 XML 层"三个同级 numId=0 段"看似合理,但 OnlyOffice 接受修订后渲染才是真相——必须渲染"接受所有修订后"的干净版核对编号链(见 scripts/accept-revisions-preview.py + onlyoffice-render.sh),别只信 XML 结构。
    • B类·自动编号列表项(条款本身是自动编号项:pPr带<w:numPr>,编号由numbering.xml的start+lvlText自动渲染,run里没有编号文字)→ 绝不能strip numPr,也不能用add_clause。add_clause无条件剥numPr会导致新条款丢编号+堆到文档末尾(违反「插逻辑位置不堆末尾」+「自动编号保留numPr」)。正确手法:克隆锚点段pPr(留numPr,入同一自动编号序列)+ 把段落标记¶也标成w:ins + 文本run用_body_rpr标w:ins,倒序插入→新条款自动续编、后续原文自动顺延,无需手动改任何原文编号。动手前先判A/B类:看插入锚点附近的条款段pPr是否带numPr且其numId的lvlText是序号格式。完整可复用脚本+验证清单见 references/auto-numbered-list-clause-insert.md 5b. 新增段落numPr必须匹配目标章节(2026-07-12 盈浦健康科普案教训):当合同每个章节正文段落都有独立numId自动编号时,add_clause克隆邻近段落的pPr会导致新段落继承错误章节的numId。典型错误:在"七、不可抗力"之后插入"八、转包与分包"的正文段落,克隆了P73(numId=11=不可抗力的序列),转包正文渲染为不可抗力的"3."。修法:①如果新段落属于新章节且只有一段正文→strip numPr(单段不需编号);②如果新段落应加入已有章节的编号序列→设正确的numId;③新独立章节多段正文→新建numId(在numbering.xml中添加新abstractNum+num)。add_clause后必须验证新段落的numId属于正确章节。详见 contract-reviewer/references/numpr-section-continuity-check.md
  6. 签署页INS字体必须匹配标签(2026-06-15铁律): 签署页"甲方:""乙方:"等标签run和名称run经常字号不同(如标签sz=28 bold=YES,名称sz=24 bold=no)。用DEL+INS替换名称时,INS的rPr必须匹配标签run(sz=28 bold=YES),不能照抄被替换的旧名称run。否则签署页字体大小不一致。检查方法:读同段落非DEL/INS的普通run的rPr,INS必须与之一致
  7. rsid属性: w:ins内run要有rsidR,w:del内run要有rsidDel 8b. 多字号文档:tracked_replace的INS字号/字体可能错(2026-06-17教训): tracked_replace克隆被匹配run的rPr,若该run无显式eastAsia字体或无显式sz,库会从全局_body_rpr回填——但_body_rpr是全文最常见正文格式(如主合同sz=32)。当目标段落用的是另一个段落样式(如附件《学员管理制度》用Bodytext2样式 eastAsia=宋体 sz=30),库会给INS错填sz=32,且因被匹配run的cs非空而跳过eastAsia字体回填→INS无显式CJK字体。症状wb-ins-font-verify.pyMISSING FONT;接受修订后插入字比周围大一号。库的validate()查不出(它只比单一全局body_sz)。修法:save前遍历所有WB INS run,按其所在段落的pStylebasedOn链解析出真实eastAsia/ascii/sz,对缺字体或字号≠本段样式的INS run显式补齐(fallback宋体/30)。校验以wb-ins-font-verify.py(逐段比对)为准,不是库的validate()。完整解析+修法见本条。\n8. tracked_replace短字符串误命中(2026-06-17教训): tracked_replace("培训服务", "第一条 培训服务")本意是补P60的编号,但"培训服务"也出现在标题"校外培训服务合同"中,导致错误命中标题段。短字符串或常见词组作为匹配目标时,tracked_replace可能命中非目标段落。解决方案:先用Document(docx).paragraphs确认目标段落的精确索引和完整文本(如验证paragraphs[60].text.strip()=='培训服务'),然后用zipfile+lxml直接操作body.findall(W+'p')[目标索引]在段首first_run.addprevious(ins)插入w:ins。新run的rPr必须从同级原文参照段提取(如"第二条"的sz=32),不能用ContractEditor的默认值
  8. 多run编号处理(关键陷阱,2026-06-08教训): 编号如"(5)"在docx XML中经常分散在多个run中(如 + 5 + )委托方)。tracked_replace按完整字符串(5)搜索会匹配不到。正确做法:遍历连续plain runs,拼接文本后查找目标编号,找到后删除原runs、插入DEL+INS元素。处理顺序必须从后往前(避免index偏移)。如果run包含编号+正文混合(如)委托方),需拆分run保留正文部分。参见 references/split-run-renumber.md
  9. 逻辑自洽检查: 删除某条款/上限后,全文搜索所有引用该内容的条款一并修改
  10. 交叉引用更新(2026-06-15终审发现的铁律): 新增heading级别条款(如add_clause新增style=2标题段)后,合同自动编号会顺移,但正文中硬编码的交叉引用("第X条""合同第X条"等)不会自动更新。每次add_clause插入heading级别条款后,必须用正则第\s*\d+\s*条扫描全文,找到所有交叉引用,逐个检查是否因编号顺移而需要更新(DEL旧条号+INS新条号)。偏移量=该引用位置之前新增的heading条款数量。遗漏此步会导致条款引用指向错误的条款,直接影响合同权利义务

9c. 拼接残稿:交叉引用整批指向"不存在的条款"(2026-06-25 海外并购FA合同): 起草人从旧模板拷条文、中间又插了新章节的"两套模板拼接残稿",最典型的硬伤是正文里的交叉引用全部指向旧模板的条号、而新合同里那些条号根本不存在。海外FA实证:违约责任已顺延成第7条、终止成第8条,但条文里仍写"依据第5.3条单方终止""适用第5.4条尾款期""第4.1.5条滞纳金""第4.1.6/4.1.8条违约金""按第三章约定支付"——这6类引用在全合同零落点。这与 rule 9(我方 add_clause 导致的顺移失配)是镜像问题:rule 9 是我们改动引发的,本条是源文件自带的、改之前就坏了。诊断:通读后把每个"第X条/第X.X条/第X章"引用拉出来,回正文核对该条号是否真有对应条款(建表:引用条号→实际章节标题)。零落点的即为坏引用。修法(WB 修订,字符级 DEL 旧号+INS 新号):按"功能对得上的现存条款"改——如"单方终止"现落在 8.3 则 5.3→8.3、"尾款期"落在 8.4 则 5.4→8.4、"滞纳金"落在 7.1(一) 则 4.1.5→7.1。交付前 grep 残留:接受修订后全文 re.search(r'第?5\.[34]条|4\.1\.[568]条|第三章约定', l) 必须 0 命中。判据:FA/服务类、并购类合同尤其高发(常拿一份通用服务合同模板临时加章),凡接手"看着像拼出来的"合同,先做一遍交叉引用落点核验再动其他。 9. 删除整条/整款时连编号一起删: 不能只删内容留空编号,后续编号按顺序修改保持顺延 9b. 删除整份文件/附件(如承诺书、担保函)(2026-06-30 新增): 当reviewer要求删除合同文件中的某个独立文件(如承诺书)时:①用tracked deletion(w:del, author=WB)标记该文件的全部段落——逐段构建w:del元素包裹段落全部文本,不能用tracked_replace(它适用于文本替换,不适用于整段删除);②在关联条款处加批注,说明删除的法律依据和建议;③批注必须有具体法条引用,不能只说"建议删除" 10. 新增条款必须有编号(终审返工第一原因): 每个新增的条款段落都必须有编号,编号格式与原文同级一致。不能只插入条款正文而忘记编号。编号的加粗/字体必须与原文同级条款标题一致——如原文"第十条"加粗,新增的也必须加粗。插入编号后,后续原文编号必须用修订模式顺延(DEL旧号+INS新号)。(2026-06-08+06-10教训:复达合同3个新增条款缺编号;安全测试合同新增条款缺编号且编号未加粗,连续两次返工)

10b. 条款内子条款必须有编号(2026-06-30 徐泾北大居体检合同): 在某个条款(如"第七条 违约责任")下新增子条款时(哪怕只新增一段),每个子条款必须带编号(如"1、""2、""3、"或"8.1""8.2"),编号格式与原文同级子条款一致。不能只插入子条款正文不加编号。这与 Rule 10 的条款级编号不同——Rule 10 管的是"第X条",本条管的是条款内部的"1、2、3、"或"8.1、8.2"。实现方式:如果用 add_clause 插入,text 必须以编号开头(如 "1、乙方逾期提供服务的...");如果用 zipfile+lxml 直接构建 INS 段落,INS 内首 run 的 w:t 必须以编号开头。诊断:接受修订后,条款标题下是否有多个并列段落但缺少编号 → 就是漏了。

⚠️ 已有段落也需要加编号(2026-06-30 Doro纠正,2026-07-06 再次确认):当条款下已有一段原文内容,新增哪怕一段新内容后,该章节变为多段——已有段落和新增段落都必须加编号。不能只给新增的加编号而让已有段落无编号。

两种实证场景

  • 场景A(多子条款):徐泾北大居合同第七条下,原文有一段违约责任条款,新增4个子条款。第一版只给新增的编了1、2、3、4,Doro纠正"补充第七条的编号"——正确做法是给原文段落加 INS "1、",新增的编2、3、4、5。
  • 场景B(章节内新增单段,2026-07-06 第七章合同书格式案):原文第8章"争端的解决"只有一段正文(无子编号),workflow新增一段"维权费用"条款后变为两段。Editor只插入了纯文本内容不带"8.2"编号,已有段落也没加"8.1"。Doro指出"新增维权费用条款没有按照全文统一编号增加条款编号"。正确做法:原有段落段首插入 INS "8.1 ",新增段落以"8.2 "开头。
  • 根因:Editor/Reviewer都只检查了章节号连续(7→8→9无跳号),没有检查章节内部从单段变多段后是否需要子编号。判断铁律:章节标题下如果有≥2个并列内容段落,每段都必须有子编号。

⚠️ 子编号格式必须与原文同级子编号一致(2026-07-06 手动修复被Doro纠正):手动插入子编号(如"8.1 ")时,必须先检查原文同级子编号(如7.1、7.2、9.1、9.2)的run结构和格式:

  • 常见格式:子编号(如"7.1")单独一个bold run + 空格(not bold)+ 正文(not bold)。即编号部分加粗,内容部分不加粗。
  • 手动插入时:INS run 的 rPr 必须从原文同级子编号的首 run 克隆(含 w:b/w:bCs/w:rFonts/w:szCs),不能用段落其他 run 的 rPr(那些是正文格式,没有 bold)。
  • 验证方法:插入前用 lxml 读原文邻近子编号段(如 P29 的 "7.1" run、P38 的 "9.1" run)的首 run rPr,确认 bold 状态和字体。
  • 教训:第一版手动修复只用了段落正文 run 的 rPr(font=time, sz=21, no bold),Doro说"你再认真看看"——原文所有子编号首run都有 <w:b/><w:bCs/>,修复版必须匹配。
  1. 新增编号必须独立成段(铁律): 给无编号段落补编号时,编号必须在该段落开头插入(作为ins),或将该段落拆分为独立的编号段+内容段。绝不能把编号追加在上一段的末尾
  2. 新增条款标题段+内容段属于同一条款,共用一个编号(2026-06-12 Doro退回原因): 新增一个带标题的条款时(如"第X条 知识产权"),标题和正文内容属于同一个条款,编号只出现在标题段。不能把标题和内容拆成两个独立编号(如"第12条 知识产权"和"第13条 乙方在履行本合同过程中..."是错误的——内容段不应有独立编号,它是第12条的内容)。标题段独立一个w:p且加粗,内容段另起一个w:p且不加粗,但两段共用一个条款编号

12b. ⚠️ 新增条款的段落结构应与原文同级条款一致,不预设一律分两段(2026-06-26 检测服务协议书教训): 新增条款的段落结构取决于原文——原文怎么做,新增就怎么做。若原文同级条款标题+正文分两段(如"6、不可抗力"独立一段 + 正文另起一段),新增也分两段;若原文合一段,新增也合一段。不搞一刀切。判断方法add_clause 前先检测插入位置前后原文条款的段落结构——看前一条款是标题+正文各一个 <w:p> 还是合并在一个 <w:p> 里,新增条款照此格式。分两段时:标题段从原文标题段克隆 pPr(含 <w:b/><w:spacing> 等),正文段从原文正文段克隆 pPr(含缩进等)。两段共用一个条款编号。交付前自查:在 OnlyOffice 中确认新增条款的段落结构与原文同级条款一致

手动构建 WB INS run 的 rPr 必须完整(2026-06-26 检测合同返工教训)

用 zipfile+lxml 手动构建 w:ins 内的 w:r 时,绝不能只设 hint="eastAsia" 就完事。必须从原文参照 run 完整复制 rPr,包括:

  • w:rFonts 四属性齐全(ascii、hAnsi、eastAsia、cs),不能只设 hint
  • w:sz 显式设置(如 sz=28),不能依赖继承
  • w:b/w:bCs 加粗状态与原文同级一致
  • w:colorw:szCs 等其余属性

错误示例(2026-06-26 实证,被 Doro 一眼看出字体不对):

rPr = etree.SubElement(r, 'w:rPr')
etree.SubElement(rPr, 'w:rFonts').set('w:hint', 'eastAsia')  # ❌ 只有 hint,缺字体名、缺 sz、缺 b

正确做法:从原文同级段落的首个 run 完整 copy.deepcopy(rPr),然后只替换文本。 13. tracked_replace编号精准: 如果需要替换编号文字,确保替换完整。分布在多个run中的编号需合并处理

  1. 手动构建 INS 段落必须完整克隆原文 run 的 rPr(2026-06-26 检测合同教训): 用 zipfile+lxml 手动构建 w:ins 内的 w:r 时,绝不能只设 hint="eastAsia"。必须从原文同级段落的首个 run 完整复制 w:rPr(含 w:rFonts 四属性 ascii/hAnsi/eastAsia/cs、w:szw:bw:bCs 等全部属性)。只写 hint 会导致:缺 sz→字号不对、缺 eastAsia→中文字体丢失、缺 b→标题不加粗。正确做法copy.deepcopy(原文run.find(w:rPr)) 然后只改 w:t 文本
  2. "二选一"条款必须同步改选择编号(2026-06-10终审发现): 中国合同模板常有"按以下第__种方式解决"的二选一格式(如仲裁vs法院)。当reviewer要求切换选项时(如从仲裁改为法院),必须同时修改两处:①选项内容本身(如乙方→甲方)②选择编号(如"第1种"→"第2种")。只改选项内容不改选择编号=逻辑矛盾,形式上仍选的是原选项。具体操作:找到填入选择编号的位置(通常是Crystall等人的INS),用WB DEL+INS替换为正确编号
  3. PDF合同直接批注: 用pymupdf(fitz)在PDF上插入高亮+comment annotations(author=WB)
  • 批注内容格式铁律:只写"建议修改为:……"或"建议增加:……",直接给修改方案
  • 禁止写理由:不要写"理由:""原因:""因为"等解释性文字
  • 禁止加前缀标签:不要写【新增】【修改】【删除】【建议】等标签
  • 批注内容越精简越好,只要修改方案,不要分析过程
  • DOCX合同批注(Word原生comment):ContractEditor没有批注方法,需手写OOXML(comments.xml + commentRangeStart/End/Reference + Content_Types override + rels,五处id全一致)。完整可复用脚本见 references/docx-comments-insertion.md。修订与批注可共存:先ContractEditor做完修订save,再在产物上加批注。锚点按"接受修订后"文本匹配(跳过w:del),commentRangeStart插在段首(w:r或w:ins)之前
  1. use_title_format陷阱

新增段落插入auto-numbered序列时必须带numPr(2026-07-12+07-13 多案教训)

核心铁律:当新增段落插在原文有numPr的段落序列中间时,新增段落必须有相同的numPr(numId+ilvl)。add_clause默认strip numPr会导致编号断裂。

2026-07-13 消防设施检测案实证:原文"十二、其它事宜"下P36-P42全部有numPr(numId=4)渲染为1-6。workflow在P38(3、违约)后用add_clause插入维权费用段,但add_clause strip了numPr→P39无编号→编号断裂(3直接跳到4,中间维权费用段无编号)。

修法:不用add_clause,手动用zipfile+lxml构建段落,克隆邻近段落的完整pPr(含numPr),标记段落¶为INS(pPr/rPr/ins),文本run包在w:ins中。

Workflow新增段落丢失/错配numPr(2026-07-12 盈浦健康科普合同教训)

当原文每个章节的正文段落都有 numPr 自动编号(如 numId=9 渲染"1." "2." "3."),workflow 用 add_clause 或手动 INS 新增段落时常见两类错误:

错误1:新增段落完全没有numPr

  • add_clause 默认 strip numPr(A类手动编号设计),但如果原文用的是B类自动编号,strip后新段落不渲染编号,与同章节兄弟段落格式断裂
  • 修法:新增段落的 pPr 必须加入该章节的 numId + ilvl=0

错误2:新增段落的numPr挂错序列

  • 从邻近段落克隆 pPr 时,如果新段落插在另一个章节下,会继承错误的 numId(如转包条款继承了不可抗力的 numId=11,渲染为"3."而非独立的"1.")
  • 修法:为新章节创建独立的 abstractNum + num(克隆现有 decimal "%1." 定义,新 abstractNumId + numId),新段落引用新 numId

错误3:Workflow给不该有sz的run加了显式sz

  • 原文 run 无显式 sz(继承 Heading 1 样式的 sz=48 等),ContractEditor 的字体补全逻辑可能给标题 run 塞入 sz=20/sz=21,导致标题文字从24pt变成10pt
  • 修法:save后检查原文run无sz的段落,INS或plain run是否被加了sz;有则删除

诊断铁律:修复编号/格式问题前,先把原文和修订版同段落的 numPr + run rPr 逐一比对,确认哪些是workflow引入的差异、哪些是原文就有的。不对完不动手。

子编号必须随章节编号顺延(2026-07-13 璞石合同教训)

当章节标题编号顺延(如"第七条"→"第八条")后,该章节内的子编号也必须顺延(7.1→8.1, 7.2→8.2等)。Workflow常见遗漏:只改了"第X条"标题的汉字/数字,但正文子条款"X.Y"编号不动。

  • 检查方法:接受修订后全文搜索"X.Y"格式,确认X与所属章节标题一致
  • 修复技巧:子编号通常拆为两个run(如run1="7" + run2=".1 "),只需对第一个run做DEL+INS(如DEL "7" + INS "8"),第二个run(".1 ")不动
  • 影响范围:每个被顺延章节的所有子条款都要改(如不可抗力7.17.4→8.18.4,争议解决8.18.4→9.19.4,其他条款9.19.3→10.110.3)

编号顺延操作顺序铁律(2026-07-13 舜葵案确立)

所有tracked_replace必须在所有add_clause之前完成。 特别是编号顺延(从后往前改)必须在新增条款之前全部做完。原因:add_clause插入新段落后,后续tracked_replace可能命中已修订段落内的w:ins文本导致ValueError: Element is not a child

正确操作顺序:

  1. 所有文本替换(称谓统一、错别字、条款修改等)
  2. 所有编号顺延(从最后一个编号往前改)
  3. 所有add_clause新增条款
  4. validate() + save()
  5. strip-inherited-ins-attrs.py 修复字体
  6. wb-ins-font-verify.py 验证

编号顺延会沿用原文"单条子编号"(2026-06-17 26华新案教训)

新增条款触发主编号顺延时(如新增第9条→原9/10/11顺延为10/11/12),editor只把主编号DEL旧号+INS新号,会原样沿用原文该条款的子编号结构。若原文某条是"单条子编号"(如原文"9.争端的解决/9.1xxx",9.1下无9.2,本身已违反"有2才有1"),顺延后变成"10.1xxx"仍是单条子编号,问题延续。

  • 根因:reviewer/editor都只看"新增条款"的有2才有1(规则132行只约束新增条款),没检查"原文既有条款"的单条子编号;顺延逻辑只动主编号,不规整子编号。
  • 诊断:顺延后检查每个被顺延的条款——标题下是否只有一个"X.1"且无"X.2"。XML层:内容段首是 DEL(旧主号)+INS(新主号)+run(".1"+正文),".1"是原文run自带。
  • 修法(WB修订模式):去掉单条子编号→①移除workflow插入的主号INS(撤销);②原文run的".1"拆出包进<w:del author=WB>;③段首缩进空格run也包进w:del。接受修订后:标题下正文直接跟随、无子编号(与正确的新增条款做法一致)。完整可复用脚本见 references/renumber-collision类思路。
  • 边界:是否规整原文既有单条子编号属规则问题,需Doro确认(与"原文格式不改"规则122行有边界冲突)——擅自大面积改原文子编号有风险。安全做法:仅规整"本次审查已因顺延/修改触碰的条款"。
  • 🔴 Doro已拍板(2026-06-17 26华新案,定论):单条子编号顺延这类问题不系统化修,当个案手动处理即可。 我提的方案A(扩reviewer规则到所有条款)和方案B(改editor顺延逻辑自动检测+去掉单条子编号)都被否。Doro原话"今天的编号问题不是大问题,不要动workflow"。结论:不改 review-rules、不改 renumber_range 逻辑、不改 review-contract.yaml——出现时在终审/手动阶段一份一份修。未来session别再提"改workflow根治单条子编号"的方案(已被否过)。这也是"方案≠授权执行"的又一例:方案写得再周全,Doro说不动就不动。

中文手动编号合同的子条款和heading级新增(2026-07-01 生育友好协议实证)

当合同使用中文手动编号("一、""二、""三、"作为主条款,"(1)""(2)"作为子条款)时,新增条款有两种情况:

情况A:在现有条款内新增子条款

  • 新增子条款必须带编号,格式与同级一致(如"(3)")
  • add_clause 时,text 必须以编号开头:ed.add_clause("(3)本协议终止或解除后...", after_search=...)
  • 不能只插入正文内容不带编号
  • 教训:生育友好协议在"六、保密与知识产权"下新增甲方后续使用权条款,第一版没加"(3)"编号,Doro指出"补充新增的内容编号"

情况B:新增独立heading级条款

  • 新增heading条款必须带编号ed.add_clause("八、转包与分包", after_search=...)
  • heading下的正文另起一段:ed.add_clause("未经甲方书面同意...", after_search="八、转包与分包")
  • 后续所有heading编号必须顺延ed.tracked_replace("八、不可抗力条款", "九、不可抗力条款")ed.tracked_replace("九、争议解决条款", "十、争议解决条款"),以此类推
  • 顺延必须从最后一个heading往前改(避免互相覆盖),或按顺序逐个改
  • 不能遗漏任何一个heading的顺延——一个漏改就会导致后续全部错位
  • 教训:生育友好协议新增"八、转包与分包"后,"八→九""九→十""十→十一"三个顺延全部遗漏,被Doro指出

判断方法:插入前先数一遍原文的完整编号链(如"一、二、三、四、五、六、七、八、九、十"),确定插入位置和后续需要顺延的编号列表。

validate() 误报"编号跳跃"——预存编号体系≠本次修订引入(2026-06-26 卓川人力派遣合同)

validate() 的编号连续性检查(第554-570行)从 accepted view 中提取手动编号(如 "9.2"→9、"13.4"→13、"22.1"→22),若原文 numbering.xml 中不同章节使用不同 numId 前缀(如 numId=10 用 lvlText='9.%1'、numId=11 用 lvlText='12.%1'),手动编号之间会出现天然跳跃。这是原文的编号体系设计,不是本次修订引入的validate() 无法区分"新引入的跳号"和"原文预存的编号体系"。判据:运行 numbering-diagnose.py 看 numbering.xml 的 numId→lvlText 映射,若跳跃对应不同 numId 前缀→预存体系,save 可继续。若跳跃在同一 numId 序列内→本次修订可能引入,需排查。规则层面:review-rules 已明确"编号缺失、编号跳号不是审查issue,不列入问题清单"。

validate() 误报"不应加粗"——<w:b w:val="0"/> 显式关闭加粗(2026-06-22 心理挂件沙盘合同修复)

有些合同(如青浦华新镇设备采购模板)每个段落的 rPr 都带 <w:b w:val="0"/>——这是显式关闭加粗(OOXML 中 val=0/false/off=加粗OFF),条款标题与正文一律不加粗,仅靠缩进区分(标题flush-left,正文 left=542)。

  • contract_docx_lib.py 旧版 validate() 第577/590行用 rpr.find(qn('b')) is not None 判加粗——只看 <w:b> 元素是否存在,把 val="0"(关闭)误判为加粗ON。用克隆兄弟段落手法(A类)新增条款时,INS 继承了 <w:b w:val="0"/>,validate 误报"不应加粗: '7、转包与分包'"。
  • 已修(库层永久修复):在 contract_docx_lib.py 加了模块级 _is_bold_on(rpr) helper,正确解析 w:b 的 val(无val或val∈{1,true,on}=ON;val∈{0,false,off}=OFF),validate 两处加粗/字号判断都改用它;同时把 clause-title 正则从 ^\d+[..] 扩到 ^\d+[..、]\s* 以认 数字、 格式。这是真 bug 修复,惠及所有"显式 val=0 关闭加粗"的合同,别再退回。
  • 判别:动手前先看源文件条款段 rPr——若 <w:b w:val="0"/> 普遍存在,则标题/正文都非加粗,克隆兄弟段落即正确(INS 自然非加粗),不要手动给新标题加 <w:b/>(那会让它比兄弟标题更粗,反而不一致)。

INS 中文字体:eastAsiaTheme 主题回退 + 接受修订预览做决定性验证(2026-06-24 金信大厦租赁案)

症状:源合同正文 run 用 eastAsiaTheme="minorEastAsia" 让中文走主题字体,run 自身无显式 eastAsia 属性(只有 ascii/hAnsi/cs=Times New Roman)。tracked_replace 回填 INS 字体时(库约306-317行逻辑),因 rFonts 四名称属性"非全空"(ascii 有值),会把 eastAsia 也填成 Times New Roman——而 Times New Roman 无中文字形,INS 的中文字体与原文有效渲染字体不一致。库的 validate() 查不出(它不比主题回退字体)。

正确字体怎么定(原文中文实际渲染字体 = 解析主题):

  1. word/theme/theme1.xml<a:minorFont>(正文走 minor;标题走 majorFont)下 <a:ea typeface="...">
  2. 若该 ea空字符串(很常见)→ 回退到 word/styles.xmldocDefaults/rPrDefault/rPr/rFontseastAsia(金信大厦案=宋体)。
  3. 所以 INS 中文 eastAsia 应设为这个回退字体(宋体),不是 Times New Roman。

修法(save 后跑一遍):遍历所有 author=WBw:insw:rrPrrFonts,把 eastAsia=='Times New Roman'(且文字含中文)的改成解析出的回退字体(宋体),并设 hint='eastAsia'ascii/hAnsi/cs 保留 Times New Roman(管西文)。

⚠️ 视觉验证的致命陷阱——vision 对修订态字体会误报:OnlyOffice 渲染修订态插入文字(紫色 + 下划线)时,视觉上常显示为类无衬线、看起来"比正文粗 / 字体不同"。vision_analyze 会据此报"字体不一致"——这是 track-changes 的渲染特性,不是真实字体差异,不要据此返工。

  • 决定性验证 = 渲染"接受所有修订后"的干净版:生成接受全部修订的副本(解包所有 w:ins、删除所有 w:del 连同内容、移除批注 commentRangeStart/End + 含 commentReference 的 run),用 OnlyOffice 渲染该干净版,在无修订颜色干扰下核对插入文字与正文字体大小粗细。金信大厦案:修订态 vision 报"无衬线不一致",接受修订版 vision 报"宋体、字号粗细完全一致"——后者才是真相。
  • 可复用脚本:scripts/accept-revisions-preview.py <in.docx> <out.docx> 一键生成接受修订版供渲染验证(仅供核对,不是交付物——交付的是带修订痕迹的版本)。

变体·原文正文runs混合继承/显式sz导致"文字大小不一致"(2026-07-01 生育友好协议,Doro退回):源文件正文部分 runs 有些显式设了 sz=24,有些完全没有 sz(靠 docDefaults 继承 10.5pt)。WB INS 正确设了 sz=24,但因原文 runs 的混合,OnlyOffice 渲染出字号不一致。修法:确定正文区域的 target_sz(取 body range 内显式 sz 的众数值),对 body range 内所有 runs(plain + INS + DEL)批量补齐。注意不碰标题区和签署区。完整诊断+代码见 references/mixed-inherited-sz-fix.md

变体·add_clause产出的INS段落缺eastAsia或缺sz(2026-07-01 反委托代发协议/生育友好协议实证)add_clause 插入的新段落(全部文字都是INS)在源文件正文 run 无显式eastAsia字体(靠docDefaults回退)时,INS run 的 rPr 可能完全没有 eastAsia 属性。同理,当源文件正文 run 无显式 sz(靠docDefaults继承)但邻近段落有显式 sz=24时,INS段落渲染出的字号会与邻近段落不一致——因为 INS 在修订上下文中可能丢失 docDefaults 继承链。症状:wb-ins-font-verify.pyMISSING FONT,或 OnlyOffice 渲染后新增段落文字明显偏小/偏大。修法(save后必跑)

  1. 先检查邻近原文段落(前后各2段)是否有显式 sz,取其 sz 值作为 target_sz
  2. 遍历所有 INS-only 段落(整段都是 w:ins),若其 run 缺 sz 且 target_sz 存在,补齐 sz+szCs
  3. 同时补齐缺失的 eastAsia(按 docDefaults 或 theme 回退字体)

修法(save后必跑)

# Post-save sweep: fix INS runs missing eastAsia font
for p in body.findall(f'{WNS}p'):
    for ins in p.findall(f'{WNS}ins'):
        for r in ins.findall(f'{WNS}r'):
            text = ''.join(t.text for t in r.findall(f'{WNS}t') if t.text)
            if not any('\u4e00' <= c <= '\u9fff' for c in text):
                continue
            rpr = r.find(f'{WNS}rPr')
            if rpr is None:
                rpr = etree.Element(f'{WNS}rPr')
                r.insert(0, rpr)
            rf = rpr.find(f'{WNS}rFonts')
            if rf is None:
                rf = etree.SubElement(rpr, f'{WNS}rFonts')
            if not rf.get(f'{WNS}eastAsia'):
                rf.set(f'{WNS}eastAsia', '宋体')
            if not rf.get(f'{WNS}ascii'):
                rf.set(f'{WNS}ascii', '宋体')
            sz = rpr.find(f'{WNS}sz')
            if sz is None or not sz.get(f'{WNS}val'):
                if sz is None:
                    sz = etree.SubElement(rpr, f'{WNS}sz')
                sz.set(f'{WNS}val', '21')

判据:源文件段落 run 的 rPr 中 eastAsia=NoneeastAsiaTheme 也没有显式值 → add_clause 产出的 INS 段落大概率缺 eastAsia。每次 save 后跑一遍这个 sweep。

变体·直接 XML 编辑 + 源 run 完全无显式字体(2026-06-25 海外并购FA合同):不走 ContractEditor 库、用纯 zipfile+lxml 自建字符级 diff 引擎做 tracked-changes 时(克隆源 run 的 rPr 构造 w:ins/w:del),若源合同正文 run 的 rPr 完全没有字体属性(ea/ascii/eaTheme 全 None,中文字体 100% 来自 docDefaults 的 eastAsiaTheme="minorEastAsia"),克隆这种"裸 rPr"进 w:ins,INS 在修订上下文里会丢掉 docDefaults 回退 → 字体核验报 ea=None eaTheme=None(中文可能渲染异常)。这与库路径(tracked_replace 误把 ea 填成 Times New Roman)是同一根因的两个入口:库填错值、直接编辑漏填值。修法(save 后必跑的 sweep):遍历所有 author=WB 的 w:ins→w:r→rPr→rFonts,对含中文([\u4e00-\u9fff])的 INS run 显式补 eastAsia="宋体" ascii/hAnsi/cs="Times New Roman" hint="eastAsia",缺 sz 补 sz/szCs=21(或本段实际字号)。验证探针:扫所有 WB INS 含中文 run,判 eastAsiaTheme=="minorEastAsia" or (ea and ea!="Times New Roman") 为真才算过,应 0 异常。直接 XML 编辑没有库的 validate() 兜底,这条 sweep+探针是唯一防线,每次都跑。

⚠️ wb-ins-font-verify.py 段落索引漂移(2026-07-13 练塘环保袋合同教训)add_clause 插入新段落后,后续原文段落的索引全部+1。wb-ins-font-verify.py 报告的 "P78" 是当前文件的段落索引,但如果你在 add_clause 之前的代码中用 paras = body.findall(...) 缓存了列表,该列表已过时。修复字体时不要按脚本报告的索引硬编码 paras[N]——应按文本内容搜索目标段落(如遍历 paras[70:85] 找含目标 INS 文字的段落)。实证:脚本报 P78 HINT MISMATCH,第一次 fix 写 paras[78] 静默无效(该索引已非目标段),改用内容搜索 '日' in t.text 才命中 P71。

交付前视觉验收的两个已知误判 + 一个渲染特性(2026-06-25 海外并购FA合同)

  • vision 在"接受修订后干净版"上同样会误判,不止修订态:既有记录把 vision 误报字体归因于 markup 紫色修订态;实测在已接受修订的干净版上也照样误判——把整页宋体看成黑体(位图缩放把衬线渲染成类无衬线)、把 % 看成 (小符号位图误判,与 OCR ‰/% 重灾区同理)。铁律:字体、‰/%、金额等小符号一律回数据层核(读 INS run 的 rFonts 取 eastAsia;读 w:t 文本 grep 【5】%/【3】% 看是否含 ),绝不拿 vision 的像素判断当字体/符号的最终结论。vision 只对"版面截断/错位/红色块"这类大尺度视觉有效。
  • Word 原生批注(comment)不会渲染进 x2t→PDF:用 onlyoffice-render.sh(x2t 引擎)把带批注的 docx 导成 PDF 时,右侧批注气泡不导出,vision 看 PDF 会报"没有批注气泡"——这是 x2t 导出特性,不是批注丢失。批注数据在 docx 里完整(靠 id 四向一致 + python-docx 可打开校验确认即可),在 OnlyOffice/Word 编辑器右侧栏正常显示。别据此返工去"修"气泡。

编号问题诊断纪律(2026-06-16 Maggie两次纠正"你没有认真看上下")

修改任何编号问题前,必须先看清实际渲染的编号链,分清自动编号和手动编号,绝不凭XML的delText/ins文字顺序或肉眼扫一遍就动手。教训:未核对就把合同2(端午节)的"转包6/违约7/争议8"擅自改成7/8/9并上传,被纠正"你没有认真看上下";同时把合同1(医疗急救招聘)的编号现象误判成我们改错的,其实是他人删段导致的自动重排。

⚠️ 端午节案06-16同日后续澄清(防止误读上一条):当天晚些Maggie明确指示"转包责任应该是7,上一个编号是6,手动修复上传"。核查发现端午节真实结构:「售后服务」是原文自动编号(numId=3,start=6)渲染成6,「转包/违约/争议」是WB新增w:ins手动编号6/7/8,转包手动6与售后自动6撞号(接受修订后是6,6,7,8两个6)。所以 6/7/8→7/8/9 方向本来就对,当初错在"擅自"——没先OnlyOffice核对、没等Maggie确认,不是方向错。结论:有明确指示+完整核对+四查验证时,这个renumber就该做,别因为上一条"擅改"教训而拒绝执行正确的修复。修法见下文「WB自加手动编号与前序自动编号撞号」。

诊断步骤(顺序不可颠倒)

  1. 用OnlyOffice x2t渲染交付版+原文为PDF(见 scripts/onlyoffice-render.sh)——OnlyOffice是Maggie/Doro实际使用的引擎,渲染结果与LibreOffice/python模拟可能不同,核对编号一律以OnlyOffice为准
  2. pdftotext -layout提取行首编号,逐条数出完整编号链(1、2、3…),定位重复/跳号/错乱的确切位置
  3. 分清编号来源先跑 scripts/numbering-diagnose.py <docx> 一次性摊开全貌,别再手搓探针):
    • 自动编号:段落pPr有<w:numPr>,编号由numbering.xml的<w:start>lvlText生成。一个看似"6、"的编号可能来自numId=3, start=6,不是手动打的字、也不是笔误
    • 手动编号:编号是run里的<w:t>文字(如"6、"直接写在文字开头)
    • numId=0或无效abstractNum引用不渲染——这种段落的"1、2、3"其实是手动文字
    • 同一份合同常自动+手动混用,甚至"4、运输……5、结算"两条挤在同一个段落里没分段——这种隐藏的段内编号容易被漏看

编号撞号的第四种成因:自动编号顺移与文末固定手动编号撞号(2026-06-26 卓川人力派遣合同实证)

合同使用混合编号体系(正文条款用 auto-numbering numId=1 渲染"第X条",末尾附件条款用手动文本编号如"第二十六条 本协议附有附件…"),新增 auto-numbered 条款导致自动编号整体顺移后,自动编号可能与文末的固定手动编号撞号

  • 卓川人力实证:原合同自动编号为"第二十二条 协议生效→第二十三条 争议解决→第二十四条 联系方式",文末手动"第二十六条 附件"。新增"维权费用对等"+"转包/分包"两个 auto-numbered 条款后,自动编号顺移为"第二十六条 争议解决",与手动"第二十六条 附件"撞号(接受修订后 PDF 显示两个"第二十六条")。
  • 诊断onlyoffice-render.sh 渲染接受修订版为 PDF,pdftotext -layout | grep '^第' 数出完整编号链,看是否有重复编号。
  • 修法:文末手动编号段落不是 auto-numbered 序列的一部分,需手动 WB 修订 DEL 旧号 + INS 新号(如"第二十六条"→"第二十八条")。纯 zipfile+lxml 定位该段首 run 的 w:t,改编号文本。
  • 判据:合同含 manual text numbering + auto-numbering 混用,且文末有独立的手动编号段落时,新增 auto-numbered 条款后必须检查编号链是否撞号。
  • 边界:此问题与"编号跳号不列入问题清单"规则不冲突——撞号是重复,不是跳号。且撞号由我方新增条款引发,非原文预存。

编号撞号的第三种成因:源文件自带的潜伏自动编号(2026-06-16 端午节合同实证)

判断"是我们改的还是workflow没改对"时,别只在"我们WB改"和"他人修订重排"两类里找——还有第三类:源.doc文件起草人当年给某段挂了一个start≠1的decimal自动编号,它在OnlyOffice里自动渲染出可见编号,但run的<w:t>里没有这串字

  • 端午节实证:原文1-5是手打文本编号,但"售后服务"段挂了numId=3 → abstractNum start=6, lvlText='%1、',OnlyOffice自动渲染成"6、售后服务"。workflow在末尾新增转包/违约/争议时,只数到手打的最后一个数字5,顺手编成6、7、8 → 与潜伏的自动"6、售后服务"撞号
  • 这是源文件自带的雷,既不是我们WB改错、也不是workflow算错——workflow看见的是手打文本序列(…4、5、然后无可见数字的售后服务),它没"看见"那个自动渲染的6,逻辑上接5编6没错,但撞了自动6。
  • 诊断铁律numbering-diagnose.pyrendered列会把这个潜伏编号显形。**新增手打条款的起始编号,应接续该段rendered的自动值往下编,不是接续最后一个手打数字。**正确终号:售后服务=6 → 转包=7 → 违约=8 → 争议=9。
  • 回答Maggie/Doro的归因问题时,先用脚本证明编号来源,再下"谁的责任"的结论——绝不凭印象说"workflow没改对"或"我改错了"。
  1. 逐字节比对原文与交付版的问题段落etree.tostring对比)确认问题是我们WB改的、还是原文/他人修订自带的。他人修订自带的编号现象(如删除某子项导致后续自动编号前移)按"他人修订不动"规则,先报告不擅改

markup视图 vs 接受修订后视图(关键认知)

OnlyOffice修订视图(markup)对"因删除/插入导致编号变化"的段落,会显示旧号新号叠加(如删除某子项后,后面项显示"(3)(2)"=旧3新2)。这是track-changes的正常渲染,不一定是错误。真实编号要看"接受所有修订后"的版本(删除全部w:del元素+删除带段落标记删除的段+解包w:ins后重新渲染)。Maggie/Doro平时看的是markup视图——她说编号错乱时,必须弄清她指的是markup叠加显示,还是接受后的真实编号。

诊断有歧义时先确认再改(铁律)当存在多种合理解读(如"5、结算"挤在第4条段内算不算独立第5条),列出A/B/C方案+渲染图请Maggie/Doro拍板,不要自己选一种就改并上传。已经上传的错误版本,先还原成原始workflow产出再按确认方案改。

修复手段:WB自加手动编号与前序自动编号撞号(端午节案,2026-06-16实战验证)

症状:我们用w:ins新增的条款(如转包/违约/争议)手动写了编号"6、7、8",但前一条原文条款是自动编号(numPr)恰好也渲染成"6",导致接受修订后出现两个"6"。Maggie指示"转包应该是7"。 判定:先OnlyOffice渲染交付版+原文(onlyoffice-render.sh),pdftotext数出真实可见编号链,确认「前序自动编号末值」=N,则我方手动编号应从N+1起顺延。 修法(最干净):这三条编号文字就在各自w:ins的首个w:r的w:t里(如"6、转包限制…"),且author=WB是我们自己的修订——直接改w:t.text的编号前缀即可(6、→7、,7、→8、,8、→9、),不必拆run、不必碰rPr、不必转手动numbering。纯zipfile+lxml:定位p.find(w:ins)确认author==WB→ins.find(w:r).find(w:t)→assert text以旧号开头+含关键词→t.text=新号+text[len(旧号):]。只替换document.xml重新打包,其余文件原样。 易错:① 必须assert该ins的author=='WB',绝不能改他人(如Crystall)的ins编号;② 改完字体rPr必须改前==改后(见验证查3);③ 从后往前或用段索引定位,避免run顺序误判。完整可复用脚本见 references/wb-ins-renumber-collision.md。

修复手段:自动编号→手动固定编号(根治"删段重排",2026-06-16实战验证)

当编号错乱的根因是他人用修订模式删除了某个自动编号列表项,导致OnlyOffice markup视图把后续项渲染成双编号(如删(2)笔试后,面试显示"(3)(2)"、项目管理显示"(4)(3)"),且Maggie要的是"修订视图下编号稳定显示"——最干净的修法是把这一组列表项从自动编号(numPr)转成手动固定文本编号。手动文本是字面量,渲染器原样输出,从根上消除自动编号引擎的删段重排。完整可复用代码见 references/auto-number-to-manual-fix.md。核心要点:

  1. 每段删 pPr/numPr,在段落第一个内容元素前插入编号run("(1)""(3)""(4)")
  2. 被他人删除的那一项,其编号run必须包进他人的 <w:del>(复制该段已有del的author/date,给新del一个不冲突的大id如99001,用delText装编号)。否则:接受修订后会残留一个孤立的"(2)",或markup里这个编号不带删除线与该段删除状态不一致
  3. 编号run的rPr用本段原run的rPr(字体/字号一致,本例宋体sz=24),不要新造字体
  4. 绝不碰他人删除的正文内容(屠佳青的笔试del id=18/19/20一字不动),只在段首加编号
  5. 全文先确认无对这些子项编号的交叉引用再改(本例"详见附件一"是文字列举非编号引用,安全)

Workflow产出的spurious sz属性 + 原文run污染(2026-07-12 盈浦+2026-07-13 洋励教训)

⚠️ 2026-07-13 洋励合同发现更严重的变体:ContractEditor不仅给INS加错属性,还会给原文runs添加不应有的属性(eastAsia/cs/sz)。 诊断方法:对比待审查目录原文和交付文件的同段原文run rPr。修复方法见 references/workflow-font-contamination-repair.md

Workflow(ContractEditor库)有时会给原文没有显式sz的run错误添加sz属性。典型场景:

  • 原文标题段落(如Heading 1 style=1)的runs没有显式sz,字号由样式继承(如Heading 1定义sz=48=24pt)
  • Workflow处理后,某些run被加上了sz=20(10pt),导致标题文字突然缩小
  • 原文正文runs没有显式sz(继承docDefaults sz=22),workflow的INS runs却设了sz=21

诊断:对比原文和修订版的同一段落runs,逐个检查是否有原文没有但修订版多出的sz属性。 修法:删除INS run上多余的sz(让它走继承),或确保sz值与原文一致。 交付前必检:对所有WB修订涉及的段落,检查原文run是否有显式sz——如果没有,INS run也不该有。

Workflow产出的numPr错误(2026-07-12 盈浦健康科普合同教训,2026-07-13 消防设施检测合同再证)

当合同使用自动编号(每个章节正文段落都有numPr,如numId=9/10/11/12各管一章),workflow新增段落时常见两种numPr错误:

  1. 新增段落缺少numPr:应归入某章节编号序列的新段落没有设numPr,导致该段无编号而同级段落有编号。2026-07-13实证:消防设施检测合同原文"十、其它事宜"下P32-P37全部有numPr(numId=4)渲染1-6编号,workflow用add_clause在P38(3、违约)后插入维权费用段但缺numPr,导致该段无编号、编号链断裂。add_clause默认strip numPr(A类设计),但目标区域是B类自动编号时,必须手动为新段落添加正确的numPr。
  2. 新增段落numPr挂错序列:从相邻段落克隆pPr时继承了上一章节的numId(如转包条款继承了不可抗力的numId=11),导致渲染为错误章节的续编号

诊断numbering-diagnose.py查看全文numPr分布,确认每个新增段落的numId是否属于正确章节。 修法

  • 缺numPr:加入正确章节的numId+ilvl
  • numPr挂错:如果新章节只有一段正文,新建独立numId(在numbering.xml添加abstractNum+num);如果应归入已有序列,改为正确的numId

同模板合同从原文重做的正确流程(2026-07-13 舜葵/洋励教训)

当发现workflow交付件格式被污染需要从原文重做时:

  1. 先做所有tracked_replace(称谓/侵权/索赔/误期/仲裁/编号顺延),从后往前顺延编号避免覆盖
  2. 再做所有add_clause(违约责任章节/转包连带/保密章节)
  3. save后跑字体sweep:对比原文run属性,strip INS中原文没有的属性
  4. 编号顺延必须覆盖到最后一个编号条款(如21→23),漏一个就编号错乱

关键陷阱

  • tracked_replace在已有修订的段落上会报ValueError: Element is not a child——必须在add_clause之前做完所有tracked_replace
  • 同模板合同(如舜葵/洋励)的修订方案必须完全一致:逾期天数、争议措辞、章节结构、编号方式
  • 字体sweep必须区分"原文有ascii=宋体但无eastAsia"和"原文什么都没有"两种情况——不能一刀切

交付前降级验证(vision工具不可用时的强制四查,2026-06-16)

改完编号/格式后,vision截图不可用时按此降级方案验证,缺一不可:

  1. 逐段markup文本diff vs交付源:提取两版每个w:p的markup文本(含delText),逐段比对,确认只有目标N段不同、其余全部零改动(本例346段只动4段)。这是防"误伤其他段落"最硬的证据
  2. pdftotext渲染层核对编号链onlyoffice-render.sh转PDF后pdftotext -f1 -l1,肉眼数1.2条下编号严格按(1)(2)(3)(4)顺序、无双号
  3. 逐段编号run rPr == 正文run rPr:确认新插入的编号run字体/字号与同段正文一致
  4. python-docx能打开Document(out)不抛异常)证明XML合法,并核对接受修订后视图(删被删段+解包ins)编号链仍连续

技术规范(铁律)

表格单元格定位铁律(2026-07-02 改错列教训)

操作表格时,必须通过列索引+表头名称双重确认目标单元格,不能用"找到第一个匹配文本的单元格"。实证:除颤仪行单价=19600、成交总金额=19600,代码找到第一个"19600"命中了单价列(Col6),实际应操作成交总金额列(Col7)。正确做法:先从表头行确认目标列的索引号,再用 cells[目标索引] 精确定位。

写XML时中文字符铁律

用Python字符串写XML内容时,必须用普通字符串(str)直接包含中文,绝不能用bytes literal(b'...')——bytes内的中文会被Python写成\uXXXX转义序列,XML解析器会将其当作字面反斜杠文本渲染,客户看到乱码。正确:comments_xml = '...请注意确认金额...' 然后 .encode('utf-8') 写入zip。

文件操作

  • 表格单元格编辑必须保持格式(参见 references/table-cell-format-preservation.md):定位目标段落索引→保存首 run 格式→只清空该段重写→不碰其他段落。禁止 cell.paragraphs[0].clear() 压多段为一段,禁止 XML 全 cell 文字重分片破坏段落边界
  • ⚠️ lxml 序列化导致 OnlyOffice 无法打开 docx(2026-07-01 劳务派遣协议案,致命陷阱):lxml 的 etree.tostring() 输出单引号 XML 声明(<?xml version='1.0' encoding='UTF-8' standalone='yes'?>)+ LF 换行符。原始 docx 内的 XML 使用双引号声明 + CRLF。OnlyOffice 对单引号声明的 XML 不兼容,会报"格式有问题/打不开"。python-docx 的 Document.save() 同样触发此问题(内部调 lxml 序列化)。修法(save 后必跑的 XML 格式修复):重新打包 docx ZIP 时,对每个 .xml.rels 文件做两步修复:①单引号→双引号:re.sub(r"<\?xml version='1\.0' encoding='UTF-8' standalone='yes'\?>", '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>', text) ②LF→CRLF:text.replace('\n', '\r\n')(仅对不含 CRLF 的文件)。诊断:用 zipfile 读取 docx,检查各 XML 文件首行是否含 version='1.0'(单引号)和 \n(无 \r\n)。预防:ContractEditor 库的 save() 方法内部已处理(如未处理需补丁);手动 zipfile+lxml 操作时,将修复逻辑封装为 fix_xml_declarations(zip_path) 在最终写出后调用。完整修复脚本见 references/lxml-xml-declaration-fix.md
  • 纯zipfile+lxml操作XML,绝不用python-docx读写
  • python-docx的Document.save()会重建run结构,导致格式丢失
  • 用 zipfile.ZipFile 读取docx,etree 解析XML,修改后 zipfile 写回
  • 只修改w:t节点的text属性,绝不碰w:rPr(格式)、w:pPr(段落格式)
  • ⚠️ zipfile 同文件读写陷阱(2026-06-26 朱家角标识标牌实证,两次数据损坏)zipfile.ZipFile(path, 'r') 读取后,绝不能直接 zipfile.ZipFile(path, 'w') 写回同一路径——zipfile 在 'w' 模式下会立即截断文件,导致后续 zin.read()BadZipFile: Truncated file header正确做法:始终写临时文件→os.replace(tmp, target)。已损坏的 docx 无法恢复,只能从模板或原始文件重建。判别:损坏文件 zipfile.ZipFile(path) 返回 0 entries 或抛 BadZipFile。

tracked_replace 命中错段陷阱(2026-06-17 培训合同教训)

tracked_replace 匹配的是文本子串,短锚点会命中你不想改的段落。实证:要给独立段落"培训服务"(P60)补"第一条"编号,但全文还有标题"校外培训服务合同"(P2/P40)——用 tracked_replace("培训服务", …) 命中了标题,渲染出"校外第一条 培训服务合同"。

  • 诊断:改前先 Document(src) 遍历 enumerate(doc.paragraphs)grep 出锚点串的所有命中段及其索引。命中数>1 就不能用 tracked_replace。
  • 修法:改用 zipfile+lxml 按段落索引精确定位(body.findall(w:p)[i],加 55<=i<=65 之类范围+ text.strip()=='培训服务' 全等判断双重锁定),在该段第一个 w:r 前 addprevious 一个 WB 的 w:ins。
  • 补编号字号:插入"第X条 "的 ins run,sz 要匹配同级条款标题(本例"第二条"sz=32),不是正文 sz。先读邻近"第二条"段的首run sz 再设。

tracked_replace 多 run 碎片化文本:结果松散但接受视图正确(2026-06-26 CT维保合同-香花桥)

源合同文本被拆成逐字 run(如"上"\n"海"\n"市"…),tracked_replace 的字符级 diff 在高碎片化文本上仍能匹配,但产生的 INS/DEL 会很松散——每个字符单独成 INS/DEL。判据:只看接受修订后的视图(跳过所有 DEL,保留所有 INS),不要纠结 markup 视图的松散程度。若接受视图正确→通过。实证:P8 甲方名称"上海市青浦区香花桥街道重固镇社区卫生服务中心"→"上海市青浦区香花桥街道社区卫生服务中心",tracked_replace 产生 INS "香花桥"+"街道" + DEL "重固"+"镇",接受后正确。

短字符串(2字)在碎片化文本上的陷阱tracked_replace("造与", "造成") 在拆分 run 中可能部分成功——INS "成" 被插入,但 DEL "与" 未生成,导致"造与成"。症状:原始搜索字符串在 accepted view 中仍存在("与"未被删除)。修法:save 后用 zipfile+lxml 补刀——遍历该段 runs,找到残留的待删字符,手工包进 w:del(DEL run + delText)。验证:accepted view 中搜索旧字符串,0 命中。

tracked_replace 跨 w:ins 元素失败(2026-06-26 健康积分兑换项目协议)

tracked_replace 匹配文本跨越 w:rw:ins(author="WB") 边界时,会抛出 ValueError: Element is not a child of this node。根因:上一轮 workflow 在段落中插入了 w:ins(如"双方"被拆成 w:r["双"] + w:ins["方"]),tracked_replace 收集 runs 时混入 w:ins 子元素,remove 时 parent 不匹配。

  • 判别:改前遍历目标段落的子元素标签(w:r / w:ins / w:del),看是否有 w:ins 分割了匹配文本
  • 修法:不用 tracked_replace,改用 zipfile+lxml 四步操作:裁掉第一段 run 跨越部分 → DEL 被裁文字 → 移除 w:ins → DEL 旧文字 + INS 新文字
  • 易错:中文切片长度("但本"=2字用[:-2][:-3])、旧文末尾与新文开头重复、标点符号归属
  • 完整可复用脚本 + 验证清单见 references/tracked-replace-spanning-wins.md

tracked_replace 在高密度修订段落失败(2026-06-29 劳务派遣协议)

当段落已被多轮修订,子元素结构变成 pPr + ins + ins + del + ins + del + ins...(十几个 ins/del 交替排列),tracked_replace 会抛出 ValueError: Element is not a child of this node。根因:库的 diff 算法收集 runs 时遍历所有子元素(包括 w:ins/w:del 内的嵌套 run),remove 时 parent 指向的是 w:ins 而非 w:p,导致 parent.remove(runs[idx]) 失败。

  • 判别:改前检查目标段落的子元素结构——如果 w:ins/w:del 元素数量 > w:r 数量,说明是高密度修订段落
  • 修法A(拆小段):把一次替换拆成多个小段,每次只替换一段纯 w:r 文本(不跨越 ins/del 边界)。如原文 "甲方有权立即解除本协议。" 在纯 w:r 中,单独替换这一句即可
  • 修法B(纯 lxml):不用 ContractEditor,直接 zipfile+lxml 操作。定位段落 → 在末尾 append 新的 w:ins 元素(含 w:r + w:t)。适合在段落末尾追加文字的场景
  • ContractEditor 没有 find_paragraph 方法:不能 ed.find_paragraph(text) 定位段落。需要自己遍历 body.findall(f'{WNS}p') 提取文本匹配

修订模式

  • word/settings.xml添加 <w:trackRevisions/>
  • author从review-rules.md中读取(默认WB)
  • author铁律:所有合同修订的author一律为"WB",无论是通过ContractEditor库、workflow、还是手动写XML(execute_code/terminal)。绝不能用"小Maggie"或其他名称。2026-06-15教训:终审修正交叉引用时手动写XML用了author="小Maggie",被Doro退回要求改为WB
  • 删除用 w:del+w:delText,插入用 w:ins+w:t
  • 精细化修订:字符级tokenizer(CJK每字一token,ASCII连续一token,标点单独token)+ difflib.SequenceMatcher,合并相邻同类型操作
  • w:del内run需 w:rsidDel 属性,w:ins内run需 w:rsidR 属性

原文编号格式不改,新增内容按原体系顺延(2026-07-01 香花桥招标需求案,Doro纠正)

原文用什么编号格式就保留什么格式:numPr自动编号(✦/1./①等符号列表)、中文手动编号("一、二、三")、数字列表("1. 2. 3.")——不得把一种格式改成另一种。新增修订内容按原文已有的编号体系顺延即可。

  • 实证:原文用 numPr decimal "%1." 渲染为"✦ 控费协助服务""✦ 报表分析服务"等列表项。Editor 错误地给每个段落段首插入 INS "第一条""第二条"...并strip numPr,把原文的列表格式完全改成了中文编号。Doro纠正:"原文的条文编号是怎么样的不要改,新增修订按顺序顺延编号即可。"
  • 铁律
    1. 不得给已有 numPr 的段落改成手动编号(不strip numPr+加文字编号)
    2. 不得给手动编号的段落加 numPr 改成自动编号
    3. 新增独立章节(如"四、其他要求")用与原文同级章节一致的格式(原文用"一、二、三"则新增用"四、")
    4. 新增列表项如需加入已有 numPr 序列,保留 numPr 让其自动续编(B类,见 rule 5)
    5. 不得把数字编号("1. 2. 3.")改成中文编号("第一条 第二条"),反之亦然(2026-07-01 反委托代发协议教训:原文用 numPr decimal "%1." 渲染为 "1. 2. 3. 4. 5.",Editor 错误地给每段插入 INS "第一条""第二条"...中文编号,被 Doro 退回:"编号按照原文的编号不要修改成第x条,新增段落按顺序增加编号")
  • 判断方法:动手前先用 numbering-diagnose.py 看原文编号体系,确认要加入的是哪种序列

给新增段落添加手动编号时strip已有numPr(2026-07-01 反委托代发协议)

仅限新增段落从模板/兄弟段落克隆了 pPr 导致意外带入 numPr 的场景:原段落有 numPr(如 decimal "%1.")时,在段首插入 INS "第X条" 会导致自动编号和手动编号叠加渲染("1. 第一条...")。必须同时移除 pPr/numPrpPrChange/pPr/numPr。详见 references/numpr-strip-when-adding-manual-numbering.md注意:这条规则仅适用于「新增段落本身不应属于auto-numbered序列」的情况。如果新增段落应归入已有列表序列,保留numPr(见B类规则)。

numPr自动编号跨w:del段落后重置(2026-07-01 反委托代发协议)

OnlyOffice渲染时,完全被w:del包裹的段落会打断numPr自动编号计数——后续段落的编号从1重新开始。当合同修订后出现「已有编号段→整段删除段→新增编号段」的结构时,numPr自动编号不可靠。修法:strip所有段落的numPr,改为手动文本编号("N. "作为w:ins插入段首)。这能保持原文的数字编号外观("1. 2. 3.")同时避免计数器重置。详见 references/numpr-strip-when-adding-manual-numbering.md Scenario B。

add_clause 的 after_search 在 tracked_replace 后可能失效(2026-07-01 凤雅案)

tracked_replace 后段落内部变成 del+ins 混合,add_clause(after_search=...) 的文本匹配可能静默失败(不报错但不插入)。正确做法:先做完所有 tracked_replace,再用 lxml addnext 直接按段落索引插入新条款。详见 references/add-clause-after-tracked-replace-failure.md

新增条款规则

  • 独立 w:p 段落,不追加在上一条末尾
  • ⚠️ 格式克隆必须匹配层级:新增的子条款(ilvl=1)必须从原文同层级的子条款段落复制w:pPr和w:rPr,不能从主条款标题(ilvl=0)复制。关键区别在w:ind的left和hanging值——主条款标题和子条款正文的缩进不同。错误复制会导致新增条款缩进与上下文不一致
  • 具体做法:在原文中找到紧邻的同ilvl段落,完整复制其pPr(包括w:ind、w:spacing、w:numPr等),不能跨层级复制
  • 编号接续:插入位置的编号接续前一条,后续原文编号修订模式顺延
  • numPr处理取决于原文编号体系(自动编号保留numPr,手动编号strip numPr)。⚠️自动编号(B类)切忌用add_clause(它无条件剥numPr)——手法见 references/auto-numbered-list-clause-insert.md(克隆锚点pPr留numPr + ¶标w:ins + 倒序插入,新条款自动续编、原文自动顺延)
  • 新增条款名称格式必须与原文条款名称格式一致:字体、字号、缩进量、加粗/不加粗,从原文紧邻的同层级段落复制
  • 新增heading段落的sz必须匹配heading样式定义,不是body样式(2026-06-15铁律):add_clause新增heading段落(如style=2的条款标题)时,INS run的sz必须用heading样式的sz,不能用body_rpr的sz。例如:heading 2样式定义sz=24(12pt),body正文sz=21(10.5pt),新增标题的INS run应设sz=24。ContractEditor的add_clause()目前使用_title_rpr(从原文heading提取),如果_title_rpr中缺sz,需从styles.xml的对应heading样式补上。手动构建时同理——必须读styles.xml确认heading样式的sz值
  • 新增条款下一级条文内容格式必须与原文一致:编号、字体、字号、缩进量、加粗/不加粗
  • 内容匹配条款主题

在已有numPr自动编号的段落插入手动编号前缀时,必须strip numPr + pPrChange内的numPr(2026-07-01 反委托代发协议实证)

当原文段落有 <w:numPr>(如 numId=3 → decimal "%1." 自动编号),你用 w:ins 在段首插入手动编号(如"第一条 ")后,OnlyOffice 会同时渲染自动编号和手动编号,显示为"1. 第一条..."。

修法(三步,缺一不可)

  1. 移除该段 pPr/numPr
  2. 移除该段 pPr/pPrChange/pPr/numPr(pPrChange 记录修订前的 pPr,若不清除旧 numPr,接受修订版仍会渲染出自动编号)
  3. 对所有需要加手动编号的段落批量处理,不能只改一个段落

判别:插入手动编号前,先检查目标段落 pPr 是否有 numPr。有→先 strip 再插 INS。

易错

  • 只 strip pPr/numPr 不 strip pPrChange 内的 → 接受修订后仍显示"1."
  • 跨两段的条款(如第一条正文跨 P6+P7),P7 也可能有独立的 numPr 需要 strip
  • DEL-only 空段(原文已被全部删除的段落)如果有 numPr 也会渲染出编号,一并 strip

代码模式

for pidx in target_indices:
    p = paras[pidx]
    ppr = p.find(f'{WNS}pPr')
    if ppr is not None:
        # Strip numPr
        num_pr = ppr.find(f'{WNS}numPr')
        if num_pr is not None:
            ppr.remove(num_pr)
        # Strip pPrChange内的numPr
        ppc = ppr.find(f'{WNS}pPrChange')
        if ppc is not None:
            inner_ppr = ppc.find(f'{WNS}pPr')
            if inner_ppr is not None:
                inner_num = inner_ppr.find(f'{WNS}numPr')
                if inner_num is not None:
                    inner_ppr.remove(inner_num)

与 Rule 5 的关系:Rule 5 讨论 add_clause 创建新段落时的 numPr 处理(A类strip/B类保留)。本条讨论的是在已有段落前插入 INS 编号前缀时,该段落自身的 numPr 必须清除——这是两个不同场景。

金额/数量/单价绝不直接修订(铁律,2026-07-02 Doro纠正)

即使算术明确(如数量2×单价19600≠成交总金额19600→"应该是39200"),也绝不直接修改。因为无法判断是数量错、单价错、还是成交总金额错——只有当事人知道。只能在有问题的成交总金额单元格加批注"请注意确认金额"。定位目标单元格时必须打印整行所有列确认是哪一列,不能找到第一个匹配的文本就动手。

金额/比例不一致的批注风格(2026-07-08 Doro示范,同日实战纠正)

当合同中比例与金额不匹配(如写"30%即5328元"但15984×30%≠5328),批注方式:

  • 批注范围:覆盖完整的矛盾区间——从第一处比例/金额提及一直到最后一处(如从"支付项目经费的30%,即人民币5328元"一直到"70%经费发票,即人民币10656元(大写:壹万零陆佰伍拾陆元整)"),把所有相关的比例+金额全部圈住,不只标注一个点
  • 批注内容:简洁一句指出矛盾——"支付比例与金额不符,请注意确认"
  • 不在批注里替对方算数(不写"15984×30%=4795.20≠5328")
  • 不在批注里建议解决方案(不写"请确认以比例为准还是以金额为准,并相应调整")
  • 不做详细分析(不写"实际为总额的三分之一")
  • 不给长篇大论(不要解释数学推导过程)
  • 只点出什么和什么不一致,让对方自己确认——他们比你更清楚意图
  • Doro原话(2026-07-08):"如果是我,我会从支付30%一直到70%xxx元批注'支付比例与金额不符,请注意确认'"——范围要宽,文字要短

扩展适用:所有"数值A与数值B存在矛盾"的情形(如面积×单价≠总价、期限描述与日期计算不符、百分比之和≠100%等),都按此模式:圈住完整矛盾区间 + 一句话点明"XX与XX不符,请注意确认"。

批注修改技术(扩大commentRangeStart/End范围):当需要把已有批注的覆盖范围从几个字扩大到整段时,操作步骤:①用zipfile+lxml定位原commentRangeEndcommentReference run并移除 ②在新的结束位置(如"壹万零陆佰伍拾陆元整)"所在run之后)插入commentRangeEnd+commentReference run ③同时修改comments.xml中的批注文本。2026-07-08实证:爱在党群合同原批注只覆盖"支付项目经费的30%,即"6个字,需扩大到覆盖从30%首付到70%尾款的完整区间。

¶标记INS必须完整(铁律,2026-07-02 人事档案案教训)

用zipfile+lxml手动构建新段落的pPr/rPr/ins(¶标记)时,必须设置w:author="WB"w:date属性。缺少author会导致OnlyOffice显示为其他修订人。代码模式:

ins_mark = etree.SubElement(rpr_in_ppr, f'{WNS}ins')
ins_mark.set(f'{WNS}id', str(next_id))
ins_mark.set(f'{WNS}author', 'WB')  # 必须!
ins_mark.set(f'{WNS}date', '2026-07-02T10:00:00Z')  # 必须!

comments.xml编写:字符串不能用bytes literal(2026-07-02教训)

用Python写comments.xml时,不能用b'...\u5b8b\u4f53...'(bytes literal)——unicode转义在bytes中不解析,会直接把\u5b8b\u4f53字面写入文件显示为乱码。必须用普通字符串'...宋体...'然后.encode('utf-8')或直接zout.writestr(fname, xml_string.encode('utf-8'))

保留原文批注锚点(铁律,2026-07-01 Doro反复纠正)

替换段落文本时,只删除 <w:r> 元素,绝不删除批注锚点元素。原文的 <w:commentRangeStart><w:commentRangeEnd><w:r><w:commentReference> 必须原样保留。

批注丢失后的恢复:当批注在编辑过程中丢失时,从原始文件的comments.xml恢复所有原始批注,并重新分配非冲突ID。完整恢复脚本+ID重编号+验证见 references/comment-restoration-from-original.md

错误做法(2026-07-01 反委托代发工资协议案,Doro两次退回):

for child in list(p):  # ❌ 删除所有子元素,包括批注锚点
    if child.tag != f'{WNS}pPr':
        p.remove(child)

正确做法

for child in list(p):
    tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
    if tag == 'r':  # ✅ 只删除run元素
        p.remove(child)
    # commentRangeStart/End/Reference 保留不动

验证:修改后检查段落内是否存在 <w:commentRangeStart> 元素,数量应与修改前一致。

多版本交付:当从同一原始文件制作多个修订版本(v1/v2)时,每个版本都独立保留原始批注。不要假设"版本1已经处理了批注,版本2可以跳过"。

多版本交付的作者归属(2026-07-01 反委托代发工资协议案)

当用户在OnlyOffice中编辑过文件(产生新作者如"华诚-Z"),后续合并时应:

  1. 从用户编辑过的版本开始(不从原始文件重新开始)
  2. 遍历所有 <w:ins><w:del> 元素,将非WB作者改为WB
  3. 保留用户的具体编辑内容,只改作者名

嵌套修订合并(Author B修改Author A的tracked changes,2026-07-03 模特合作协议案)

当用户(如华诚-Z)在已有WB修订的文件上做二次编辑,结果是华诚-Z的w:del嵌套在WB的w:ins内——这意味着华诚-Z删除了WB曾经插入的部分文字。用户说"以华诚-Z为准"时,三步合并:①接受嵌套的华诚-Z del(从WB ins中移除)②清空变空的WB ins元素 ③统一author为WB。不能只跑 unify-author-wb.py(它只改名不处理嵌套)。合并后可继续追加新的WB修订(如回归模板的条款修改)。完整算法+代码见 references/merge-layered-revisions-with-priority.md

错误做法:从原始文件重新开始做修订 → 丢失用户在OnlyOffice中的编辑。 铁律:用户说"用原文件作为修订的基础版本"指的是原始批注/格式,不是丢弃用户的编辑。先确认用户编辑过的版本是否存在(如 /tmp/v1_doro_updated.docx),优先在此基础上叠加。

含第三方修订痕迹的文件:改前必备份,覆盖即丢失(2026-07-01 Doro反复纠正)

当文件包含第三方(非WB)的tracked changes(如华诚-Z、Crystall等人的修订)时,在对文件做任何修改之前,必须先保存带时间戳的备份。覆盖含第三方修订的文件 = 不可逆地丢失他人的编辑痕迹。

  • 实证:反委托代发工资协议,华诚-Z在OnlyOffice中做了3处修订(第六条去法条引用、第七条简化纠正流程、第八条加退回员工安置)。后续制作版本1/版本2时,直接在 /tmp/ 的中间文件上操作并覆盖,导致华诚-Z的修订痕迹全部丢失,Doro要求恢复时已无法找回。
  • 铁律
    1. 改前备份cp <file> <file>.bak_<timestamp> — 在任何修改之前
    2. 不覆盖含第三方author的文件:如果文件中存在 w:author 不是 WB 的 w:ins/w:del,绝不在该文件上直接操作
    3. 多版本制作时的正确流程
      • 原始文件(无任何修订)→ 备份为 original.bak
      • 含第三方修订的文件(如华诚-Z版)→ 备份为 with_huacheng.bak
      • 版本1 = 从原始文件 + WB修订 + 第三方修订(author改WB)
      • 版本2 = 从原始文件 + WB修订 + 第三方修订(author改WB)+ 额外保护条款
    4. 作者改名只在副本上做:先 cp 出工作副本,在副本上改作者名,原件保留
  • 判别:修改前用 zipfile+lxml 扫描 w:author 属性,发现非WB作者 → 触发备份流程
  • 恢复:如果已覆盖丢失,先用系统化文件扫描在 /tmp 所有中间文件中查找仍含目标作者的文件(references/systematic-file-recovery-lost-authors.md)。找不到时,只能从 session 记录中还原第三方的修订内容(如华诚-Z的3处修改),但原始修订痕迹(id、时间戳、精确位置)无法恢复
  • 修改已有tracked changes的作者和文本:用lxml+zipfile直接操作w:ins/w:del元素的author属性和w:t文本(references/modify-tracked-change-author-text.md

待审查目录管理铁律

只有用户说"pass"后才能从待审查目录移除原文件。即使交付文件已上传到任务交付目录,待审查中的原文件也必须保留,直到用户明确确认。

  • 2026-07-01教训:在重新审查反委托代发和生育友好时,把原文件从待审查删了,但Doro没有pass这两份合同。被要求恢复。
  • 交付≠pass。交付只是上传修订版,pass是用户确认审查通过。

浮动图片检查

  • 修订前检查所有 wp:anchor 类型drawing,无关图片删除

页眉页脚(修订模式同样适用)

  • 正文修订后必须检查header*.xml和footer*.xml
  • 页眉页脚中新增的所有内容(包括"法律顾问修订版"等标识文字)必须使用修订模式(w:ins, author=WB),不得以纯文本直接写入。纯文本写入的页脚在OnlyOffice中不显示为修订,Doro无法看到是我们加的
  • 2026-06-11教训:健康云服务合同-练塘的footer中"法律顾问修订版"被editor以纯文本写入footer1.xml,没有w:ins包裹,导致该文字不在修订模式中。修复方法:用lxml将footer中的目标w:r包进w:ins元素(设置id/author=WB/date)

审阅别人的修订

  • 保留别人的修订不动(不接受也不拒绝)
  • 用自己的修订模式在上面加修改

操作顺序(关键!)

铁律:始终从原始源文件开始,不要迭代修改已修改的版本

多版本创建时的致命陷阱:当需要创建多个版本(如版本1法定安排、版本2反委托保护)或重新制作某个版本时,必须每次都从原始源文件重新开始,而不是基于已修改的版本继续修改。迭代修改会导致:

  • 批注锚点(commentRangeStart/End/Reference)被意外删除或重复
  • 修订标记(ins/del)嵌套混乱,tracked_replace失败
  • 字体格式丢失或冲突
  • 段落索引偏移导致插入位置错误

正确流程

  1. 保存原始源文件的副本(如 /tmp/original.docx
  2. 每次创建新版本时,重新从原始副本开始
  3. 一次性完成所有修改(tracked_replace + add_clause + 删除段落 + 添加批注)
  4. 保存为新版本文件

错误流程(导致崩溃):

  • 修改原始文件 → 保存为v1
  • 基于v1继续修改 → 保存为v2
  • 发现v1有问题,基于v1修改 → 保存为v1_new
  • 基于v1_new再修改...(级联错误)

详见 references/multi-version-creation-pattern.md

必须使用脚本库(最新版在 /home/maggie/contract-work/)

import sys; sys.path.insert(0, '/home/maggie/contract-work')
from contract_docx_lib import ContractEditor

⚠️ 活代码在 /home/maggie/contract-work/contract_docx_lib.py,skill目录下的 scripts/contract_docx_lib.py 可能滞后。始终从 /home/maggie/contract-work/ 导入。

库也用于诉讼文书(监督申请书/起诉状/答辩状等)

ContractEditor 不止合同——审/改诉讼文书同样用它。差异点:①大段改写用「整块 del+ins」而非字符级 diff(否则 markup 交错不可读,Doro 看的就是修订态)②引号字体、半角括号这类问题改 styles/numbering 层不改文档层 ③validate 的"不应加粗"规则对加粗的请求项会误报(诉讼文书请求项常加粗,与合同正文不加粗体例不同)④修订 author 按文书归属定(Doro 文书上署"小Maggie",合同历史署"WB")。整块替换、整段修订删除(让自动编号重排)、引号转仿宋、半角括号转全角等可复用技法见 references/litigation-doc-tracked-changes.md新成稿文书(非修订态)的技法——法条原文脚注(从同案姊妹文书克隆 affb/sz18 脚注体例 + 脚注标用字符流定位精确落在条号后)、用同案文书做母版保同源成稿(含清页眉删 pBdr)、⚠️Doro 编辑器回传的 docx 每轮都丢显式 eastAsia 字体属性、每次交付前都要全局补仿宋再渲染——见 references/legal-doc-footnotes-and-templating.md法条原文脚注(Doro偏好:引法律规定一律脚注呈现原文不删改)、克隆母版段落建配套新文书(保同案同源)、源档丢eastAsia字体的规范化等技法见 references/litigation-doc-footnotes-and-templates.md(脚注格式从同族footnotes.xml克隆、引用标用split-run精确插在条号后/多run锚点必踩坑、清错配页眉+pBdr横线)。

审查意见文档格式修复(2026-07-13 Doro指示,2026-07-14 朱家角眼科设备补充)

生成审查意见文档后的两项必做格式修复:

  1. 删除表格中的空白行:模板中表头后通常有4-5行空白占位行(三列全空),交付前必须删除
  2. 页眉日期改为修订当日:模板页眉(header2.xml)中的日期(如"2019/3")改为当日(如"2026/7")。注意日期可能被拆成多个run(如"201"+"9"+"/"+"3"),需逐run定位修改。⚠️ 不能只改文件名占位符,不改日期;也不能嘴上说改了而不重新读 header2.xml 验证。必须以再次读取 header2.xml 的结果为准。

标题:规则写"标题《》内填写所审查的合同名称"——必须读合同正文P0的标题全称,不能只看文件名。文件名"医疗合同(2)"≠合同标题"医疗设备器械购销合同"。

内容来源铁律(2026-07-14 朱家角眼科设备案):用户如果明确指出"文件是我改过的,不是你的版本",说明审查意见的依据必须是用户当前已修改并实际交付的【修】文件,不是你手头旧副本、不是你之前做坏/做偏的本地版本。正确顺序:①先读取当前任务交付目录里的最新版【修】;②按该版中实际存在的 WB 修订制作【审】;③不能把自己旧版本里曾经出现过但当前文件里已不存在的条目继续写进【审】。

上传目录铁律(2026-07-14 朱家角眼科设备案):本类【审】审查意见的交付目录口径是 Doro合同审查任务/任务交付/。不能只传顾问单位专属子目录后再解释"文件其实在容器里"。用户问"文件该上传到哪"时,重点不是路径知识,而是你是否按交付口径放对目录。必须先放对主交付目录,再谈其他目录副本。

交付纪律(2026-07-14 连续纠正):用户说"全部改好"时,不允许再用"我继续如实汇报还差一点"代替成品。审查意见文档至少要同时满足:①标题正确;②模板无关内容已删除;③表格内容按当前【修】准确落表;④页眉日期改成当天并经 XML 再验证;⑤上传到正确交付目录。缺一项都不应说"改好"。

已审查文件重复处理检测(2026-07-13 香花桥安全生产案)

生成审查意见时必须按模板实际参数设字体(不凭记忆),规则见 references/review-opinion-generation.md。核心:只写差异不做理由说明、数据行sz=24(12pt)+eastAsia=仿宋+ascii=TNR+hint=eastAsia、有修改意见则删"无法律修改意见。"。另见 references/review-opinion-format-checklist.md(2026-07-13:删表格空行+页眉日期改当日+标题用合同正文全称)。

Post-save INS属性修复脚本(必跑,2026-07-13确立)

ContractEditor的tracked_replace对继承型文档(原文run无显式ea/hint/sz)会给INS添加多余属性(ea=宋体, hint=eastAsia, sz=21)。save后、font-verify前必须跑

python3 ~/.hermes/skills/legal/contract-editor/scripts/strip-inherited-ins-attrs.py <output.docx>

脚本逻辑:逐段找第一个plain run做参照,参照无的属性从同段WB INS中strip。跑完再跑wb-ins-font-verify.py确认PASS。

Doro纠正:缩进/字体大小错了时,不在旧坏文件上补丁式修,直接回原文重做(2026-07-13 白鹤劳务派遣协议)

当Doro已明确指出**“缩进、字体大小都错”**时,说明问题不是单个INS run的小脏点,而是交付件整体格式已经失真。此时不能继续在旧的【修】文件上做局部patch(补一个空格、删一个sz、strip几个rFonts)企图救回来。

正确做法:

  1. 重新读workflow/修订规则,不要沿用前一次“我以为只差一点”的判断;
  2. 回到待审查原文/原始docx重新开始,不要以旧坏交付件为基底;
  3. 只把确认需要的修订重新做一遍,再重新生成新的【修】文件;
  4. 生成后再跑wb-ins-font-verify.pypython-docx打开验证;
  5. 验证通过后覆盖任务交付并把附件实际发给Doro。

为什么:

  • 旧坏文件上的局部patch容易留下新的不一致:某些标题缩进修了,其他标题没修;某些段落字号修了,别的段落仍是污染属性;
  • Doro这类反馈的真实含义不是“再补一刀”,而是“你上一版的格式判断不可信了,回原文重做”。

白鹤案实证: 前一版我误判为只需清理eastAsia/hint/sz和个别标题前导空格;Doro直接指出“缩进、字体大小都错,重新读workflow的修订规则,重新全文看、改,改好了上传”。最终正确路径是:从原始白鹤--劳务派遣协议.docx重新生成修订版,而不是继续在旧【修】文件上局部补丁。

用户说“对照原文件,按照workflow规则,重新处理【修】”时,默认基底是原文,不是旧【修】(2026-07-14 朱家角眼科设备合同)

当用户明确要求:“对照原文件,按照workflow的规则,重新处理【修】、精准修订、格式、内容都一并修复”,且随后强调**“你自己找”**时,执行含义应默认为:

  • 先自己定位原文与现有【修】;
  • 以原文为基底重做,而不是在旧【修】上继续补丁;
  • 重做后把新的【修】覆盖到任务交付目录。

不要误解为:先向用户追问路径,或默认只能在旧【修】上修修补补。这里的“重新处理【修】”是交付物口径——目标是产出新的【修】文件,不是限定必须以旧【修】为编辑基底。

朱家角眼科设备案实证:

  • 用户先指出旧结论有问题,并要求“对照原文件,按照workflow规则,重新处理【修】”;
  • 后续又明确说“你自己找”;
  • 正确动作是:自行从 cache/临时目录定位原始 .doc,转成 .docx,对照旧【修】识别错误后,从原文重做新的【修】朱家角(眼科设备)合同26.7.13.docx,再放回任务交付/

适用边界:

  • 如果用户明确说“恢复workflow修订版再改”或“就在这版上改”,才以现有修订版为基底;
  • 若用户只说“对照原文件重新处理【修】”,默认应回原文重做。

用户要求“进一步修改,包括编号、加粗、缩进、字体及大小等在内的格式、内容,并参考待审查原文件进行精细化修订”时,必须把任务定性为“原文精修重建”,不是继续试错式patch(2026-07-14 白鹤劳务派遣协议)

当用户已经明确指出**“字体、编号都有错”**,随后进一步要求:

  • “通读workflow的修订规则”
  • “去进一步修改,包括编号、加粗、缩进、字体及大小等在内的格式、内容”
  • “参考待审查原文件进行精细化修订的调整”

则本次任务必须定性为:以待审查原文件为唯一基底,按原文结构做精细化重建。这不是“在坏版上继续局部补丁”,也不是“先试一版再说”的容错场景。

强制执行含义

  1. 先通读workflow规则,再读原文,不能直接对着坏版动手。
  2. 原文是唯一可信基底;旧【修】只用于识别“要保留哪些实质修订”,不作为格式基底。
  3. 格式审查至少覆盖五项:编号、加粗、缩进、字体、字号;不能再用“字体脚本PASS”代替完整结论。
  4. 内容修订必须精细化
    • 错字按字符级/片段级修;
    • 标题编号不得粗暴 tracked_replace('八、', '十、')
    • 新增条款必须按明确锚点插入,避免正文与标题倒挂;
    • 涉及已有段落重写时,要防止旧句残留与新句并存。
  5. 中间版不合格时,不得上传试错。只报告真实验证结果,继续回原文修。

白鹤劳务派遣协议案暴露出的典型坑

  • 标题顺延粗改会把手动编号合同做坏:出现 八十、违约责任九十一、特别约定八、违约责任十、违约责任 这类“旧标题+新标题串联”的灾难性结果。
  • 错字修订若不做字符级控制,会重复叠字或误删:如 建立建全建立健全建全健全,甚至把整句骨架弄断。
  • 新增条款若只按文本搜索后插入,可能正文先于标题、顺序倒挂:例如先出现“第三方侵权责任”正文,后面才出现标题。
  • 字体验证进入“无同段原文可比”阶段时,不代表可以交付:即使脚本只剩 MISSING HINT (无同段原文可比),仍必须继续核编号链、段落顺序、标题/正文结构和接受修订后的可读性。

因此形成的操作铁律

  • 用户把“格式”拆成编号、加粗、缩进、字体、大小五项时,必须逐项核,不得再用单一脚本、INS数量或段落数替代结论。
  • 用户明确要求“参考待审查原文件进行精细化修订”时,默认进入原文精修重建模式
    • 原文负责格式与结构;
    • 旧【修】只负责提供应保留的法律性修改点;
    • 不允许在坏版上连续试错式 patch。
  • 任何中间版一旦出现“旧标题+新标题并存”“旧句+新句并存”“正文标题倒挂”“错字越修越多”等迹象,说明路线错了,必须立即回到原文重新组织修订。

用户要求“重新审查【修】”时,审查范围必须覆盖编号、加粗、缩进、字体及大小,不能只看段落数/INS数(2026-07-14 共建服务协议书+朱家角眼科设备)

当用户让你“按同样标准重新审查【修】”或直接指出“格式包括(编号、加粗、缩进、字体及大小)”时,格式审查结论必须覆盖这五项,不得再用“段落数差异不大 / WB INS数量 / 字体脚本通过”替代完整判断

用户说“核查”=做完整质检,不是指哪打哪(2026-07-14 朱家角眼科设备合同)

当用户已经明确说“核查【修】有没有问题”“我看到有问题”“重新审查已交付的【修】”时,默认任务含义不是围绕用户刚点到的一两个点做回应式排查,而是:按 workflow 全标准对整份【修】做一次完整质检式审查,把问题尽量找全。

⚠️ 但先把核查对象说准(2026-07-14 Doro连追两次):如果用户明确追问“我让你检查谁的修订”,必须立即收缩范围并答准对象(如“查 WB 的修订”),之后的核查和汇报都围绕该对象展开。不能一边说“全面核查”,一边把“整份文件问题”和“WB 修订问题”混在一起汇报,否则用户会认为你根本没听懂指令。

先确认核查对象是谁,再开始查。 这类指令下,用户可能明确要求“查 WB 的修订”,也可能要求查整份文件。不能把“整份合同全面核查”和“只查 WB 修订”混为一谈。用户一旦追问“我让你检查谁的修订”,说明上一轮范围没扣准——必须立刻收缩并重新汇报。

解释指令时,必须只解释用户原话的准确含义,禁止顺手外延发挥。 若用户只是让你复述/解释其指令,回答应严格限于:核查对象是谁、先做什么、是否修改、何时汇报。不要额外补进“我会顺便检查签署页/原文结构/其他所有问题”等超出原话的延伸。只要用户随后追问一句“我让你检查谁的修订”,就说明你上一轮解释已经掺入了不该掺的范围。

执行含义:

  1. 不做“定点答题”——不能只围着用户刚追问的那一处展开;
  2. 默认前提是:用户既然要求“核查”,说明其已经怀疑交付件存在问题,agent应主动扩大检查范围;
  3. 审查输出应覆盖:
    • 编号
    • 加粗
    • 缩进
    • 字体及大小
    • 精细化修订是否准确
    • 新增条款位置与条款逻辑
    • 是否有残留/误删/重复/错位
    • 签署页是否完整
    • 原文内容是否被不当裁剪
    • 原文已有内容/结构是否被改乱
  4. 对照待审查原文件逐项核:不仅回答“这版看起来顺不顺”,还要回答“相对于原文件,哪里该改没改、哪里改过头、哪里格式继承错了”;
  5. 输出目标是问题清单,不是即时辩解。先尽量找全,再分类(严重/一般,格式/内容,精细化不到位/结构性问题)。

表达纪律:

  • 用户没让你“只解释某一项”,就不要把注意力锁死在某一项上;
  • 如果前一轮回复只抓了单点、漏了整体问题,下一轮必须切换到整份复审模式,不能继续局部拉扯;
  • 在“我说pass你再看下一份”的流程下,当前这份没完成前不得跳看下一份。

强制检查项:

  1. 编号:主编号链是否连续;新增条款是否带编号;新增后后续编号是否顺延;必要时用 numbering-diagnose.py + 实际段落文本双重核对。
  2. 加粗:新增标题、顺延后的标题、子编号首run的 bold 状态是否与原文同级一致;不能只看正文字体脚本 PASS。
  3. 缩进:对比 w:ind(start/hanging/firstLine)与原文同级段落;如果原文同类段落靠文本前导空格缩进,还要看文本层面。
  4. 字体及大小:至少抽样核对关键修订段落的 rFonts/szwb-ins-font-verify.py 只是辅助,不是全部结论。
  5. 结构可读性:用 python-docx 或实际打开视图检查是否出现空段异常、段落丢失、签署页链条断裂、标题和正文脱节。只看 XML 层不够。

结论表达纪律:

  • 如果编号文本已经改对,但存在空段异常、签署页丢失、字体/缩进未核完,不能说“格式没有被改乱”
  • 必须分开写:
    • 编号是否正常
    • 加粗是否正常
    • 缩进是否正常
    • 字体及大小是否正常
    • 是否存在结构异常
  • 有任何一项未过,就应明确说**“格式审查标准下未完全合格”**,不要用“整体大概率没问题”糊过去。

日间照料中心案教训: 只看到 wb-ins-font-verify.py PASS、段落数一致、XML中的 本协议 已插入,就容易误判“格式无问题、内容已修好”;但 python-docx 读取接受视图时仍显示 三、一式二份,说明修订结果在实际阅读层面并不稳。因此,凡是用户把“格式”明确拆成五项时,审查必须按五项逐项落结论,不得再用单一脚本或段落数替代。

2026-07-14 朱家角眼科设备补充铁律:

  • 用户追问“精准修订没做到、该加粗的地方没加粗”时,说明交付问题不是抽象的“格式还差一点”,而是修订粒度 + 标题/编号加粗两项都没过。此时必须把“精准修订”和“应加粗位置”作为独立检查项重新核,不能只回到字体脚本。
  • 用户说“手动改好上传”时,含义是先改好,再上传。如果自己验证仍未过,上传行为本身就是错的;不能以“先上传再如实汇报还有问题”替代完成任务。
  • 交付纪律:凡是自己都不能明确说“已改好”的版本,不得上传到任务交付目录。上传不是进度汇报工具,只能是合格成品的交付动作。

在已存在WB修订的段落上重写措辞:先读子元素结构,禁止只改一段INS文本(2026-07-13 白鹤劳务派遣协议)

当目标段落已经是多段WB修订碎片混合结构(如 RUN + DEL + INS + RUN + DEL + INS + RUN + INS),不能只图省事去修改其中一段 w:ins 的文本内容,否则极易出现:

  • 新句子写进了第一段 INS;
  • 旧的 RUN/DEL/INS 碎片还留在后面;
  • 接受修订后形成 重复句、断裂句、半个词残留(典型表现:乙方全额赔偿。、重复的“消除影响”)。

铁律:改这类段落前,必须先把该段所有直接子元素逐个打印出来,确认真实结构,再决定如何修改。

最低操作纪律

  1. 先列出目标段落的全部直接子元素(按顺序看 RUN / DEL / INS),不要只看 accepted text。
  2. 如果目标语句横跨多个修订碎片,要么整段重做,要么把相关旧碎片成组删除,不能只改第一段 INS。
  3. 修改后再次打印该段的直接子元素,确认只剩下预期的 DEL + INS 组合。
  4. 最后再看 accepted text,确认没有重复尾句、残留半词、残留旧删除链。

白鹤案实证:P70 原结构是:

  • RUN: ...甲方
  • DEL: 有权依法向
  • INS: 有权要求乙方赔偿甲方因此支付...
  • RUN: 乙方
  • DEL: 追
  • INS: 全额赔
  • RUN: 偿。
  • INS: 如对甲方造成其他不良影响的,乙方还应当消除一切影响。

如果只改第一段 INS,会导致 accepted view 变成: ...甲方有权要求乙方赔偿甲方因此支付...乙方全额赔偿。如对甲方造成其他不良影响...,甚至出现重复尾句。

正确修法

  • 先保留前半句 RUN + DEL + 第一段INS
  • 再把后面失效的 RUN/DEL/INS 碎片整组删除(如 RUN:乙方DEL:追INS:全额赔RUN:偿。、重复尾句 INS)
  • 修改后再次核 accepted text

结论:凡是用户让你“修一句话”,但该句所在段已经被多轮 tracked changes 打碎,先做结构审计,再动文本。别对着 accepted text 直接下刀,那是修文书,不是拆炸弹;而这类段落,恰恰就是拆炸弹。

用户明确要求“恢复workflow修订版”或“修改两个workflow完成的【修】”时,禁止从原文重做(2026-07-13 白鹤劳务派遣协议;2026-07-14 朱家角眼科设备/日间照料中心)

当用户明确说:“恢复workflow的修订版,读规则,查修订的问题,改。”“去按照workflow的规则修订两个workflow完成的【修】”,或先要求“检查 WB 修订”再进一步说“手动修改,好了上传”时,表示本次返修的基底已经被指定为 workflow 交付版【修】,而不是原文。

铁律

  1. 先恢复到 workflow 修订版(核对文件hash/大小或来源路径),再动手;
  2. 之后只允许在 workflow 版上做最小必要修复或进一步精细化修订;
  3. 禁止擅自回到原文重做整份文件;
  4. 禁止把“我觉得从原文重做更干净”当作理由覆盖用户指定基底;
  5. 用户说“参照原文”时,含义是以原文为校准尺核对格式/内容/精细化修订,不等于把原文当编辑基底
  6. 修复前先回答两个问题:①当前交付件是不是 workflow 产物?②用户要的是“修好这版【修】”还是“重做新的【修】”?没搞清前不能动手。

原因:用户纠正的不是“修得不够多”,而是“你改出来的更差”。这类指令的实质是:不要发明新版本,不要扩大改动面,只修 workflow 版里实际有问题的点。

执行顺序

  • Step 1:恢复 workflow 版到工作路径;
  • Step 2:明确本次核查/返修对象(整份【修】 vs 仅 WB 修订)。若用户追问“我让你检查谁的修订”,必须先答准范围再动手;
  • Step 3:逐项核查 workflow 版的真实问题(格式/缩进/字体/内容/精细化修订);
  • Step 4:参照原文,只修被核实的问题点,不能把“修错误的新增方式”误做成“删除新增内容本身”。若新增条款内容本来应保留,正确动作是重做其修订方式/格式/位置,而不是整段删掉;
  • Step 5:验证后再上传。上传前必须确认你不是在明知仍有关键问题时交付。 若用户直接命令“做好上传”,也只能在确已修好并自检通过时上传;如果自己验证仍未过,继续修,不得把“先上传再如实汇报还有问题”当成完成任务。Doro多次直接纠正:未完成品上传没有意义,也不应把返工压力推回给用户。

2026-07-14补充铁律(Doro明示)

  • “你的修订是很差的,你不要替代workflow。” —— 这是对方法的直接纠正,不是情绪表达;
  • 当用户要求“修改两个workflow完成的【修】”时,任何把原文直接生成为新【修】、再覆盖任务交付的做法,都属于替代workflow,即使你主观上认为内容更干净,也算违反指令。
  • 因此,针对已交付【修】的返修任务,默认工作对象=现有【修】文件;原文只用于逐项比对和校准,不作为重新起稿基底。

新增标题段缩进必须匹配原文多数标题(2026-07-13 消防设施检测案)

原文标题段的缩进可能不统一(如一个用firstLine=482,其余用start=420)。add_clause克隆邻近段落的pPr,可能恰好克隆了少数派格式。

诊断:新增标题段时,先遍历原文所有同级标题段的w:ind,取众数格式(多数标题用的格式)作为参照。不要只看紧邻的一个段落。

实证:消防合同原文"四~八、十"都用start=420,只有"九"用firstLine=482add_clause克隆了"九"的pPr,导致新增"十、转包"和"十一、侵权"缩进与其他标题不一致。

编号/格式核对用OnlyOffice渲染

诊断编号问题或交付前自查,用 scripts/onlyoffice-render.sh <docx> [pdf] 把合同渲染成PDF(OnlyOffice引擎=Maggie实际所见),再pdftotext -layout数编号链或pdftoppm转图发Maggie确认。详见上文「编号问题诊断纪律」。

编号来源诊断脚本(必跑,先于动手)

scripts/numbering-diagnose.py <docx> 一次性摊开:①numbering.xml的 numId→(numFmt,lvlText,start),②每段是否带numPr/ins/del,③rendered列显示OnlyOffice会自动加的编号(run里没有的字)。专治"自动编号vs手动编号vs源文件潜伏编号"三类混淆。.docsoffice --headless --convert-to docx。详见上文「编号撞号的第三种成因」。

用户指出“编号不对”后的处理铁律(2026-07-14 朱家角眼科设备合同)

当用户已经明确指出**“编号不对,重新查、改”**时,禁止继续沿用之前那版【修】文件做小修小补式 patch,也禁止先嘴上解释“我已经查过”。正确路径必须是:

  1. 先回原文重新核编号链,用 numbering-diagnose.py + 实际段落文本对照,确认原文主编号链(如 8/9/10)和新增条款后应顺延成什么(如 8/9/10→8/9/10/11/12)。
  2. 把当前【修】文件当成嫌疑件而不是基底:如果上一版已经出现 .争端的解决.合同生效.1 本合同… 这类“编号前缀丢失”的现象,说明此前的 tracked_replace/顺延逻辑已经把编号结构做坏了,不能继续信任旧版交付件的局部状态。
  3. 优先从原文重建正确编号,不要在坏版上继续追着补数字。尤其是手动文本编号合同(主编号写死在文本里)中,先新增条款、再对后续编号整段重写/精准替换,比在已损坏的编号段上继续 tracked_replace('8.', '10.') 稳得多。
  4. 修完后必须再次读实际文件文本确认编号链,不能只看 validate() 通过或脚本没报错。最终至少要肉眼核到:新增条款编号正确、后续主编号连续、子编号仍归属正确。

朱家角眼科设备案教训:上一版把 8/9/10 顺延时做坏,实际渲染成 .争端的解决.合同生效.1 本合同在…….2 本合同一式四份…….合同附件……。这类错误说明“编号字符本体”已经丢了——继续在坏版上 patch 往往越补越乱。用户一旦明确指出编号错,默认策略就应切换为回原文重建编号链,而不是继续解释旧版为什么“理论上没问题”。

特殊场景

合同中的"二选一"条款修改

合同中经常有"双方同意按以下第___种方式解决(填选1或2)"的格式。如果前一个审查人(如Crystall)已选了选项1,reviewer要求改为选项2:

方案A:只改选择编号

  • DEL原选择数字"1" → INS新数字"2"
  • 保留两个选项的原文不动

方案B:删除二选一格式,直接重写(reviewer方案)

  • DEL整个"双方同意按以下第X种方式解决:"
  • DEL两个选项段落
  • INS新的直接表述(如"任何一方均可向甲方所在地法院提起诉讼。")

注意:如果前一个审查人的选择是以INS标记的(如Crystall INS "1"),不能直接修改他人的INS内容。正确做法是移除该INS元素,替换为WB的DEL+INS。

操作顺序(关键!)

  1. editor = ContractEditor("原文件.docx")
  2. 内容级 editor.tracked_replace(old, new) — 文本修改(不涉及条款编号)
  3. editor.add_clause(text, after_search) / editor.add_clause_before(text, before_search) — 在合同逻辑对应位置新增条款
  4. 编号级 editor.tracked_replace() — 条款编号顺延(必须在所有add_clause完成后)
  5. errors = editor.validate()必须通过才能save

⚠️ 先增后改编号铁律(2026-07-13 练塘合同实证)add_clause/add_clause_before 会改变XML树结构,之后对同一区域做 tracked_replace 可能因元素parent关系变化抛出 ValueError: Element is not a child of this node所有结构性新增必须在编号替换之前完成。 详见 references/operation-ordering-renumber.md。 5. editor.save("【修】原文件.docx") 6. 特殊交付物文件名规则:如需制作流程单等交付物,文件名必须包含合同名称以防同一顾问单位多份合同的交付物互相覆盖。例如:【审】合同流程单(+法务审核)-基层工作人员高温慰问用品采购合同.xlsx,而非通用的【审】合同流程单(+法务审核).xlsx 7. 审查意见文档标题必须包含合同全称(2026-06-29 教训):生成审查意见文档时,标题必须是"关于《XXX合同》的审查意见",其中XXX是classifier识别出的contract_titlecontract_summary中的合同名称。绝不能用泛化的"关于《合同》的审查意见"。从模板生成时,必须替换占位符为实际合同名称。

审查意见文档生成纪律(2026-07-02 朱家角恭兴+肃言案,Doro多次纠正;2026-07-12 盈浦医疗合同案补充)

生成前必须做的事:

  1. 找到并检查实际模板文件(从review-rules.md读取路径),用python-docx读取模板的每个run的rPr(sz/eastAsia/ascii/bold),确认字体规格——不能凭记忆
  2. 查看review-rules.md中关于审查意见的全部规则,workflow规则同样适用于手动操作

生成后必须做的事(2026-07-12 Doro纠正):

  1. 删除表格中的空白行:模板表格中预留的空行(三列全空)必须全部删除,不留空行
  2. 页眉日期改为修订当日的日期:模板页眉中的旧日期(如"2019/3")必须替换为当前修订日期(如"2026/7")。注意日期可能被拆成多个run("201"+"9"+"/"+"3"),需逐run处理
  3. 标题必须填写合同正文中的完整合同名称(从合同P0或前几段读取),不是文件名。规则:"标题《》内填写所审查的合同名称"——这个"合同名称"是合同正文标题,不是文件名的简写

模板结构(以朱家角为例,其他顾问单位按各自模板):

  • P1:标题"关于《XX》的审查意见"——居中、加粗、仿宋16pt(sz=32)
  • P2:"无法律修改意见。"——有修改意见时必须删除此段
  • P3:"审查意见:"——仿宋12pt
  • 表格:条文|原文|修订后
  • 签名:"邱庭 律师"——右对齐、仿宋12pt

字体规格(必须显式设置,不依赖继承):

  • 标题:eastAsia=仿宋, sz=32(16pt), bold=True
  • 表头:eastAsia=仿宋, sz=24(12pt), bold=True
  • 数据行:eastAsia=仿宋, ascii=Times New Roman, hAnsi=Times New Roman, sz=24(12pt), bold=False, hint=eastAsia
  • 每个run都必须显式设sz——不设sz会导致字号回退到默认值,与模板不一致

内容规则(铁律,2026-07-02 Doro纠正):

  • 只写原文和修订后的内容(包括批注内容),不做理由说明
  • 禁止写(注:统一称谓为甲方/乙方)、(注:原引用法规已废止)等解释
  • 原文列写原文,修订后列写修订后的文字,完毕
  • 批注内容也要体现在表格中:条文列写条文位置,原文列写被批注的原文内容,修订后列写批注文字
  • 行顺序按条款号排列

同模板合同一致性:

  • 同模板合同的审查意见,除个案差异行(如某份有金额问题)外,所有模板级修订行必须完全相同
  • 个案行按各合同实际情况处理(有问题就有这行,没问题就没有)
  • 详见 references/same-template-consistency.md

批注精确性(2026-07-02 教训):

  • 批注锚点必须在问题发生的精确位置(如除颤仪成交总金额"19600"单元格),不是随便找个相关cell

  • 金额正确的合同不需要金额批注——这是个案事实差异,不是"不统一"

  • "统一"指的是同一套审查逻辑一致应用,不是机械地给所有合同加相同批注 7b. 审查意见生成必须先读模板确认结构(2026-07-02 铁律):生成前必须先读取模板文件(路径在review-rules.md中指定),确认完整结构(标题格式/字号/加粗、"无法律修改意见。"占位段、"审查意见:"标题、表格、签名),然后严格按模板结构生成。不可凭记忆假设模板长什么样。有修改意见时必须删除"无法律修改意见。"段落。审查意见只写原文和修订后的内容(包括批注内容),不做理由说明——这条规则同样适用于手动操作,workflow规则对小Maggie手动执行时一视同仁。

  • 同模板合同审查意见必须统一(2026-07-02 铁律):同模板多份合同的审查意见,公共修订行内容完全一致、行顺序按条款号排列、字体统一(中文仿宋+英文Times New Roman)。批注内容也必须体现在审查意见表格中。详见 contract-reviewer/references/same-template-consistency.md

  • ContractEditor 库默认 sz=21 与 docDefaults 继承冲突(2026-07-09 施工安全协议+2026-07-13 洋励/盈浦健康科普连续验证):当原文 runs 没有显式 sz(依赖 docDefaults 或 Word 默认继承)时,ContractEditor 的 tracked_replace/add_clause 会给 INS run 加上显式 sz=21+ea=宋体docDefaults sz=22(11pt)≠ sz=21(10.5pt),OnlyOffice实际渲染出半号差异。同理eastAsia:原文走eastAsiaTheme=minorEastAsia时,显式设ea=宋体也可能与主题字体不一致。铁律(0713多份合同连续复现):save后必须跑格式修复sweep——必须per-paragraph匹配,不能全局统一设属性。同一合同不同段落可能有完全不同的字体方案(如P20有explicit ascii=宋体, P36完全无rFonts),全局修复会制造新的mismatch。详见 references/per-paragraph-font-matching.md判断基准永远是"原文同段run有什么INS就有什么,原文没有的INS也不该有"。绝不信任库的_body_rpr/_title_rpr——它们是启发式提取,对继承型文档会填入错误值。

审查意见文档内容规则(2026-07-02 Doro纠正,铁律)

审查意见只体现差异,不做理由说明。 表格三列(条文|原文|修订后)只写原文和修订后的文字,不写(注:……)、不写理由、不写解释。

  • 修订后:甲方同意向乙方购买……(注:统一称谓为甲方/乙方,"授予"修改为"出售"以准确反映买卖关系)
  • 修订后:甲方同意向乙方购买,同时乙方同意向甲方出售以下器械

此规则同样适用于手动操作——workflow规则不因"手动做"而降级或忽略。做审查意见之前先看review-rules.md中的格式要求。

审查意见文档字体硬规则(2026-07-02 两份合同字体不一致教训)

审查意见文档的字体必须严格按以下规则设置,每个run都必须显式设置,不能依赖继承

位置 eastAsia ascii hAnsi bold
表头行 仿宋 Times New Roman Times New Roman True
数据行 仿宋 Times New Roman Times New Roman False
标题/签名 仿宋 Times New Roman Times New Roman False

常见错误

  • 数据行只设eastAsia=仿宋,漏设ascii/hAnsi → 英文/数字回退默认字体
  • 数据行设ascii=仿宋, hAnsi=仿宋 → 英文也变仿宋(应该是TNR)
  • 新增行完全不设字体(依赖模板继承)→ 模板只有表头显式设了字体,新行不继承

根因:模板文件只有表头row的字体是显式设置的,新建的数据行不会继承表头字体。每次新增行必须显式设置所有四个rFonts属性。

生成审查意见的完整模式见 references/review-opinion-generation-pattern.md

同模板合同审查意见一致性(2026-07-02 朱家角恭兴+肃言教训)

同模板合同的审查意见必须统一:

  1. 行顺序:按条款号排列(第1条→第6.4条→第7.1条→...),不能各自乱序
  2. 内容一致:模板级问题(同一条款的同一修订)两份合同的表述必须完全一致
  3. 个案差异单独列:如金额不一致等个案问题,在统一行之外单独加行
  4. 风格统一:要么都不写注释,要么都写(规则是不写) 7b. 审查意见文件名必须包含"-审查意见"后缀(2026-07-02 Doro纠正):审查意见文件命名为【审】原文件名-审查意见.docx,不是【审】原文件名.docx。例如:【审】肃言合同-审查意见.docx。 7c. 审查意见文档字体强制设置(2026-07-02 两份合同字体不一致教训):生成审查意见后必须遍历所有table cell的所有run,显式设置字体:eastAsia=仿宋, ascii=Times New Roman, hAnsi=Times New Roman。模板只有表头行有显式字体,新建的数据行如果不强制设置会回退默认字体。禁止把ascii/hAnsi设为仿宋(仿宋没有西文字形,英文/数字应该用TNR)。完整字体设置代码见 references/review-opinion-font-enforcement.md

审查意见文档生成铁律(2026-07-02 朱家角恭兴+肃言合同教训)

生成审查意见文档前必须先读模板文件的实际XML参数,不凭记忆写代码:

  1. 先读模板Document(template_path) → 检查每个段落/表格run的实际 rFonts/sz/bold
  2. 字体参数从模板来,不从规则文字描述来:review-rules.md写"仿宋体"但没写sz值——必须从模板XML读出sz=24(12pt)才能用
  3. 模板结构严格遵循:标题(居中加粗16pt) → 删除"无法律修改意见。"(有意见时) → 保留"审查意见:" → 表格 → 签名
  4. 数据行格式硬编码:eastAsia=仿宋, ascii=Times New Roman, hAnsi=Times New Roman, sz=24, hint=eastAsia, bold=False
  5. 只写原文和修订后内容(包括批注内容),不做理由说明——不写(注:...)
  6. 批注内容必须纳入表格:合同中加了什么批注,审查意见表里就写什么
  7. comments.xml必须用str写入不用bytes literalzout.writestr(fname, xml_str.encode('utf-8')) 而非 b'...\u5b8b\u4f53...'(后者unicode不解析变乱码)

同模板合同一致性铁律(2026-07-02 朱家角教训)

同一顾问单位同批送审的多份同模板合同,模板级修订必须完全一致

  • 同一个法律问题(如法规引用过时、侵权兜底缺失)在A合同改了,B合同也必须改
  • 个案问题(如A合同金额有误但B合同正确)按各自实际情况处理,不强行统一
  • 审查意见的行顺序按条款号排列,模板级行一致,个案行各自不同
  • 手动修改时:先处理一份确定完整修订清单,再逐份对齐执行
  • 参照已修订合同做同模板修订:当Doro说"参照X合同的修订进行修订"时,必须按五步走:①逐段对比确认模板一致性→②提取WB修订→适配→应用→③INS字体逐个核对→④全文通读accept后审查合理性→⑤交付前检查。不可跳步。完整实现模式+Doro强制验证纪律+pitfalls见 references/same-template-revision-transfer.md
  • ⚠️ 移植修订前必须回看最近讨论过的规则(2026-07-09 铁律,两次纠正):从已交付合同移植修订到同模板新合同时,不能"照搬"——必须逐条检查每个INS的文本是否符合当前最新规则。特别是近期刚讨论/纠正过的规则(如数据归属"归甲方或相关权利方所有"而非"归甲方所有"),这类错误在源文件中可能已经固化,移植时必须同步修正。2026-07-09两次教训:①workflow第1份产出的"归甲方所有"写法是在Doro 07-08确立新规则之前生成的,移植到第2份时直接复制了旧错误;②Doro只说了一句"数据所有权昨天我们刚讨论过"就指出了问题——说明这类规则变更Doro期望我自动适用,不需要反复提醒。检查清单:移植前列出最近3天内skill/rules/memory中新增或修改的审查规则,逐条比对源修订内容。
  • 数据归属表述(2026-07-08 Doro定论,2026-07-09 再次确认):涉及数据权利归属时,归属方固定写"归甲方或相关权利方所有"(不是"归甲方所有")。前面的数据描述内容随合同业务而变,不写死。⚠️ 这是铁律——即使从已交付合同的修订中移植(如同模板合同参照修订),也必须检查此表述是否正确。2026-07-09教训:从第1份华新镇体检合同移植修订到第2份时,直接复制了错误的"归甲方所有"表述,被Doro一句话指出"数据所有权昨天我们刚讨论过"。规则来源=review-rules.md §4。详见 references/same-template-revision-transfer.md 的 Data Attribution Rule 章节

文件命名铁律:【修】/【审】+ 原始文件名(2026-06-29 Doro三次纠正)

交付文件命名是 【修】前缀 + 原始文件名,不是自己重新起名。

  • 【修】合同_朱家角.docx(原文件是 合同_朱家角.docx
  • 【修】购销合同___朱家角.docx(原文件是 购销合同___朱家角.docx
  • 【修】巷泽居委会办公家具采购项目合同.docx(不能用合同标题替代原文件名)
  • 朱家角-巷泽居委会办公家具采购项目合同-修订版-邱庭-20260629.docx(完全错误的命名格式)

原始文件名 = classifier 输出的 original_filename,即邱律师/用户发来的文件名。审查意见文件同理:【审】 + 基于原始文件名的审查意见文件名。

企微API文件名前缀清理(2026-06-30 印刷品制作合同)

企微API下载文件时会自动在文件名前添加 doc_[0-9a-f]{12}_ 前缀(如 doc_0ad4ee63bff5_印刷品制作合同2026.6(1).docx)。这个前缀是系统自动加的,不是原始文件名的一部分。

清理规则:在构造交付文件名之前,先用正则 re.sub(r'^doc_[0-9a-f]{12}_', '', original_filename) 去掉前缀,得到 clean_filename,再用 clean_filename 构造交付文件名。

  • 【修】印刷品制作合同2026.6(1).docx(去掉 doc_0ad4ee63bff5_ 前缀后)
  • 【修】doc_0ad4ee63bff5_印刷品制作合同2026.6(1).docx(前缀未清理)

workflow 已在 editor step 7(主清理)和 deliverer step 1(兜底检查)双重处理。手动审查时同样需要先清理再命名。

auto_notify 自动审查架构:企微收到文件后由 auto_notify_new_file.sh 监控 → 上传 Nextcloud → 入队 contract-queue → contract-queue-runner 串行执行。不可回退到 auto_notify 直接启动 uwf thread exec 的模式(前台模式有 ACP stdin bug)。详见 references/auto-notify-queue-routing.md

审查意见文档内部标题必须包含合同全称(如"关于《巷泽居委会办公家具采购项目合同》的审查意见"),但文件名仍按上述规则。

Author统一脚本(2026-07-02 凤雅幼儿园派遣案)

当Doro确认修订内容后要求"修订人统一成WB",用 scripts/unify-author-wb.py 一键完成。脚本覆盖 w:ins/w:del/rPrChange/pPrChange/sectPrChange/tblPrChange/trPrChange/tcPrChange 全部author属性,并修复XML声明。用法:python scripts/unify-author-wb.py input.docx [output.docx]

高密度修订文档叠加WB修订(2026-07-02 凤雅幼儿园派遣案)

当文档已有大量tracked changes(如华诚-Z的170+处修订),ContractEditor的tracked_replace会因段落结构高度碎片化而失败。直接用zipfile+lxml操作。三种操作模式:①段末追加(find last content child → append w:ins)②插入新段落(clone neighbor pPr → addnext全段w:ins)③段内替换(split existing ins element)。必跑post-save sz fix补齐INS缺失的字号。完整代码+陷阱见 references/high-density-tracked-changes-layering.md

多修订者合并为终稿(2026-06-30 劳务派遣协议案)

当用户(Doro/Maggie)在自己编辑过的合同上要求"合并修订"或"出一版清洁终稿"时,分两种场景:

场景A:统一修订者(保留修订模式)

  • 遍历所有 w:ins/w:del 元素的 w:author 属性,统一改为同一名称(如 "WB")
  • 不改文本内容,不改编号,只改作者名
  • 代码:for elem in doc.iter(): if f'{WNS}author' in elem.attrib: elem.attrib[f'{WNS}author'] = 'WB'

场景B:接受所有修订 + 清理终稿(无修订模式)

  • Step 1:接受所有 w:ins(转换为普通 w:r),删除所有 w:del
  • Step 2:修复合并产生的文本损坏(这是最常见的坑):
    • 句子截断(如"生工伤后"开头,前面丢了一段)→ 补全缺失的开头
    • 主体混淆(如"乙甲方""甲乙方"甲乙双方混在一起)→ 修正
    • 拼接乱码(如"导致需要解除以书面形式明确通知退回该劳动合同的"新旧文本交叉)→ 重写为通顺表述
    • 断句不完整(如"不属,从工伤保险基金支付"中间缺文字)→ 补全
  • Step 3:删除重复条款(合并后同一条款可能出现两个版本,如医疗期条款原版+修订版)
  • Step 4:统一编号(合并后编号可能跳跃或重复)
  • Step 5:通读全文检查逻辑连贯性

易错

  • 用户说"合并"≠"接受修订"。先确认用户要的是场景A(保留修订模式但统一作者名)还是场景B(接受修订出终稿)
  • 合并产生的文本损坏不是随机乱码,而是修订标记的 w:ins/w:del 在解包时新旧文本交叉拼接的结果。修复时必须回原文理解意图,不能凭印象改写
  • 编号修复时,被删除的段落会导致后续编号跳号,但新插入的段落可能也有编号,两者叠加导致编号体系混乱。必须逐节检查

程序化diff比对+修订模式应用(2026-06-30 劳务派遣协议案)

当用户有两份合同(原版+新版),要求将新版的所有改动用修订模式(author=WB)写入原版时,使用程序化diff方法。适用于改动量大(10+处)、不适合手动逐条对比的场景。

最小化修订原则(永恒铁律,Doro 2026-06-30"永远不会变"):只标记实际改动的词语/片段为del/ins,不动其他文字。绝不允许整段del+整段ins替代只改几个字的情况。这是Doro反复强调的核心要求,违反等于返工。difflib.SequenceMatcher的段落级diff粒度太粗时,必须降级到字符级或片段级精确匹配(用minimal_replace_in_para只改动的片段)。

先拒绝再加入原则(铁律,Doro 2026-06-30三次纠正):当需要在已有tracked changes的文档上叠加新修订时,绝不能在tracked changes上再叠加tracked changes("修订修改修订"是Doro的红线)。正确做法:

  1. 先接受所有已有修订:解包所有w:ins(转为普通w:r),删除所有w:del → 得到clean baseline
  2. 在clean baseline上应用新修订:新修订只标实际改动的片段为del/ins
  3. 如果用户要求的是"把新版diff到原版":先恢复原版到用户最初上传的原始状态(无任何修订),再把所有改动(包括用户的编辑+你的优化)作为tracked changes写入

三步法操作流程(Doro标准流程)

  1. 恢复原版:原版恢复到用户最初上传的原始状态(无修订标记)
  2. 优化新版:将所有优化意见直接写入新版(clean edits,无修订模式)
  3. 最小化diff:用WB修订模式,将优化后的新版逐段对比原版,只标出差异部分

操作流程

  1. 接受原版已有修订:如果原版文件本身有tracked changes(如上一轮workflow产出),先接受所有ins/del得到clean baseline
  2. 加载新版:读取用户编辑后的clean final版本
  3. 三级diff(段落→句子→字符,逐级降级到最小粒度):
    • Level 1 段落级:用difflib.SequenceMatcher对比两版的非空段落文本,得到change blocks(equal/replace/insert/delete)
    • Level 2 句子级:对replace块,先用split_sentences按句号/问号/叹号/分号拆分新旧段落为句子列表,再做句子级SequenceMatcher。相同句子→keep,变化的句子对→降级到Level 3
    • Level 3 字符级:对1:1的句子替换对,用cjk_tokenize将句子拆为CJK字符+ASCII词+标点,做字符级SequenceMatcher。相同的token→keep(普通run),删除的→w:del,插入的→w:ins
    • N:M段落块:当replace块新旧段落数不等时,将新旧段落的所有句子flatten后做统一句子级diff,按原段落归属分发操作结果,剩余未匹配的新句子作为独立ins段落插入
  4. 应用修订(三种粒度的函数):
    • replace_para(粗粒度,整段del+ins):仅用于新旧文本完全不同、无共享句子的情况
    • sentence_diff_to_elements(中粒度):段落内按句子diff,相同句子保持普通run,变化句子做del+ins
    • token_diff_to_elements / 字符级内联(细粒度,优先使用):句子内按字符diff,只标记实际变化的字符为del/ins,其余保持普通run
    • insertmake_ins_para(text, rpr) + insert_after(ref, new_p) — 新增段落
    • deleteconvert_to_del(p) — 整段标记为w:del
  5. 编号修正:diff完成后逐节检查编号连续性(insert/delete会导致编号跳号/重复)
  6. 保存上传

三级diff辅助函数

def cjk_tokenize(text):
    """CJK字符级分词:每个中文字/标点=一个token,ASCII连续字母数字=一个token。"""
    tokens = []; i = 0
    while i < len(text):
        ch = text[i]
        if '\u4e00' <= ch <= '\u9fff' or ch in ',。、;:!?""''()【】《》—…·[]%%':
            tokens.append(ch); i += 1
        elif ch.isascii() and ch.isalnum():
            j = i
            while j < len(text) and text[j].isascii() and text[j].isalnum(): j += 1
            tokens.append(text[i:j]); i = j
        else:
            tokens.append(ch); i += 1
    return tokens

def split_sentences(text):
    """按句号/问号/叹号/分号拆分文本为句子列表,保留标点。"""
    parts = re.split(r'([。!?;])', text)
    sentences = []; i = 0
    while i < len(parts):
        if i + 1 < len(parts) and parts[i+1] in '。!?;':
            sentences.append(parts[i] + parts[i+1]); i += 2
        else:
            if parts[i]: sentences.append(parts[i])
            i += 1
    return [s for s in sentences if s.strip()]

精准度验证:diff完成后遍历所有段落,检查同时包含普通run和del/ins的段落数(mixed count)。mixed越多说明粒度越细。理想状态:只改一个字的段落应该显示为[保留大段原文] [DEL:旧字] [INS:新字] [保留大段原文],而不是[DEL:整段旧文] [INS:整段新文]

关键函数(zipfile+lxml,不依赖ContractEditor):

def replace_para(p, new_text):
    """原文→w:del,新文→w:ins"""
    old_text = get_text(p); rpr = get_rpr(p)
    ppr_copy = copy.deepcopy(p.find(f'{WNS}pPr')) if p.find(f'{WNS}pPr') is not None else None
    for child in list(p): p.remove(child)
    if ppr_copy: p.append(ppr_copy)
    # w:del with old text
    d = etree.SubElement(p, f'{WNS}del')
    d.set(f'{WNS}id', next_rev()); d.set(f'{WNS}author', 'WB'); d.set(f'{WNS}date', rev_date)
    r1 = etree.SubElement(d, f'{WNS}r')
    if rpr: r1.insert(0, copy.deepcopy(rpr))
    dt = etree.SubElement(r1, f'{WNS}delText'); dt.set(XML_SPACE, 'preserve'); dt.text = old_text
    # w:ins with new text
    ins = etree.SubElement(p, f'{WNS}ins')
    ins.set(f'{WNS}id', next_rev()); ins.set(f'{WNS}author', 'WB'); ins.set(f'{WNS}date', rev_date)
    r2 = etree.SubElement(ins, f'{WNS}r')
    if rpr: r2.insert(0, copy.deepcopy(rpr))
    t2 = etree.SubElement(r2, f'{WNS}t'); t2.set(XML_SPACE, 'preserve'); t2.text = new_text

def make_ins_para(text, rpr_t=None):
    """创建tracked insertion段落"""
    p = etree.Element(f'{WNS}p')
    ins = etree.SubElement(p, f'{WNS}ins')
    ins.set(f'{WNS}id', next_rev()); ins.set(f'{WNS}author', 'WB'); ins.set(f'{WNS}date', rev_date)
    r = etree.SubElement(ins, f'{WNS}r')
    if rpr_t: r.insert(0, copy.deepcopy(rpr_t))
    ppr = etree.SubElement(r, f'{WNS}pPr')
    rPr2 = etree.SubElement(ppr, f'{WNS}rPr'); etree.SubElement(rPr2, f'{WNS}ins')
    t = etree.SubElement(r, f'{WNS}t'); t.set(XML_SPACE, 'preserve'); t.text = text
    return p

def convert_to_del(p):
    """整段标记为tracked deletion"""
    full_text = get_text(p); rpr = get_rpr(p)
    ppr_copy = copy.deepcopy(p.find(f'{WNS}pPr')) if p.find(f'{WNS}pPr') is not None else None
    for child in list(p): p.remove(child)
    if ppr_copy: p.append(ppr_copy)
    d = etree.SubElement(p, f'{WNS}del')
    d.set(f'{WNS}id', next_rev()); d.set(f'{WNS}author', 'WB'); d.set(f'{WNS}date', rev_date)
    r = etree.SubElement(d, f'{WNS}r')
    if rpr: r.insert(0, rpr)
    dt = etree.SubElement(r, f'{WNS}delText'); dt.set(XML_SPACE, 'preserve'); dt.text = full_text

def minimal_replace_in_para(p, old_fragment, new_fragment):
    """最小化修订:只标记实际变化的片段为del+ins,其余文字保持不动。
    在段落中找到包含old_fragment的w:t元素,将其拆分为:
    [before run] [w:del:old_fragment] [w:ins:new_fragment] [after run]
    优先使用此函数而非replace_para(整段替换)。"""
    for r in list(p.findall(f'{WNS}r')):
        for t in list(r.findall(f'{WNS}t')):
            if t.text and old_fragment in t.text:
                parent_r = r.getparent()
                r_idx = list(parent_r).index(r)
                rpr = r.find(f'{WNS}rPr')
                rpr_copy = copy.deepcopy(rpr) if rpr is not None else None
                pos = t.text.index(old_fragment)
                before = t.text[:pos]
                after = t.text[pos + len(old_fragment):]
                parent_r.remove(r)
                insert_idx = r_idx
                new_elems = []
                if before:
                    br = etree.Element(f'{WNS}r')
                    if rpr_copy: br.insert(0, copy.deepcopy(rpr_copy))
                    bt = etree.SubElement(br, f'{WNS}t')
                    bt.set(XML_SPACE, 'preserve'); bt.text = before
                    new_elems.append(br)
                d = etree.Element(f'{WNS}del')
                d.set(f'{WNS}id', next_rev()); d.set(f'{WNS}author', 'WB'); d.set(f'{WNS}date', rev_date)
                dr = etree.SubElement(d, f'{WNS}r')
                if rpr_copy: dr.insert(0, copy.deepcopy(rpr_copy))
                dt = etree.SubElement(dr, f'{WNS}delText')
                dt.set(XML_SPACE, 'preserve'); dt.text = old_fragment
                new_elems.append(d)
                if new_fragment:
                    ins = etree.Element(f'{WNS}ins')
                    ins.set(f'{WNS}id', next_rev()); ins.set(f'{WNS}author', 'WB'); ins.set(f'{WNS}date', rev_date)
                    ir = etree.SubElement(ins, f'{WNS}r')
                    if rpr_copy: ir.insert(0, copy.deepcopy(rpr_copy))
                    it = etree.SubElement(ir, f'{WNS}t')
                    it.set(XML_SPACE, 'preserve'); it.text = new_fragment
                    new_elems.append(ins)
                if after:
                    ar = etree.Element(f'{WNS}r')
                    if rpr_copy: ar.insert(0, copy.deepcopy(rpr_copy))
                    at = etree.SubElement(ar, f'{WNS}t')
                    at.set(XML_SPACE, 'preserve'); at.text = after
                    new_elems.append(ar)
                for elem in new_elems:
                    parent_r.insert(insert_idx, elem)
                    insert_idx += 1
                return True
    return False

易错

  • SequenceMatcher对长文本段落效果好,但对短段落(如纯编号段、空段)会产生虚假diff。预处理时过滤空段落
  • replace_para替换整段文本,不是字符级精确diff。对于只改几个字的段落,diff粒度较粗(整段del+整段ins),但接受修订后结果正确
  • 编号修正必须在diff应用之后单独做一遍,不能依赖diff自动处理
  • find_para(text_start) 用startswith匹配,同名开头的段落会误命中。加start_after参数避免

劳务派遣合同审查要点(2026-07-02 凤雅幼儿园案)

站用工单位(幼儿园/学校)立场审查派遣协议+劳动合同时的核心保护点:

  1. 辅助性岗位认定:要求派遣单位提供材料+逾期后果(视为确认+风险归派遣单位)
  2. 费用承担上限:甲方承担的经济补偿以实际派遣期间对应法定标准为上限
  3. 开票义务:派遣单位有明确的开票时限,逾期甲方有权暂缓支付
  4. 安全协议与主协议一致:附属安全管理协议的工伤条款不能与主协议矛盾
  5. 兜底免责条款:因派遣单位原因(含劳动合同条款无效/模糊)导致用工单位被追责→全部由派遣单位承担
  6. 确认声明优化:劳动合同中"不向用工单位主张"的声明——华诚-Z的写法已足够("确知…同意不向用工单位主张"),不需要加"穷尽"限定(Doro 2026-07-02回退)
  7. 超龄劳动者:根据2026-07-01施行的《超龄劳动者基本权益保障暂行规定》新增工伤保险条款

Doro修改风格:Doro倾向简洁的兜底表述(如"因乙方原因导致…"),而非列举式("条款无效/模糊/瑕疵")。表述覆盖面越广、措辞越简洁越好。

合同模板修订(非workflow场景,2026-06-29 劳务派遣协议案)

当用户要求参考一份新合同模板,将有利于甲方的内容用修订模式改进原合同时,这不是标准的 review-contract workflow,而是手动修订任务。

操作要点

  1. 通读两份合同,逐条对比差异
  2. 以原合同为基底,用 ContractEditor 库做修订(author=WB)
  3. 整体格式、编号逻辑按原合同来,最小化修改为原则
  4. 新合同中仍有不足保护甲方的,可根据法律法规和司法实践做增减
  5. 严禁凭记忆处理,必须查最新法律法规、上海地区规定及司法实践
  6. 修订完成后验证并上传

违约后果公式(铁律,2026-06-29 Doro纠正):当法律已赋予甲方某项权利(解除权、审核权、退回权等),修订的重点不是简单写入"甲方有权XX"(权利法律已给),而是写明违约后果:

  • 标准公式:「甲方因此支付的一切费用、承担的赔偿或补偿金、损失等由乙方全额赔偿,乙方另向甲方支付违约金人民币 元。如对甲方造成其他不良影响的,乙方还应当消除一切影响。」
  • 违约金金额留空(6个空格),由甲方自行填写
  • 赔偿范围必须完整列举(一切费用、赔偿或补偿金、损失),并括注具体类型(如重新招聘费用、行政罚款、律师费、诉讼费等)
  • "消除一切影响"是兜底,覆盖名誉损害、商誉损失等非经济损失

详见 references/contract-template-revision.md

待审查目录操作铁律(2026-07-01 Doro两次纠正)

严禁在Doro说pass之前删除待审查目录中的原文件。无论审查了多少轮、出了多少个修订版本,原文件必须留在待审查目录,直到Doro明确说pass。违反此规则等于丢失原始文件。已犯过两次(反委托代发+生育友好),被Doro发现后恢复。恢复方法:从 ~/.hermes/cache/documents/ 复制原始文件(保留 doc_ 前缀的是cache副本)回待审查目录。

新增条款numPr判断(2026-07-12 盈浦健康科普合同教训)

新增章节下只有一段正文时,不加numPr。判断方法:看原文中同样只有一段正文的章节(如"一、合作背景")是否有numPr——如果没有,新增章节也不加。只有多段正文的章节才用numPr编号("有2才有1"规则在numPr层面的体现)。

实证:盈浦合同原文"一、合作背景"只有1段正文且无numPr,"七、不可抗力"有2段正文有numId=11。新增"八、转包与分包"只有1段正文→不应有numPr。错误地加了numId=14导致渲染出孤零零的"1."编号。

新增条款段落pPr必须完整克隆邻近同类段落(2026-07-12 盈浦教训)

新增段落的pPr不能只有spacing,还要检查原文同类段落是否有:

  • ind(首行缩进 firstLine/firstLineChars)
  • spacing
  • 其他pPr子元素

实证:原文正文段有ind firstLine=420 firstLineChars=200(首行缩进两字符),workflow新增的P75只有spacing没有ind,渲染时缺少首行缩进。

workflow给标题run添加spurious sz属性(2026-07-12)

ContractEditor库在处理Heading样式的段落时,可能给run添加显式sz属性。当原文run通过Heading样式继承sz(如Heading 1的sz=48),但run本身没有显式sz时,任何库操作如果不当地设置了sz(如sz=20来自szCs的值),会导致标题文字从24pt变成10pt。

检查方法:修订后检查所有heading段落(pStyle=1/2/3...)的plain run是否新增了显式sz。原文heading run没有sz的,修订版也不该有。

审查意见文档格式处理(2026-07-12 Doro纠正,2026-07-13 格式规则补充)

不要主动生成【审】审查意见文档。除非Doro明确要求,否则只交付【修】修订版。审查意见是额外的交付物,不在标准workflow产出范围内。Doro确认:workflow生成的审查意见属于"错误交付物"——即使内容正确对应合同,文件本身也不应生成/上传。发现/tmp中有错误生成的【审】文件时直接删除,确认Nextcloud任务交付目录未上传即可。

当确需生成审查意见时,交付前必做三项格式修复(详见 references/review-opinion-format-checklist.md):

  1. 删除表格中的空白行(模板占位行)
  2. 页眉日期改为修订当日
  3. 标题用合同正文全称(读P0,不看文件名)

2026-07-14 补充:按当前【修】制作【审】的纪律

当用户要求“按照【修】中的 WB 修订去修改【审】审查意见”时,必须先读取当前任务交付目录中的实际【修】文件,不能凭本地旧版本、之前上传过的坏版、或自己先前的判断去写【审】。若用户明确说“文件是我改过的,不是你的版本”,就是在要求你:

  • 先以**当前已交付【修】**为唯一依据;
  • 只按该文件里实际存在的 WB 修订制作【审】;
  • 不能把自己先前版本里的修订点硬塞回【审】;
  • 不能在页眉日期、模板残留、表格完整性未处理完时先上传。

交付铁律:审查意见文档必须一次性做完再上传——标题、表格内容、页眉日期、模板残留四项缺一不可。用户已明确否定过“没弄完先上传”的做法。

另见 references/duplicate-workflow-detection.md(已审查文件重复处理检测)。

新增条款编号铁律(2026-07-01 Doro两次纠正)

新增条款必须有完整编号(第X条),且后续条款编号顺延。Doro第一次指出编号缺失后补了,但后续条款没有顺延("八"没变"九"),被第二次纠正。完整做法:新增条款带编号 + 后续所有条款编号用DEL旧号+INS新号顺延,一个都不能漏。

原始批注保留铁律(2026-07-01 Doro纠正)

源文件(邱律师/对方发来的.doc/.docx)中已有的批注(如Alice、法务、杜律等人的批注)必须原样保留,不得修改作者名、内容或锚点。Workflow/editor过程中丢失原始批注是严重错误。修法:编辑前先读取原始comments.xml,记录每条批注的id/author/content/锚点位置;编辑后恢复所有原始批注。只有本次新增的WB批注可以修改。

新增条款必须有编号(2026-07-01 Doro纠正)

新增条款如果不带"第X条"编号,等于格式不完整。每次add_clause或手动插入条款时,必须同时插入编号前缀(如"第一条 ""第二条 ")。编号用w:ins包裹,author=WB,字体从原文同层级段落克隆。后续原文编号如有顺延需用DEL旧号+INS新号处理。

两版本交付法(2026-07-01 反委托代发工资案)

当合同存在根本性法律风险(如整个交易安排违反强制性规定),应准备两个修订版本同时交付:

  • 版本1(推荐):回归法定安排,消除根本风险,甲方利益最大化
  • 版本2(保留风险):保留原交易安排,但加入最大限度保护甲方的条款 两版本分别命名,如"(版本1-法定安排)""(版本2-反委托保护)"。审查意见中附风险对比表。

用户编辑过的文件:从用户版本开始(2026-07-01 Doro纠正)

当用户(Doro/Maggie)已在OnlyOffice中编辑过交付文件,必须从用户编辑后的版本开始修改,不得从原始文件重建。"恢复成我刚修改完的版本,然后只做要求的那一两处改动"——用户原话。先下载用户版本→只做请求的具体修改→上传。

文件版本管理铁律(2026-07-01 华诚-Z覆盖教训)

操作前必须备份,覆盖性操作不可逆。

  1. 每次生成新版本时,保留中间版本:不要用同一文件名反复覆盖。命名用 _v1_v2_v3 后缀区分
  2. 不同作者的修订不能合并到一个操作中:华诚-Z的修订痕迹(author=华诚-Z)和WB的修订痕迹(author=WB)要分别保留,不能全部改成一个author——除非用户明确要求
  3. 操作前先备份当前状态cp file.docx file_backup_$(date +%H%M).docx
  4. 完成后验证完整性:用zipfile检查comments数量、tracked change authors、段落数,与预期对比
  5. 绝不从头重做覆盖已有文件——除非原文件确实损坏且无法修复。增量修补优先于全量重做

教训:反委托代发工资协议做了7-8次版本,每次都覆盖前一版,华诚-Z的修订被覆盖后几乎无法恢复(最终在一个中间文件中找到)。

Editor到此为止

  1. validate通过 + save完成 = Editor职责结束
  2. 将修订说明JSON输出给调用方
  3. 严禁执行任何交付动作:不docker cp、不occ files:scan、不清OnlyOffice缓存、不上传Nextcloud——这些全部属于deliverer/调用方职责
  4. 严禁通知Doro:通知由final_review角色在终审通过后发送(2026-06-15 workflow修复:deliverer只上传不通知,final_review负责终审通过后私信通知Doro。防止"先通知后终审"导致rejected时Doro已收到虚假完成通知)
  5. 禁止把Doro放入自动化流程(2026-06-15 Doro明确拒绝):不可以把Doro加到cron监控、自动通知链、审批流等自动化流程中。workflow suspended等技术问题应由小Maggie自行发现和处理,不能设计成"出问题就通知Doro"的方案——这是治标不治本。正确做法是让系统本身具备自动恢复能力
  • ⚠️ 格式保留铁律(2026-06-30 Doro纠正,2026-07-09 施工安全协议再次教训,2026-07-12 盈浦健康科普案再证)

文件格式与原文保持一致,不自创格式标准。 原文用什么字体/字号/加粗,修订后的文字就用什么字体/字号/加粗。绝不把 仿宋_GB2312 改成 仿宋,绝不把不统一的字号强行统一为 12pt。

2026-07-12 盈浦健康科普案教训(sz添加到不应有的run):原文标题P00使用Heading 1样式(sz=48),run本身无显式sz(靠样式继承)。Workflow的ContractEditor给run[0]加了sz=20——导致"上海市"三字从24pt变成10pt。根因:库的_extract_formatstracked_replace在处理段落时,可能给原本没有sz的run错误地添加了显式sz。铁律:如果原文run没有显式sz/eastAsia(依赖样式/docDefaults继承),修订后的run也不该有——属性集与原文run完全一致(不多不少)。交付前必须逐段比对原文与修订版的非修订run,检查是否被意外添加了属性。

2026-07-09 施工安全协议教训:Maggie说"你手动修改后把整个合同的格式全搞乱了...workflow修改合同的规则同样适用于你。认真一点。"。根因:手动用zipfile+lxml修改时,从已修复前的旧版本复制了INS run的rPr(含sz=21+eastAsia=宋体+ascii=Times New Roman),但原文Normal样式是仿宋且原文run没有显式sz——导致有sz和无sz的run混用、宋体和仿宋混用→OnlyOffice渲染时字体大小全乱。铁律:①手动修改格式搞乱后,必须从原文重新开始(回到workflow最终交付版.bak_r2fix或待审查目录的原始文件),不在已破损版本上补救;②手动修改与workflow修改遵守完全相同的规则——先用numbering-diagnose.py看原文结构,INS run的rPr从同段原文run deepcopy(rPr),不自行构造;③save后必须用wb-ins-font-verify.py验证;④如果原文run没有显式sz/eastAsia(依赖docDefaults继承),INS run也不该有——属性集与原文run完全一致(不多不少)。

  • 实证(Doro两次纠正):生育友好宣传阵地建设协议,原文用 仿宋_GB2312,字号不规则(部分继承、部分12pt、部分无显式字号)。第一版错误地把所有字体改成 仿宋 并统一为 12pt,被 Doro 退回:"改的还是错的,你自己好好看一看格式的规则,文件格式与原文保持一致"。第二版恢复了原文格式(仿宋_GB2312、不规则字号),新增条款的 INS run 也匹配所在段落的原文格式。
  • 铁律
    1. INS/DEL run 的 rPr(字体名/字号/加粗)必须从同段落原文 run 复制,不是自创标准
    2. 原文 仿宋_GB2312 就保持 仿宋_GB2312,原文 仿宋 就保持 仿宋,不互相替换
    3. 原文字号不规则(部分12pt、部分继承)就保持不规则,不强行统一
    4. 新增段落(全部文字都是 INS)的格式从原文同类型段落克隆 pPr + rPr
  • 禁止操作:批量替换字体名(仿宋_GB2312仿宋)、批量统一字号、批量统一加粗状态
  • 正确做法:修改前先用 Document(filepath).paragraphs[i].runs[j].font 读取每个段落/run 的实际格式,修改时 copy.deepcopy 原文 run 的 rPr 给 INS run 使用

交付后手动返修(非workflow场景)

手动构建 WB INS 段落 rPr 陷阱(2026-06-26 检测合同教训)

当手动用 etree + zipfile 创建 WB INS 段落时,不能只设 w:rFonts hint="eastAsia"。必须从原文参照段落的第一个 w:r 完整复制 w:rPr(含 w:rFonts 四属性 ascii/hAnsi/eastAsia/csw:szw:bw:bCsw:color 等全部子元素)。只写 hint 会导致:①字号缺失(INS 文字大小与原文不一致);②CJK 字体缺失(中文渲染回退到默认字体);③加粗丢失(标题与原文同级不一致)。正确做法copy.deepcopy(ref_p.find(W+'r').find(W+'rPr')),然后替换 w:t 文本。交付前验证wb-ins-font-verify.py 检查 INS 的 sz/eastAsia/b 与同段原文 run 一致。

⚠️ 改前必做:用「线上已交付版」当基底,先diff再动手(2026-06-24 徐函险情)

迭代修改一份已经交付到 Nextcloud 的文书时,绝不能假设本地 /tmp 的工作副本(response_vN.docx)就是线上最新版——用户(Doro/Maggie)会在 OnlyOffice 里直接改交付版(加小标题、调措辞),这些改动只在 Nextcloud 那份里,本地副本会静默落后。直接拿本地副本改完覆盖回去 = 抹掉用户的线上编辑

  • 实证:徐函本地 response_v6.docx 与 Nextcloud 徐函-0623回应-优化稿…docx 差一处——优化稿里 Doro 给利冲段加了「其五,违规约定预先利冲豁免。」小标题(把它编进问题清单第5点)。若我直接用 v6 改完覆盖,这个标题就没了。
  • 铁律:① 改前先把 Nextcloud 那份 sudo cp 出来当基底(不是本地工作副本);② 段级 diff 本地副本 vs 线上版(zipfile+lxml 提每个 w:p 文本逐段比对),逐一核对每处差异是不是用户的编辑;③ 用户的编辑一律保留,在其基础上改;④ 覆盖前再 sudo cp 一个 .bak_时间戳 备份,Nextcloud 自身也会留版本。
  • 段错位排查:diff 出现「整段右移」(opt[i] 对上 v6[i-1])多半是某处插入/删除了段落导致后续整体偏移,不是每段都改了——用 difflib.SequenceMatcher 在错位起点定位真正的插入点,别被海量「不同」吓到逐段重写。

在「用户已自行修订过」的合同上叠加我方修订(2026-06-25 金信大厦案)

Maggie/Doro 发来一份他们本人已用修订模式改过的合同(track changes 已存在,author 如 "maggie jia"),要求在此基础上再补几处修订(典型:模版比对后补缺失/反向条款)——不重跑 workflow,也别用 ContractEditor 库(库的字符级 diff 会卷进用户既有修订)。一律 zipfile+lxml 直接追加。五步配方(新 id 从 maxid+1000 起防撞且便于过滤、作者沿用文档既有修订线、克隆用户已渲染正确的 INS rPr 预防中文字体回退坑、addnext 三种插入机制、只换 document.xml)+ 四查验证(XML合法/接受修订后文本正确/本次新增 INS 中文字体非 Times/用户原有修订逐 id 比对一字未动)见 references/layer-revisions-on-user-revised-doc.md。收口同样走 accept-revisions-preview→OnlyOffice→vision,且 vision 报「页底截断」先按数据层+跨页拼接分清「PDF 分页 vs 真丢数据」(金信大厦实证为分页跨页,不返工)。

当已交付的文件被退回要求修复(如字体不一致),不重跑workflow,直接手动修:

  1. 从cache/documents/取原文(或按上方铁律取 Nextcloud 已交付版当基底)→ ContractEditor重做全部修订
  2. 修订后运行 python ~/.hermes/skills/legal/contract-reviewer/scripts/wb-ins-font-verify.py <docx> 验证所有WB INS的rPr
  3. 验证通过 → 上传Nextcloud → 清OnlyOffice缓存
  4. 自己确认没问题再汇报,不要让Doro帮你验收
  5. 绝不要post-process已生成的docx来修补格式(如批量strip <w:b/>)——2026-06-10教训:批量移除bold反而破坏了原文加粗格式(金额、风险条件、标题的加粗都被抹掉)。格式问题必须在ContractEditor库层面修复,然后从原文重新生成

终审/交付后返修铁律(2026-06-27 Doro 两度纠正)\n\n### 终审 minor 问题直接修,不标记"人工确认"\nworkflow reviewer 在 review_round≥4 强制 pass 时可能标注"建议人工确认后交付"。\n这不意味着你可以把问题丢给 Doro——你应该直接修掉 minor 问题然后交付。\n- 实证:朱家角标识标牌 reviewer 标注"3项残留minor问题:买卖双方称谓、合同签订点→签订地、审查意见文档不一致"+"建议人工确认"。被 Doro 反问"这为什么需要人工确认""你不知道该怎么修改吗"。\n- 铁律:终审发现的 minor 问题一律直接修,不标记"人工确认"、不丢给用户判断。\n\n### 交付后发现问题 → 重新跑 workflow,不手动 patch\nDoro 发现交付件有问题时,重跑 workflow 从源头重新生成,不要手动 zipfile+lxml patch 已交付文件。\n- 实证:手动 patch 朱家角标识标牌("买卖双方"→"双方"、"合同签订点"→"合同签订地"),Doro 说"你别改了 你重新跑workflow吧"。\n- 铁律:① 删掉已交付文件(Nextcloud 任务交付/ + /tmp/contract-review/);② 清理旧 thread(kill worker);③ 新开 uwf thread start + thread exec --count 20 --background 从头跑。\n\n## 风险识别必须落实到修订(铁律,2026-07-01 Doro纠正)

审查过程中识别出法律风险后,合同条款本身必须做对应修改来应对该风险——不能只在分析中"认识到"风险但合同文本不做任何修改。

  • 实证:反委托代发工资协议审查,分析中明确指出"甲方直接发工资极易被认定为事实劳动关系",但补充协议的核心条款(甲方直接向员工支付工资)未做实质性修改,只加了几个附属条款"打补丁"。Doro质问:"你已经认识到了这个问题,合同里的相关约定为什么不修改?"
  • 铁律:①识别出风险→合同文本必须有对应修改(要么消除风险源,要么加入实质性保护条款);②"打补丁式"修改(只在边角加免责条款但核心风险条款不动)不够——核心条款本身必须改;③如果风险无法通过修改合同文本来消除(如反委托安排本身的合规性问题),必须在批注中明确标注"不建议签署"并给出替代方案。

结构性问题的多版本交付(2026-07-01 反委托代发协议案)

当合同存在根本性的结构法律风险(不是条款文字问题,而是整个交易安排本身有问题),应产出两个修订版本:

  • 版本1(推荐版):消除风险源,回归法定/合规安排。如:删除"反委托"安排,回归派遣公司直接发工资的法定模式
  • 版本2(保护版):保留原安排,但加入最大限度保护我方的条款。如:保留反委托但加入事实劳动关系兜底赔偿、履约保证金、三方签署要求

文件命名【修】原文件名(版本1-简要说明).docx【修】原文件名(版本2-简要说明).docx

批注:两个版本都应在关键条款处加批注,说明核心风险、法律依据、推荐方案

附件文件根本性不利时的处理(2026-07-01 承诺书案)

当合同文件包含的附件(如承诺书、担保函)对我方根本性不利时:

  1. 用tracked deletion删除全文(w:del, author=WB)——逐段构建w:del包裹段落全部文本
  2. 在关联条款处加批注,说明:①该附件的法律性质(如"甲方单方面全面兜底承诺");②不建议签署的理由和法律依据;③如确需签署的替代建议
  3. 批注必须有具体法条引用,不能只说"建议删除"

需要三方签署的情形(2026-07-01)

当合同安排涉及第三方权益且存在法律风险时(如劳务派遣中的代发工资安排涉及派遣员工),应要求三方共同签署:

  • 第三方(如派遣员工)签字确认,明确知悉并同意相关安排
  • 三方签署可在一定程度上降低争议风险,但不能根本消除法院的实质审查
  • 在合同中增加三方签署条款:ed.add_clause("本协议应由甲方、乙方及XXX三方共同签署...")

审查意见文档不是默认交付物(2026-07-01 Doro指示)

不要默认生成审查意见文档。审查意见(【审】文件)仅在以下情况需要:

  • Doro明确要求
  • 合同存在重大风险需要详细说明
  • workflow的deliverer角色要求生成
  • review-rules.md中有"特殊交付物:审查意见文档"要求——某些顾问单位(如朱家角镇社区卫生服务中心)的review-rules.md明确要求出审查意见

手动审查时,只交付修订版(【修】文件),除非Doro说"出审查意见"或review-rules.md有特殊交付物要求。

版本管理铁律(2026-07-01 反委托代发惨痛教训)

操作前必备份,修改后必验证,覆盖不可逆。 详见 references/version-management-antipatterns.md

  1. 修改 tracked change author 前:先 shutil.copy(原文件, 原文件.bak_时间戳),确认 .bak 文件字节数一致后才动手。
  2. 批注操作:修改 comments.xml 前验证原始批注数量。操作后必须验证所有原始批注仍存在且 id/author/位置正确。
  3. 不从头重做:在已有基础上增量修补(patch),不要"清空重来"。
  4. 交付前验证清单(每次保存后必跑):
    • 所有原始批注数量 + author 正确
    • tracked change author 按要求(WB/华诚-Z/不改)
    • INS run 字体与同段落原文一致(sz/rFonts/bold)
    • 编号连续无跳号
    • 文件大小合理(不是空壳)

Workflow交付物审查(2026-07-13 Doro要求逐份审查已交付合同)

当Doro要求检查已交付合同的问题时,必须先读review-rules.md原文再开始。审查流程详见 references/workflow-output-audit-checklist.md

核心教训(6次连续低级错误):

  • 回答"格式是否一致"前必须tool call逐属性比对,不凭印象
  • 修一个属性(如numPr)不等于格式正确——必须检查完整pPr(ind/spacing/numPr/全部)
  • 原文单段正文无numPr(如"一、合作背景"只有1段)→ 新增单段章节也不加numPr
  • 新增条款必须插在签署页之前,不是"最后一个编号条款之后"——要确认签署页段落索引
  • ContractEditor默认sz=21是已知bug(docDefaults=22时),修复方法:save后strip所有WB INS的sz

手动修正workflow交付件的正确方法(2026-07-10教训)

当workflow交付件需要内容修正(如追偿句应独立成款、条款重复需合并等),不要patch workflow输出的docx——从待审查目录的原文件重新做:

cp '施工安全协议.docx' '【修】施工安全协议.docx'

然后用ContractEditor一次性执行所有修订(含workflow原本做的+新的修正)。

严禁:从workflow交付件中deepcopy INS run的rPr到新段落——workflow多轮修订过程中rPr可能有残留问题(如第一轮写了错误的sz=21/宋体,第三轮只修了部分段落),copy会把错误格式带到新段落。

严禁:用OxmlElement裸写XML构造段落。ContractEditor的add_clause()会自动从相邻段落推断格式。

红线

  • 不做独立法律判断——只执行Reviewer的问题清单
  • 不否定客户的商业安排——客户选择的交易结构(如代发工资、反委托等)是商业决策,修订只能在该框架内加保护条款,不能改变交易结构本身
  • 执行前做合理性判断——如果reviewer建议"在本款末尾增加"但新增文字与原款属于不同法律关系(如"追偿权"vs"免责"),应独立成款而非粘在原段落尾部
  • "争议解决"只放管辖/仲裁——维权费用赔偿属于"违约责任"条款,不应塞入争议解决条款
  • 多条新增条款之间不得有实质重复表述——如"维权费用"只在违约责任条款中出现一次,争议解决不再重复
  • 不凭空填写合同空白内容——月数、金额、日期等空白处是当事人商业条款,不得擅自填入(2026-07-01教训:质量保证期月数空白处直接填"12个月"无任何依据)

审查意见文档生成

详见 references/review-opinion-generation.md——含字体规格、内容规则(只写差异不写理由)、comments.xml编码陷阱、表格单元格定位陷阱。

同模板合同一致性

详见 references/same-template-consistency.md——含检测方法、一致性范围定义、验证思路。

独立修订(非workflow,Maggie直接指派修改协议)

当Maggie直接发来协议要求修改(不走Doro workflow),使用轻量字符级diff模式:difflib.SequenceMatcher + 手工 comments.xml 注入。字符级精准修订铁律同样适用——原文相同字保留普通run,只有差异字做del/ins。详见 references/standalone-charlevel-tracked-changes.md

多版本对比表 + 精准修订(非workflow场景)

当Maggie直接要求对比多份合同版本(如模版/对方修订/我方修订/协商一致),详见 references/multi-version-comparison-table.md——含横向对比表格生成(landscape docx、红色标差异)和基于对比结果的精准修订手法(逐run替换,不整段del+ins)。核心铁律:Maggie说"精准修订,不要全段修改"——必须定位到具体的run,只对需要改的数字/文字做del+ins,周围原文run完全不动。这条规则不限于workflow场景,Maggie直接指派的协议修订同样适用。

操作铁律(2026-07-01 总结15个返工问题后确立)

操作前备份

  • 任何覆盖性操作前,先 cp 文件 文件.bak_$(date +%s) 保存中间版本
  • 特别是涉及批注(comments.xml)和修订作者(w:author)的操作——一旦覆盖不可恢复
  • 2026-07-01教训:华诚-Z的修订痕迹被覆盖,所有中间版本author改成WB,差点不可恢复

操作后验证

  • 每次保存文件后必须验证:
    • 批注数量和作者是否完整(对比原文件)
    • 修订作者是否正确(遍历所有w:ins/w:del的w:author属性)
    • 字体/字号是否与原文一致(INS run的rPr必须从原文段落克隆)
    • 通读修改后的段落——语句是否通顺、逻辑是否自洽

增量修复而非从头重做

  • 出错时优先在已有文件基础上修补,不要从头重建
  • 从头重做 = 覆盖所有中间版本 = 不可恢复
  • 详见 references/file-versioning-discipline.md

文本质量

  • 修订后必须通读整段话,确认语句通顺、逻辑自洽
  • 2026-07-01教训:劳务派遣协议修订后前言不搭后语——只机械替换文字没通读上下文
  • 不填写合同空白内容——空白处(金额、期限、月数等)是当事人商业条款,只能批注提示"需填写",不能擅自填入任何数字

审查立场(与 contract-review-general 一致)

  • 站客户立场:客户的商业安排不否定,在客户选择的框架内最大化保护
  • 不做法律价值判断:"建议采用X"是律师的活,editor 只执行修订
  • 不编造法律依据:批注中引用法条必须查实
  • 详见 contract-review-general 的"审查立场铁律"

文件操作铁律(2026-07-01 多次返工教训)

操作前必须备份

  • 任何覆盖性操作前,先 cp 原文件 原文件.bak_$(date +%H%M%S)
  • 中间版本用递增命名(v1→v2→v3),绝不覆盖前一版
  • 修改 author / 合并批注等破坏性操作,先备份再动

修订后必须验证

  • 字体一致性:INS run 的 rPr(sz/rFonts/bold)必须与同段落原文 run 一致。用 zipfile+lxml 逐个 INS run 检查,不能凭"应该没问题"跳过
  • 编号连续性:通读接受修订后的全文编号,确认无跳号无重复
  • 语句通顺:修订后整段话必须通读一遍,确认语法正确、逻辑自洽、前言搭后语
  • 批注完整性:修改 comments.xml 后,对比原文件的批注数量和 author 列表

不重做,增量修补

  • 出错时在现有文件基础上修补,不从头重做
  • 从头重做 = 覆盖所有中间版本 = 丢失历史数据(华诚-Z修订被覆盖的教训)
  • 但有有限拒绝权:当issue明显违反review-rules.md的禁止项时(如修改商业条款、修改不影响法律含义的编号格式/标点),Editor应标记为skipped并说明原因,不盲目执行
  • 严禁修改Reviewer问题清单以外的任何内容——即使发现错别字、乱码、格式问题,如果不在Reviewer清单中就不能碰。发现疑似问题记入editor_notes,不动手
  • "法人代表"不改(2026-06-12 Doro明确):合同中"法人代表"和"法定代表人"都是正确表述,不要将"法人代表"修改为"法定代表人"
  • 直接修订优先于批注(2026-06-12 Doro退回运维合同原因之一):能用tracked_replace/add_clause直接改的,不做批注。批注仅限:①需客户确认的事项(名称未填写);②新增条款内容较长需说明时。选择题/勾选项不处理(2026-07-08废止)。 Reviewer的issue如果suggested_fix给了明确修改内容,editor一律用修订模式执行,不转为批注
  • 顾问单位名称只改主体定义处+签署页(2026-07-06 Doro明确):正文中作为项目名称/服务名称/标的物名称出现的顾问单位同名不动,属商业条款
  • 严禁修改商业条款——配置清单、设备参数、品牌型号、数量、单价、总价、金额等绝不能碰
  • 金额算术错误也不改(2026-07-02 铁律,Doro纠正):即使能用算术证明某个数字是"明显笔误"(如数量2×单价19600≠成交总金额19600),也绝不直接修订。因为无法判断到底是数量错(应为1)、单价错(应为9800)、还是总金额错(应为39200)——只有合同当事人知道。唯一正确做法是在有问题的数字处加批注"请注意确认金额"。实证:2026-07-02恭兴合同,错误地DEL 19600→INS 39200修改了单价列(甚至改错了列),被Doro严厉纠正。
  • 表格单元格定位必须确认列号(2026-07-02 教训):表格中同一数字可能出现在多列(如"19600"同时是单价和成交总金额)。操作前必须打印整行所有列的文本,确认目标是哪一列(用header行的列标题对应)。绝不能"找到第一个匹配就动手"。即使算术能推出"正确值"(如2×19600≠19600,看似成交总金额应为39200),也不能直接修订——可能是数量错、单价错、或有折扣,只有当事人知道哪个数字该改。一律用批注提示"请注意确认金额"
  • 金额算术不一致 = 批注,绝不修订(2026-07-02 铁律,因严重错误确立):当设备清单中 数量×单价≠成交总金额 时,不能判断哪个数字是对的(可能是数量错、可能是单价错、也可能是小计错),只有合同当事人才能确认。唯一正确做法:在成交总金额单元格加批注"请注意确认金额"。绝不能直接修改任何一个数字。2026-07-02实证:除颤仪 2×19600≠19600,错误地把单价列改成了39200,被Doro严厉纠正。
  • .doc格式乱码不修复——.doc文件提取文字可能出现缺字/乱码(如公司名缺字),这是格式转换问题不是合同问题。不得用修订模式"修复",记入editor_notes报告即可
  • 金融计算叙述必须消除歧义(铁律):描述还款冲抵时,如果某个数字已经是净额(如"686,027.40 = 2,686,027.40 - 2,000,000"),叙述中不要再写"加上…扣除…"的流水式表述(读者会当作独立的加减运算导致验算不通)。要么用递进表述("截至X日应计利息Y,还款Z冲抵后尚余W"),要么用括号内注("此前未清偿利息686,027.40元(即X前应计利息2,686,027.40元扣除还款2,000,000元)"),确保每个数字只在计算链中出现一次,读者按文字顺序做加减能得到正确结果
  • 无法执行的issue标记为skipped并说明原因
  • 格式保真是底线:INS/DEL 的字体/字号/加粗必须与同段落原文 run 一致,不自创格式标准,不批量统一字体名或字号。详见「 格式保留铁律」
  • 文件命名、编号规则、交付物要求从review-rules.md读取