feat: export core Hermes skills

This commit is contained in:
2026-07-15 02:45:56 +00:00
parent a028b63eda
commit 54711fee2a
308 changed files with 41310 additions and 1 deletions
@@ -0,0 +1,199 @@
---
name: case-analysis-nine-steps
description: 要件审判九步法——邹碧华法官提出的民事案件分析方法论。以请求权思维为基础,将案件分析分解为九个环环相扣的步骤。适用于案件分析、诉讼策略制定、庭审准备、裁判文书分析。
version: 1
tags: [legal, litigation, case-analysis, methodology]
triggers:
- 分析案件
- 案件分析
- 九步法
- 诉讼策略
- 请求权分析
- 争点整理
---
# 要件审判九步法 — 案件分析方法论
> 来源:邹碧华法官《要件审判九步法》
> 核心思维:先找法(大前提)→ 再认定事实(小前提)→ 归入裁判(结论)
## 案卷材料分析流程(九步法前置)
拿到案卷材料后,必须先完成以下步骤,再进入九步法分析:
### 一、精读全部材料
- **所有文件逐页阅读**,页眉、页脚、注释、手写内容、印章、批注一律不放过
- 标记关键信息:日期、金额、签名、盖章、手写备注
- 注意文件之间的交叉引用和矛盾之处
### 二、梳理相关方与时间线
- **找出所有相关方**:当事人、关联公司、代理人、第三方等
- **按时间顺序梳理全部事件**:合同签订、履行、违约、通知、催告、诉讼等
- 制作时间线表格,每个事件标注:时间、相关方、事件内容、对应文件
### 三、法律行为分析与法律关系提炼
- 对**每个相关方**逐一进行"法律行为"分析——做了什么、基于什么身份、产生什么法律效果
- 提炼**两两相对方之间的法律关系**(合同关系、侵权关系、担保关系、代理关系等)
- 总结每组法律关系中各方的**权利义务**
### 四、推理还原与缺失材料识别
- 基于已有材料**推理还原事件经过**
- **推测可能存在但未提供的文件和事实**(如:有催告函但没有送达凭证、有合同但没有付款凭证)
- 标注推测依据
### 五、制作补充材料清单
- 列出**需要客户补充的材料和文件**,说明每份材料的用途和重要性
- 要求客户提供,必要时说明不提供的风险
### 六、事实还原定稿
- 确认收集到所有能够搜集到的资料后,**尽可能还原案件事实全貌**
- 形成完整的事实陈述,区分"已证实事实"和"待确认事实"
完成以上步骤后,进入要件审判九步法进行法律分析。
---
## 适用场景
- 民事案件全面分析
- 诉讼策略制定(原告视角/被告视角)
- 庭审准备与争点预判
- 裁判文书逻辑审查
- 合同纠纷、侵权纠纷、物权纠纷等各类民商事案件
## 九步分析流程
### 第一步:固定权利请求
**任务**:明确当事人的诉讼请求是什么。
- 原告请求什么?(给付金钱、返还财产、确认权利、变更/解除合同等)
- 有无反诉?反诉请求是什么?
- 诉讼请求是否明确、具体、可执行?
- 注意区分:确认之诉、给付之诉、形成之诉
**输出**:列出全部诉讼请求(含反诉),逐条编号。
### 第二步:识别权利请求基础规范(法官找法)
**任务**:为每项诉讼请求找到对应的法律依据(请求权基础)。
- 该请求权属于什么性质?(合同请求权、侵权请求权、不当得利请求权、物权请求权等)
- 对应的具体法律条文是什么?(民法典哪一条、哪部特别法)
- 是否存在请求权竞合?如有,分析各请求权基础的利弊
- **必须查原文**:引用具体条文,不凭印象
**输出**:每项请求对应的法律条文及请求权性质。
### 第三步:识别抗辩权基础规范(对立规范)
**任务**:预判或识别对方可能/已经提出的抗辩。
- 权利障碍抗辩(合同无效、未成立等)
- 权利消灭抗辩(已清偿、已抵销、已免除等)
- 权利阻止抗辩(诉讼时效、同时履行抗辩权、不安抗辩权等)
- 每项抗辩对应的法律规范是什么?
- 抗辩的举证责任归谁?
**输出**:抗辩清单及对应法律依据,标注举证责任分配。
### 第四步:基础规范构成要件分析
**任务**:将请求权基础规范和抗辩规范的构成要件逐一拆解。
- 把法律条文分解为若干事实要件(要件事实)
- 对不完全法条,通过法律解释、司法解释、指导案例补充隐含要件
- 每个要件需要什么事实来满足?
**输出**:要件分解表——每项请求权/抗辩的构成要件列表。
**示例格式**
```
请求权基础:《民法典》第577条(违约责任)
构成要件:
1. 合同有效成立
2. 被告存在违约行为
3. 原告遭受损失
4. 违约行为与损失之间有因果关系
```
### 第五步:审查诉讼主张是否完备
**任务**:检查当事人的主张是否覆盖了全部构成要件。
- 有无遗漏的主张?(对照第四步的要件清单逐一检查)
- 有无矛盾的主张?
- 如代表一方,提示需要补充的主张
- 如分析裁判,检查法院是否进行了释明
**输出**:主张完备性检查表,标注缺漏项。
### 第六步:争点整理
**任务**:归纳案件的争议焦点。
- 哪些要件事实双方无争议?(可直接认定)
- 哪些要件事实双方有争议?(这就是争点)
- 是否存在法律适用争点?(法条理解分歧)
- 按重要性和逻辑顺序排列争点
**输出**:争议焦点清单,按优先级排序。
### 第七步:要件事实的证明
**任务**:围绕争点分析证据情况。
- 每个争点需要什么证据来证明?
- 现有证据是否充分?
- 举证责任如何分配?(谁主张谁举证,举证责任倒置情形)
- 有无证据缺口?如何补强?
- 对方证据的薄弱点在哪里?
**输出**:证据与争点对照表,标注证据充分性和风险点。
### 第八步:要件事实的认定
**任务**:基于证据认定事实。
- 依据构成要件"过滤"证据,排除无关联性证据
- 判断证据的真实性、合法性、关联性
- 对证据证明力大小作出判断
- 确认哪些要件事实可以认定、哪些不能
**输出**:事实认定结论,逐要件标注"已证明/未证明/证据不足"。
### 第九步:要件归入并得出结论
**任务**:将认定的事实归入法律要件,得出最终结论。
- 逐一比对:每个构成要件是否有事实支撑?
- 全部要件满足 → 请求权成立
- 任一要件不满足 → 请求权不成立
- 抗辩要件是否满足?
- 综合得出裁判/分析结论
**输出**:归入分析表 + 最终结论。
## 使用原则
1. **严格按顺序**:九步环环相扣,不跳步
2. **法条必须查原文**:涉及具体法律条文必须检索验证,不凭印象
3. **区分视角**:明确是站在原告、被告还是中立分析视角
4. **要件拆解是核心**:第四步做得越细,后续分析越精准
5. **争点是指挥棒**:第六步决定了后续证据分析的方向
6. **结论要有证据支撑**:每个判断都要指向具体证据
## 输出格式建议
分析报告按九步结构组织,每步包含:
- 分析过程
- 关键发现
- 风险提示(如有)
最后附总结:案件整体评估、胜诉概率判断(如适用)、策略建议。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
# add_clause after_search Fails After tracked_replace — Use Direct lxml Insertion
## Problem (2026-07-01 凤雅幼儿园劳务派遣协议)
After calling `ed.tracked_replace(old, new)` on multiple paragraphs, subsequent `ed.add_clause(text, after_search="...")` calls silently fail — the new paragraph doesn't appear in the output. The function returns without error but the clause is not inserted.
## Root Cause
`add_clause`'s `after_search` parameter searches paragraph text by concatenating all `<w:t>` elements. After `tracked_replace`, the paragraph's XML contains interleaved `<w:del>` and `<w:ins>` elements. The `after_search` text-matching logic may:
1. Include both old (del) and new (ins) text in the concatenation, so neither the old NOR new text matches cleanly
2. Match the wrong paragraph if the search string appears in unexpected combinations of del+ins text
## Solution: Direct lxml `addnext` Insertion
After all `tracked_replace` calls, insert new clauses directly using lxml:
```python
# Find reference paragraph by index or by scanning accepted-view text
paras = ed.body.findall(f'{WNS}p')
ref_para = paras[target_index] # e.g., P68
# Build INS paragraph
new_p = etree.Element(f'{WNS}p')
new_p.append(copy.deepcopy(ref_ppr)) # Clone paragraph formatting
ins = etree.SubElement(new_p, f'{WNS}ins')
ins.set(f'{WNS}id', next_rev_id())
ins.set(f'{WNS}author', 'WB')
ins.set(f'{WNS}date', rev_date)
r = etree.SubElement(ins, f'{WNS}r')
r.set(f'{WNS}rsidR', rsid)
r.insert(0, copy.deepcopy(ref_rpr))
t = etree.SubElement(r, f'{WNS}t')
t.set(XML_SPACE, 'preserve')
t.text = clause_text
# Mark paragraph itself as inserted (pPr/rPr/ins)
ppr = new_p.find(f'{WNS}pPr')
ppr_rpr = etree.SubElement(ppr, f'{WNS}rPr')
ppr_ins = etree.SubElement(ppr_rpr, f'{WNS}ins')
ppr_ins.set(f'{WNS}id', next_rev_id())
ppr_ins.set(f'{WNS}author', 'WB')
ppr_ins.set(f'{WNS}date', rev_date)
# Insert after reference
ref_para.addnext(new_p)
ref_para = new_p # Chain subsequent inserts
```
## When This Applies
- You need to both modify existing clauses (tracked_replace) AND add new clauses in the same editing session
- The `after_search` text has been altered by prior tracked_replace calls
## Correct Operation Order
1. All `ed.tracked_replace(...)` calls first
2. Then find target paragraphs by scanning the body with accepted-view text extraction
3. Insert new paragraphs directly via `addnext`
4. `ed.validate()` + `ed.save()`
## Verification
After save, scan paragraphs and confirm new clauses appear in accepted-view text at the expected positions.
@@ -0,0 +1,55 @@
# auto_notify_new_file.sh 架构:入队模式 vs 直接执行模式
## 根因(2026-06-30)
`auto_notify_new_file.sh` 原本自己启动 `uwf thread exec -c 5`(前台模式)来执行合同审查 workflow。
但 hermes ACP 适配器在前台模式下有 asyncio stdin 注册 bug(`KeyError: '0 is not registered'`),
导致每次 spawn `hermes acp` 子进程都立即失败,日志中全是 `agent command failed (uwf-hermes)`
**对比**`contract-queue-runner.sh` 使用 `uwf thread exec --background` 模式,正常工作。
## 当前架构(2026-06-30 修复后)
```
企微收到文件
→ auto_notify_new_file.sh (inotifywait 监控)
→ 识别 sender (邱律师=QiuTing)
→ 上传 Nextcloud 待审查/
→ 入队 /tmp/contract-queue/manifest.txt
→ 检查 contract-queue-runner.sh 是否在运行,不在则启动
→ contract-queue-runner.sh 串行执行(--background 模式)
→ uwf thread start + thread exec --background
→ uwf-hermes → hermes acp(正常)
```
## 关键文件
| 文件 | 路径 | 职责 |
|------|------|------|
| auto_notify | `~/.hermes/scripts/auto_notify_new_file.sh` | 监控文件到达、上传、入队 |
| queue runner | `~/.hermes/skills/devops/uwf/scripts/contract-queue-runner.sh` | 串行执行 workflow |
| watchdog | `~/.hermes/scripts/contract-queue-watchdog.sh` | 每20分钟检查卡住的 thread |
| auto_notify watchdog | `~/.hermes/scripts/auto_notify_watchdog.sh` | 每5分钟检查 auto_notify 进程 |
## 禁止回退
**绝不可把 auto_notify 改回自己启动 `uwf thread exec` 的模式**。前台模式有 ACP stdin bug,
只有 `--background` 模式能正常工作。如果未来需要修改 auto_notify 的 workflow 启动逻辑,
必须通过 contract-queue-runner 间接执行。
## 入队逻辑
```bash
# 入队到 contract-queue
cp "$filepath" "$QUEUE_DIR/${orig_name}"
# 追加到 manifest(去重)
if ! grep -qFx "$orig_name" "$QUEUE_DIR/manifest.txt" 2>/dev/null; then
echo "$orig_name" >> "$QUEUE_DIR/manifest.txt"
fi
# 检查 queue runner 是否在运行,不在则启动
if ! ps aux | grep -q "[c]ontract-queue-runner"; then
nohup bash "$HOME/.hermes/skills/devops/uwf/scripts/contract-queue-runner.sh" >> "$QUEUE_DIR/queue.log" 2>&1 &
fi
```
@@ -0,0 +1,98 @@
# 自动编号 → 手动固定编号修复(删段重排根治)
## 适用场景
他人(如屠佳青)用修订模式整段删除了一个**自动编号列表项**(段落 pPr 含 `<w:numPr>`,且段落标记 del=True),导致 OnlyOffice **markup 修订视图**把后续列表项渲染成"旧号新号"双编号:
```
(1)报名服务 ← 正常
(2)笔试服务[删除线] ← 被删,仍占编号位
(3)(2)面试服务 ← 双号!自动引擎按"接受后会变(2)"提前显示
(4)(3)项目管理 ← 双号!
```
Maggie/Doro 平时看 markup 视图,要求"修订视图下编号稳定显示 (1)(2)(3)(4)"。
根治办法:把这一组列表项从自动编号转成**手动文本编号**——文本是字面量,渲染器原样输出,不再经过自动编号引擎重排。
## 关键认知(动手前必须确认)
1. **被删项的删除是他人修订 → 绝不动其正文**(只在段首加编号 run,不碰 del 内容)。
2. **全文先确认没有对这些子项编号的交叉引用**(如"按上述第3项""见(4)")。本例"具体服务内容详见 附件一:服务报价单"是文字列举,非编号引用 → 安全。若存在引用,转手动编号后引用文字需同步核对。
3.`docker exec <nc容器> find ... ` 从 Nextcloud **拉当前交付版**作修复源,`md5sum` 比对确认本地副本没过时。
## 可复用代码(2026-06-16 验证通过)
```python
import zipfile, shutil, os
from lxml import etree
NS='http://schemas.openxmlformats.org/wordprocessingml/2006/main'
def q(t): return f'{{{NS}}}{t}'
def ln(el): return etree.QName(el).localname
src='交付版.docx'; out='FIXED.docx'
work='work.docx'; shutil.copy(src, work)
root=etree.fromstring(zipfile.ZipFile(work).read('word/document.xml'))
# 1. 按正文开头定位目标段(按你的合同改这些前缀)
segs={}
for p in root.iter(q('p')):
t=''.join((x.text or '') for x in p.iter() if ln(x) in ('t','delText'))
if t.startswith('报名服务:'): segs['报名']=p
elif t.startswith('笔试服务:提供'): segs['笔试']=p # 被删的那项
elif t.startswith('面试服务:'): segs['面试']=p
elif t.startswith('项目管理:整个项目'): segs['项目管理']=p
def make_rpr(): # 字体/字号照搬本段原文(本例宋体sz=24)。务必与目标段一致
rpr=etree.SubElement(etree.Element(q('tmp')), q('rPr'))
rf=etree.SubElement(rpr, q('rFonts'))
for a in ('ascii','hAnsi','cs'): rf.set(q(a),'宋体;SimSun')
etree.SubElement(rpr, q('sz')).set(q('val'),'24')
etree.SubElement(rpr, q('szCs')).set(q('val'),'24')
return rpr
def make_run(text):
r=etree.Element(q('r')); r.append(make_rpr())
t=etree.SubElement(r, q('t')); t.text=text
t.set('{http://www.w3.org/XML/1998/namespace}space','preserve')
return r
def remove_numpr(p):
ppr=p.find(q('pPr'))
if ppr is not None:
np=ppr.find(q('numPr'))
if np is not None: ppr.remove(np)
def insert_first(p, node): # 插到 pPr 之后、第一个内容元素之前
idx=len(p)
for i,c in enumerate(p):
if ln(c) in ('r','ins','del','hyperlink'): idx=i; break
p.insert(idx, node)
# 2. 普通项:明文编号 run
for key,label in [('报名','(1)'),('面试','(3)'),('项目管理','(4)')]:
remove_numpr(segs[key]); insert_first(segs[key], make_run(label))
# 3. 被删项:编号 run 必须包进【他人的】<w:del>(复制其 author/date)
p=segs['笔试']; remove_numpr(p)
ex=p.find(q('del')) # 已有的他人 del(屠佳青)
nd=etree.Element(q('del'))
nd.set(q('author'), ex.get(q('author'))) # 照抄他人 author
nd.set(q('date'), ex.get(q('date')))
nd.set(q('id'),'99001') # 不冲突的大 id
r=etree.SubElement(nd, q('r')); r.append(make_rpr())
dt=etree.SubElement(r, q('delText')); dt.text='(2)'
dt.set('{http://www.w3.org/XML/1998/namespace}space','preserve')
insert_first(p, nd)
# 4. 写回(只换 document.xml,其余条目原样复制)
new_doc=etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
with zipfile.ZipFile(work) as zin, zipfile.ZipFile(out,'w',zipfile.ZIP_DEFLATED) as zout:
for it in zin.namelist():
zout.writestr(it, new_doc if it=='word/document.xml' else zin.read(it))
```
## 交付前必验(vision 不可用时降级四查,缺一不可)
1. **逐段 markup diff vs 交付源** → 只有目标 N 段不同,其余零改动(本例 346 段只动 4 段)。
2. **pdftotext 渲染层数编号链**`onlyoffice-render.sh out.docx && pdftotext -f1 -l1 out.pdf -` 确认 1.2 下严格 (1)(2)(3)(4) 无双号。
3. **编号 run rPr == 本段正文 run rPr**(字体宋体、sz=24 一致)。
4. **接受修订后视图编号链连续** + `python-docx Document(out)` 可打开(XML 合法)。验证被删项的编号确实在 `<del author=他人>` 里、他人原 del 内容(id 不变)一字未动。
## 上传
`docker cp` 覆盖 `任务交付/` 同名文件 → `chown www-data``occ files:scan --path` → 清 OnlyOffice 缓存(`rm -rf .../App_Data/cache/files/*`)让 Maggie 打开看到新版。`md5sum` 比对容器内==本地确认上传成功。
@@ -0,0 +1,113 @@
# 在「自动编号的顶层列表」中新增条款(保留numPr)
2026-06-18 华新慢病运维合同实战。一次返工换来的教训。
## 适用判定(动手前先分清两类合同)
合同的「条款」分两种承载方式,新增条款的手法完全不同:
| 类型 | 特征 | 新增条款手法 |
|------|------|-------------|
| **A. 第X条 文本标题** | 条款标题是 run 里的文字「第七条 …」/「7. …」,段落**无** numPr | `add_clause()`(库会剥 numPr,正确)|
| **B. 自动编号列表项** | 条款本身是自动编号列表项:段落 pPr 带 `<w:numPr>`,编号由 numbering.xml 的 `start`+`lvlText`(如 `一、`/`1.`/`(1)`)自动渲染,run 里**没有**编号文字 | ❌ 不能用 `add_clause()`;按下方「numbered-insert」手法 |
**判定脚本**:对要插入位置附近的条款段落跑 `numbering-diagnose.py`,或直接看锚点段 `pPr/numPr` 是否存在且其 numId 的 lvlText 是序号格式。本案锚点「五、违约责任」段 `pPr` = `pStyle=12 + numPr(numId=1,ilvl=0) + ind`,numId=1→abstractNum start=1 lvlText=`%1、`(japaneseCounting 一、二、三)。
## 为什么 add_clause 在 B 类会坏
`add_clause()`(contract_docx_lib.py 第408-411行)**无条件**剥离新段的 numPr:
```python
numpr = new_ppr.find(qn('numPr'))
if numpr is not None:
new_ppr.remove(numpr) # ← B类灾难
```
后果(本案实测):5 个新增条款全部**丢失自动编号**,且因 pPr 缺 numPr/缩进与列表项不一致,渲染时**堆到了文档最末尾**(签署页之前),既无编号又错位——违反「新增条款插在逻辑对应位置、不堆到最后」+「自动编号保留numPr」两条规则。
`add_clause` 第二个缺陷:它只把**文本** run 包进 w:ins,**没有把段落标记(¶)标记为插入**。B 类里 ¶ 承载着自动编号,¶ 不是 tracked-insert,则接受/拒绝修订时这一项的编号增减不随修订走。
## 正确手法:numbered tracked-insert 段落
克隆锚点段的 pPr(**保留** numPr,让新段成为同一自动编号序列的一员),并把**段落标记本身**也标成 w:ins:
```python
import sys, copy
sys.path.insert(0, '/home/maggie/contract-work')
from contract_docx_lib import ContractEditor, qn
from lxml import etree
ed = ContractEditor(src)
# 1) 定位锚点段(要插在它之后的那条原文条款)
anchor = None
for p in ed.body.findall(qn('p')):
if '违约责任:按照中华人民共和国民法典' in ed.get_para_text(p):
anchor = p; break
anchor_ppr = anchor.find(qn('pPr'))
assert anchor_ppr.find(qn('numPr')) is not None, "锚点不是自动编号项,确认是否B类"
def make_numbered_ins_para(text):
new_p = etree.Element(qn('p'))
new_ppr = copy.deepcopy(anchor_ppr) # 含 pStyle + numPr(同numId/ilvl) + ind → 入同一自动编号序列
# 关键:把段落标记(¶)标成插入,整段(含自动编号)作为 tracked insertion
rpr_mark = new_ppr.find(qn('rPr'))
if rpr_mark is None:
rpr_mark = etree.SubElement(new_ppr, qn('rPr'))
ins_mark = etree.SubElement(rpr_mark, qn('ins'))
ins_mark.set(qn('id'), ed._next_id())
ins_mark.set(qn('author'), 'WB')
ins_mark.set(qn('date'), ed._revision_date)
new_p.append(new_ppr)
# 文本作为 tracked-ins run,用规范化 _body_rpr(完整rFonts四属性+hint=eastAsia+显式sz)
new_p.append(ed._mk_ins(text, ed._body_rpr))
return new_p
clauses = [ # 期望的最终正序 六~十
"保密与数据:……",
"知识产权与系统交接:……",
"转包与分包:……",
"第三方侵权:……",
"违约赔偿:……",
]
# 2) 全部插在 anchor 之后;倒序 insert 使最终正序
parent = ed.body
anchor_idx = list(parent).index(anchor)
for txt in reversed(clauses):
parent.insert(anchor_idx + 1, make_numbered_ins_para(txt))
assert ed.validate() == []
ed.save(out)
```
要点:
- **倒序插入**:每条都插在 `anchor_idx+1`,倒序遍历 → 最终正序。
- **同一 numId/ilvl**:克隆锚点 pPr 即自动继承,新条款自动续编(本案锚点是五 → 新条款渲染为六、七、八、九、十,后续原文自动顺延为十一、十二…,**无需手动改任何原文编号**)。
- **¶ 标插入** + **文本 run 标插入**,两者都要,缺一不可。
- 文本 run 用 `ed._body_rpr`(库已规整:四属性 rFonts + hint=eastAsia + 显式 sz),不要手搓 rPr。
## 交付前验证(B 类专项)
1. **接受所有修订后**渲染(删 w:del + 删带 `pPr/rPr/del` 的整段 + 解包 w:ins)→ 确认新条款编号与锚点连续、原文顺延正确、无错位到末尾。
2. **字体核对走「同段原文」标准**:本案原文正文 run = `<w:rFonts hint="eastAsia"/><w:szCs val="21"/>`(**无**显式 eastAsia 名,继承 docDefaults 宋体)。新 INS run 与之等效即合格——`ea=None hint=eastAsia` 是**正确**的,`wb-ins-font-verify.py` 若按绝对属性报 `ea=None` 是假阳性(见 contract-reviewer 的 2026-06-17 培训合同条)。唯一差异是 INS 多了显式 `<w:sz val="21">`(w:ins 必需),渲染一致。
3. **LibreOffice 渲染假象**:用 `libreoffice→pdftotext` 自查时,被顺延的自动编号项会显示 `十二、[七、]` 这种**方括号叠加**(recomputed 新号 + cached 旧号),这是 LibreOffice markup 渲染产物,**不是错误**,XML 里没有字面方括号。判真实编号一律以「接受所有修订后」或 OnlyOffice 渲染为准(OnlyOffice 是 Maggie/Doro 实际所用引擎)。
## 锚点选择铁律:插在 body text 之后,不是 heading 之后
**这是一个极易犯的错误**(2026-06-26 朱家角环保袋合同实证):
当Reviewer要求"在违约责任条款之后、争议解决条款之前新增XX条款"时,合同结构通常是:
```
P74: 八.违约责任 ← heading(numId=1)
P75: 若乙方未按本合同... ← body text(无 numPr)
P76: 九.合同金额 ← 下一个 heading(numId=1)
```
**错误做法**:锚点 = P74(heading),插入后 → 新条款夹在 heading 和它的 body text 之间,结构错乱。
**正确做法**:锚点 = P75(body text),插入后 → 新条款在 body text 之后、下一个 heading 之前,结构正确。
**判据**`numbering-diagnose.py` 确认锚点段的 `numPr` 状态——heading 有 numPr,body text 无 numPr。新条款应克隆**下一个 heading**(如 P76 合同金额)的 pPr(含 numPr),插入在**前一个 body text**(如 P75)之后。
## 一句话
锚点是自动编号列表项(pPr 有 numPr)→ 别用 add_clause,克隆锚点 pPr(留 numPr)+ ¶ 标 w:ins + 文本标 w:ins,倒序插入,新条款自动续编、原文自动顺延。**插在 body text 之后,不是 heading 之后。**
@@ -0,0 +1,95 @@
# A类(手动文本编号)新增条款:克隆"真实邻居段落"而非信任库的 _title_rpr / _body_rpr
2026-06-18 赵巷镇 X线设备采购合同实战。终审字体核验抓出"新标题不加粗",根因是库提取的标题格式丢了 bold。
## 何时用这套手法
- 合同是 **A 类**:条款标题是 run 里的**手动文本编号**(如 `8.争端的解决``第七条 索赔`),段落**无** numPr。
- 要新增一个带标题的条款(标题段 + 正文段),并希望格式与兄弟条款 100% 一致。
- (B 类自动编号列表项见 `auto-numbered-list-clause-insert.md`,手法不同。)
## 为什么不直接用库的 `_title_rpr` / `_body_rpr`
`ContractEditor._extract_formats()`(contract_docx_lib.py ~第86-175行)用启发式认"条款标题":
```python
is_clause_title = (re.match(r'^\d+[..、]\s*\S', p_text) or
re.match(r'^第[一二三四五六七八九十百千\d]+条\s*\S', p_text)) and len(p_text) < 30
# 且 _is_title_style() 要 <w:b/> 或标题字体(黑体/SimHei…) 才算 title
```
**坑**:当标题就是"8.争端的解决"(宋体 + `<w:b/>`,无特殊标题字体),若该段在扫描中**没被 `_is_title_style` 命中**(例如 b 标记在 bCs 旁、或正则边界),`clause_title_rpr` / `first_bold_rpr` 取空 → `_title_rpr` **回退到 `_body_rpr`(不含 bold)**
实测后果:新标题 `8.转包与分包` 的 INS run `bold=False`,而原文兄弟标题 `9.争端的解决` `bold=True``validate()` 查不出(它不比 bold),只有逐段 WB INS 与"同段/同级原文"对比才抓得到。
## 稳健手法:克隆紧邻同级原文段落
不取库的 `_title_rpr`/`_body_rpr`,改为**直接深拷贝隔壁真实条款段**的 pPr 和首个 run 的 rPr:
```python
import sys, copy
sys.path.insert(0, '/home/maggie/contract-work')
from contract_docx_lib import ContractEditor, qn
from lxml import etree
ed = ContractEditor(src) # 已先做完所有 tracked_replace
def find(kw):
for p in ed.body.findall(qn('p')):
if kw in ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}')):
return p
return None
# 克隆来源:插入点后面那条原文条款的【标题段】和它的【正文段】
title_src = find('8.争端的解决') # 兄弟条款标题(自带 <w:b/> + 宋体四属性 + 标题pPr缩进)
body_src = find('双方如在履行合同中发生纠纷') # 兄弟条款正文(无bold + firstLine=420 缩进)
def clone_as_ins(src_para, new_text):
"""深拷贝 src_para 的 pPr + 首run rPr,替换文本,整段(含¶)标 w:ins(author=WB)"""
np = etree.Element(qn('p'))
ppr = copy.deepcopy(src_para.find(qn('pPr')))
# ¶ 段落标记标插入
rprm = ppr.find(qn('rPr'))
if rprm is None:
rprm = etree.SubElement(ppr, qn('rPr'))
insm = etree.SubElement(rprm, qn('ins'))
insm.set(qn('id'), ed._next_id()); insm.set(qn('author'), 'WB'); insm.set(qn('date'), ed._revision_date)
np.append(ppr)
# run rPr 直接克隆兄弟段首 run(bold/字体/字号全继承,不碰库的默认值)
src_r = src_para.find(qn('r'))
src_rpr = copy.deepcopy(src_r.find(qn('rPr'))) if (src_r is not None and src_r.find(qn('rPr')) is not None) else None
np.append(ed._mk_ins(new_text, src_rpr))
return np
title_p = clone_as_ins(title_src, "8.转包与分包")
body_p = clone_as_ins(body_src, "未经甲方书面同意,乙方不得将本合同项下的…连带责任。")
idx = list(ed.body).index(title_src)
ed.body.insert(idx, title_p) # 标题插在兄弟条款标题之前 → 成为新的"8.",兄弟顺延为"9."
ed.body.insert(idx + 1, body_p)
```
## A类手动编号的顺延(与 B 类自动顺延不同!)
A 类编号是 run 里的字面文字,**不会自动顺延**。插入新"8."后,必须手动把后续所有手动编号 DEL 旧号+INS 新号(用 tracked_replace):
```python
for old, new in [("8.争端的解决","9.争端的解决"), ("9.合同生效","10.合同生效"),
("9.1 本合同在…","10.1 本合同在…"), ("10.合同附件","11.合同附件"),
("10.1 配置清单","11.1 配置清单"), ..., ("12.特别约定","13.特别约定")]:
ed.tracked_replace(old, new)
```
- **子编号一并顺延**(9.1/9.2→10.1/10.2,11.1-11.7→12.1-12.7)。
- 匹配串要够长以避免短串误命中(见 SKILL.md「tracked_replace 短字符串误命中」)。
## 交付前验证(必做)
1. **bold 对照**:新标题 INS run `bold==True` 且 ==兄弟标题;新正文 INS run `bold==False` 且有正确 `firstLine` 缩进。
```python
r = p.find('.//w:ins/w:r', ns); b = r.find('w:rPr/w:b', ns)
# 标题段 b is not None == 兄弟标题段 b is not None
```
2. **WB INS 字体逐段核验(相对同段/同级原文)**:异常应为 0。原文 run 有显式宋体四属性时,克隆来的 INS 也带四属性——与原文一致即合格。
3. **接受所有修订后渲染**,确认手动编号链连续(…7、**8.转包**、9、10、10.1、10.2、11…13),无重号/跳号。
4. python-docx 能打开(XML 合法)。
## 一句话
A 类手动编号合同新增带标题条款:**别用库的 `_title_rpr`/`_body_rpr`(启发式可能丢 bold)**,直接 `copy.deepcopy` 紧邻兄弟条款的【标题段】和【正文段】的 pPr+首run rPr,文本替换+整段标 w:ins;编号不会自动顺延,手动 tracked_replace 把后续主/子编号全部 +1。
@@ -0,0 +1,146 @@
# Comment Restoration from Original File
When comments are lost during docx editing (e.g., paragraph clear operations that remove commentRangeStart/End/Reference elements), restore them from the original file.
## Scenario
- Original file has N comments (e.g., Alice×2, 法务, 杜律 = 4 comments, ids 0-3)
- Edited file lost some/all original comments and may have added new ones (e.g., 华诚-Z comment id=0, Alice id=2)
- Goal: merge all comments — original ones preserved + new ones added, with non-conflicting IDs
## Recovery Technique
### Step 1: Extract original comments
```python
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
z_orig = zipfile.ZipFile('original.docx')
with z_orig.open('word/comments.xml') as f:
ctree_orig = etree.parse(f)
orig_comments = []
for c in ctree_orig.getroot().findall(f'{WNS}comment'):
orig_comments.append({
'id': c.get(f'{WNS}id'),
'author': c.get(f'{WNS}author'),
'date': c.get(f'{WNS}date'),
'text': ''.join(t.text for t in c.iter(f'{WNS}t') if t.text),
'element': copy.deepcopy(c)
})
z_orig.close()
```
### Step 2: Identify which comments survived in the edited file
```python
z_edit = zipfile.ZipFile('edited.docx')
with z_edit.open('word/comments.xml') as f:
ctree_edit = etree.parse(f)
edit_comment_ids = set()
for c in ctree_edit.getroot().findall(f'{WNS}comment'):
edit_comment_ids.add(c.get(f'{WNS}id'))
```
### Step 3: Find new comments (non-original authors)
```python
new_comments = []
for c in ctree_edit.getroot().findall(f'{WNS}comment'):
if c.get(f'{WNS}author') not in [oc['author'] for oc in orig_comments]:
new_comments.append({
'old_id': c.get(f'{WNS}id'),
'author': c.get(f'{WNS}author'),
'element': copy.deepcopy(c)
})
```
### Step 4: Rebuild comments.xml with all comments
Assign non-conflicting IDs:
- Original comments keep their original IDs (0, 1, 2, 3)
- New comments get IDs starting from max(original_ids) + 1
```python
new_comments_xml = etree.Element(f'{WNS}comments')
new_comments_xml.set('xmlns:w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main')
# ... add other namespaces as needed
max_id = max(int(oc['id']) for oc in orig_comments)
# Add original comments
for oc in orig_comments:
new_comments_xml.append(oc['element'])
# Add new comments with renumbered IDs
for nc in new_comments:
max_id += 1
nc['new_id'] = str(max_id)
nc['element'].set(f'{WNS}id', nc['new_id'])
new_comments_xml.append(nc['element'])
```
### Step 5: Update document.xml comment references
For each new comment, find its commentRangeStart, commentRangeEnd, and commentReference in document.xml and update the ID from old to new:
```python
for nc in new_comments:
old_id = nc['old_id']
new_id = nc['new_id']
# Update commentRangeStart
for elem in root.iter(f'{WNS}commentRangeStart'):
if elem.get(f'{WNS}id') == old_id:
elem.set(f'{WNS}id', new_id)
# Update commentRangeEnd
for elem in root.iter(f'{WNS}commentRangeEnd'):
if elem.get(f'{WNS}id') == old_id:
elem.set(f'{WNS}id', new_id)
# Update commentReference (inside w:r)
for elem in root.iter(f'{WNS}commentReference'):
if elem.get(f'{WNS}id') == old_id:
elem.set(f'{WNS}id', new_id)
```
### Step 6: Write back to docx
```python
z_out = zipfile.ZipFile('output.docx', 'w')
# Copy all files from edited.docx except comments.xml and document.xml
for item in z_edit.namelist():
if item not in ('word/comments.xml', 'word/document.xml'):
z_out.writestr(item, z_edit.read(item))
# Write updated comments.xml
z_out.writestr('word/comments.xml',
etree.tostring(new_comments_xml, encoding='UTF-8', xml_declaration=True, standalone=True))
# Write updated document.xml
z_out.writestr('word/document.xml',
etree.tostring(tree, encoding='UTF-8', xml_declaration=True, standalone=True))
z_edit.close()
z_out.close()
```
## Verification
```python
z = zipfile.ZipFile('output.docx')
with z.open('word/comments.xml') as f:
ctree = etree.parse(f)
for c in ctree.getroot().findall(f'{WNS}comment'):
print(f" id={c.get(f'{WNS}id')} author={c.get(f'{WNS}author')}: {text[:80]}")
# Check all IDs referenced in document.xml exist in comments.xml
content = z.read('word/document.xml').decode('utf-8')
doc_ids = set(re.findall(r'commentRangeStart[^>]*w:id="(\d+)"', content))
doc_ids |= set(re.findall(r'commentRangeEnd[^>]*w:id="(\d+)"', content))
doc_ids |= set(re.findall(r'commentReference[^>]*w:id="(\d+)"', content))
comment_ids = set(c.get(f'{WNS}id') for c in ctree.getroot().findall(f'{WNS}comment'))
assert doc_ids == comment_ids, f"ID mismatch: doc={doc_ids} comments={comment_ids}"
```
## Key Pitfall: Comment Text Extraction
When extracting comment text for comparison, comments may have nested `<w:p>` elements (multi-paragraph comments). Use `.iter()` not `.findall()` to get all text nodes.
## Empirical Case (2026-07-01 反委托代发工资协议)
- Original: 4 comments (Alice id=0, Alice id=1, 法务 id=2, 杜律 id=3)
- v1_doro_updated: 2 comments (华诚-Z id=0, Alice id=2) — lost Alice id=0/1, 法务, 杜律
- Final: 5 comments (Alice id=0, Alice id=1, 法务 id=2, 杜律 id=3, 华诚-Z id=4)
- 华诚-Z's comment was id=0 in v1_doro_updated, renumbered to id=4 in final
- All commentRangeStart/End/Reference IDs updated in document.xml accordingly
@@ -0,0 +1,94 @@
# 合同模板修订工作流(非workflow场景)
## 触发条件
用户要求参考一份新合同模板(保护甲方),将有利内容用修订模式改进原合同(乙方模板)。
## 与标准 review-contract workflow 的区别
- 不涉及 classifier/reviewer/editor/deliverer 角色链
- 不使用 review-rules.md
- 不需要 pass 流程(不写 tracker/xlsx)
- 直接用 ContractEditor 库手动修订
## 操作步骤
### 1. 读取两份合同
```python
from contract_docx_lib import ContractEditor
editor = ContractEditor('原合同.docx') # 乙方模板,作为修订基底
```
同时用 python-docx 或 zipfile+lxml 读取新合同全文,逐条对比差异。
### 2. 识别差异并分类
- **可直接移植**:新合同中明确有利于甲方的条款(如违约金降低、管辖权、解除权限制)
- **需要调整**:新合同有利但需适配原合同结构/编号的条款
- **需要补充**:新合同仍未覆盖的保护甲方的内容(根据法律法规判断)
### 3. 执行修订(最小化修改原则)
- 整体格式、编号逻辑按**原合同**来
-`tracked_replace` 修改既有条款
-`add_clause` 新增条款(插在合同逻辑对应位置)
- author=WB
### 4. 法律研究(严禁凭记忆)
每次修订前必须查证:
- 最新法律法规(民法典、劳动合同法、劳务派遣暂行规定等)
- 上海地区地方规定和司法实践
- 行业惯例
常见需要查证的点:
- 违约金比例上限(司法实践中过高会被调整)
- 管辖权约定(甲方所在地法院 vs 仲裁)
- 劳务派遣的法定退回情形(劳动合同法第65条)
- 雇主责任险要求(上海地区实务惯例)
- 经济补偿金的法定标准
### 5. 修订说明
完成后向用户汇报:
- 修订数量(insertions/deletions)
- 每项修订的法律依据
- 标注哪些是根据新合同移植、哪些是独立判断补充
## 违约后果公式(核心原则,2026-06-29 Doro纠正)
**"权利是法律给的,关键在违约后果"**——当法律已赋予甲方某项权利时,合同中简单写入"甲方有权XX"只是重复法律,没有实质保护价值。审查/修订的重点是**违约后果条款**:
### 标准违约后果公式
```
甲方因此支付的一切费用、承担的赔偿或补偿金、损失等由乙方全额赔偿,
乙方另向甲方支付违约金人民币 元。
如对甲方造成其他不良影响的,乙方还应当消除一切影响。
```
### 三要素
1. **赔偿范围**:一切费用、承担的赔偿或补偿金、损失等(括注具体类型如重新招聘费用、行政罚款、律师费、诉讼费等)
2. **违约金**:金额留空(6个空格),由甲方根据实际用工规模和风险自行填写
3. **消除影响**:兜底,覆盖名誉损害、商誉损失等非经济损失
### 适用场景
所有"乙方违反法定义务→甲方有权XX"类条款:
- 资质丧失 → 不止"甲方有权解除",要追加完整后果公式
- 克扣工资/欠缴社保 → 不止"暂停付款",要追加连带后果公式
- 一般违约追偿 → 不止"有权追偿",要写清赔偿范围+违约金+消除影响
### 劳务派遣协议实证(2026-06-29)
| 条款 | 原写法(弱) | 改后(含后果公式) |
|------|------------|-------------------|
| 资质丧失 | "甲方有权解除,乙方赔偿全部损失" | "乙方赔偿一切费用/赔偿或补偿金/损失(含重新招聘费、劳动者赔偿金、行政罚款、律师费等)+违约金___元+消除一切影响" |
| 审核权 | "暂停支付相关费用直至整改完成" | 追加:因乙方违法行为导致甲方承担连带责任的,一切费用由乙方赔偿+违约金+消除影响 |
| 一般追偿 | "甲方有权依法向乙方追偿" | "一切费用由乙方赔偿+违约金+消除一切影响" |
## 2026-06-29 劳务派遣协议案修订清单
| 修订 | 类型 | 法律依据 |
|------|------|----------|
| 乙方资质持续保证 | 新增 | 《劳务派遣暂行规定》第17条 |
| 甲方监督检查权扩展 | 修改 | 《劳动合同法》第62条 |
| 甲方调整岗位权 | 新增 | 《劳动合同法》第62条 |
| 甲方随时退回权 | 新增 | 《劳动合同法》第65条、《劳务派遣暂行规定》第12条 |
| 雇主责任险要求 | 新增 | 上海司法实践惯例 |
| 乙方解除权限制 | 修改 | 《民法典》第563条(催告程序) |
| 付款期限延长 | 修改 | 商业条款(甲方资金调度) |
| 甲方违约金降低 | 修改 | 上海法院对过高违约金的司法调整 |
| 乙方根本违约情形 | 新增 | 《民法典》第563条 |
| 争议解决管辖 | 新增 | 《民事诉讼法》第35条(协议管辖) |
| 附件和补充协议 | 新增 | 标准合同条款 |
@@ -0,0 +1,33 @@
# 跨境并购费用参考(5000万人民币交易规模)
> 来源:行业公开数据与市场实践,2026年6月。具体费用因交易复杂度、目标法域、各方谈判能力而异。
## 各角色费用区间
| 角色 | 费用(人民币) | 收费模式 |
|---|---|---|
| 财务顾问(FA) | 150万–250万 | 成功费,交易对价3%–5%;分期收取(签约10–20%,签约后40%,交割后40–50%) |
| 法律顾问(中国律所) | 50万–100万 | 固定费,含法律尽调15–30万、交易文件20–40万、监管审批10–20万、境外律师协调5–10万 |
| 境外律师 | 30万–80万 | 按小时(300–800美元/小时),目标法域决定 |
| 会计师(财务尽调) | 20万–40万 | 固定费 |
| 税务师 | 15万–35万 | 固定费,含税务尽调10–20万、结构优化5–15万 |
| **合计** | **265万–505万** | 占交易额约5%–10% |
## FA 费率惯例
- 中国市场:中端交易3%–5%,大型交易费率递减
- 海外莱曼公式(Lehman Formula):累退费率,5000万人民币≈680万欧元→约15万欧元(约118万人民币),但中国市场费率通常高于莱曼
- 中国FA实操中常用"一口价"或协商费率,少见纯莱曼公式
## 交易协调人(律师兼任)收费参考
- 固定项目管理费:5万–10万/月,或每项目15万–30万
- 从FA成功费分成:10%–15%
- 最优组合:固定费(保底)+ FA分成(激励)+ 法律费独立收取(不混)
## 第三方机构管理原则
- FA负责整体协调,但不代替第三方出具报告
- 第三方费用由客户直接支付
- 各机构独立承担专业责任
- 律师(作为交易协调人)可帮FA管理第三方机构,但不能替第三方机构的工作成果背书
@@ -0,0 +1,102 @@
# DOCX 批注(Word 原生 comment)插入 — 纯 zipfile+lxml
实战来源:南通新东方校外培训服务合同独立审查(2026-06-17)。Maggie 要求"用修订**和批注**的形式"。`ContractEditor` 没有批注方法(`dir()` 确认无 comment/annot/note),批注必须手写 OOXML。已验证可在 OnlyOffice 正常显示。
## 何时用 DOCX 批注 vs PDF 批注
- **docx 合同** → 用本文方法(Word 原生 comment,OnlyOffice 显示为右侧批注气泡)。
- **PDF 合同** → 用 pymupdf(fitz) 高亮+comment annotation(见 SKILL.md「PDF合同直接批注」)。两者不通用。
## 批注内容铁律(与 SKILL.md 一致,复述强调)
- 只写"建议……",给方案;**不写理由/原因/因为**;**不加【新增】【建议】等标签**。
- 批注仅限两类:①需客户确认(名称空白、标准未定义需明示);②建议增加条款且内容较长。**选择题/勾选项不处理(2026-07-08废止)。**
- 能直接修订的一律修订,批注是最后手段。
## 五个改动点(缺一不可,否则 Word 报"无法打开/需修复")
1. **新增 `word/comments.xml`**:定义每条批注的 id/author/date/initials + 内容。
2. **`word/document.xml`**:在锚点文本范围**前**插 `w:commentRangeStart`、**后**插 `w:commentRangeEnd` + 一个带 `w:commentReference` 的 run。
3. **`[Content_Types].xml`**:加 `Override` 声明 comments.xml 的 content-type。
4. **`word/_rels/document.xml.rels`**:加 `Relationship` 指向 comments.xml。
5. 三处 id(rangeStart/rangeEnd/commentReference)与 comments.xml 的 `w:comment/@w:id` **必须全部一致**
## 锚点定位(关键陷阱)
- 锚点文本要按**接受修订后**的文本匹配(遍历 w:t 时**跳过 w:del 内的**),否则被删字符会让匹配错位。
- commentRangeStart 必须插在段落第一个 `w:r` **或 `w:ins`** 之前(不能只找 w:r——修订后段首可能是 ins)。
- 先验证每个锚点在全文**唯一命中**(命中数==1)再插,多处命中会挂错段落。
## 可复用代码
```python
import zipfile, io
from lxml import etree
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
Wq = '{' + W + '}'
DATE = "2026-06-17T10:00:00Z"
comments = [ # 站顾问单位立场,需确认/建议增加内容
{"id":"201","anchor":"甲方扣除相应服务费后","text":"建议在合同或退费管理制度中明确“服务费”的扣费比例或计算方式,并在签约时向乙方明示。"},
{"id":"202","anchor":"向甲方住所地人民法院提起诉讼","text":"本条约定甲方住所地法院管辖,建议签约时以加粗或单独提示方式向乙方说明,尽到格式条款提示义务。"},
]
def build_comments_xml(comments):
p = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
f'<w:comments xmlns:w="{W}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">']
for c in comments:
p.append(f'<w:comment w:id="{c["id"]}" w:author="WB" w:date="{DATE}" w:initials="WB">')
# 批注文字字体随原文(本例宋体sz=18小一号),hint=eastAsia 必带
p.append('<w:p><w:r><w:rPr><w:rFonts w:ascii="宋体" w:hAnsi="宋体" w:eastAsia="宋体" w:cs="宋体" w:hint="eastAsia"/><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr>')
p.append(f'<w:t xml:space="preserve">{c["text"]}</w:t></w:r></w:p></w:comment>')
p.append('</w:comments>')
return ''.join(p)
comments_xml = build_comments_xml(comments)
with open('IN.docx','rb') as f: data=f.read()
bi, bo = io.BytesIO(data), io.BytesIO()
inserted = {c["id"]: False for c in comments}
with zipfile.ZipFile(bi) as zin, zipfile.ZipFile(bo,'w',zipfile.ZIP_DEFLATED) as zout:
for it in zin.infolist():
raw = zin.read(it.filename)
if it.filename == 'word/document.xml':
tree = etree.fromstring(raw); body = tree.find(f'{Wq}body')
for para in body.findall(f'.//{Wq}p'):
ptext = '' # 接受修订后文本:跳过 del
for t in para.findall(f'.//{Wq}t'):
if not any(a.tag==f'{Wq}del' for a in t.iterancestors()):
ptext += (t.text or '')
for c in comments:
if not inserted[c["id"]] and c["anchor"] in ptext:
cid = c["id"]
first = next((ch for ch in para if ch.tag in (f'{Wq}r',f'{Wq}ins')), None)
if first is None: continue
crs = etree.Element(f'{Wq}commentRangeStart'); crs.set(f'{Wq}id',cid); first.addprevious(crs)
cre = etree.Element(f'{Wq}commentRangeEnd'); cre.set(f'{Wq}id',cid); para.append(cre)
rr = etree.SubElement(para,f'{Wq}r'); rp=etree.SubElement(rr,f'{Wq}rPr')
rs = etree.SubElement(rp,f'{Wq}rStyle'); rs.set(f'{Wq}val','CommentReference')
cref = etree.SubElement(rr,f'{Wq}commentReference'); cref.set(f'{Wq}id',cid)
inserted[cid] = True
raw = etree.tostring(tree, xml_declaration=True, encoding='UTF-8', standalone=True)
elif it.filename == '[Content_Types].xml':
ct = etree.fromstring(raw); NS='http://schemas.openxmlformats.org/package/2006/content-types'
ov = etree.SubElement(ct,f'{{{NS}}}Override')
ov.set('PartName','/word/comments.xml')
ov.set('ContentType','application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml')
raw = etree.tostring(ct, xml_declaration=True, encoding='UTF-8', standalone=True)
elif it.filename == 'word/_rels/document.xml.rels':
rt = etree.fromstring(raw); RNS='http://schemas.openxmlformats.org/package/2006/relationships'
r = etree.SubElement(rt,f'{{{RNS}}}Relationship')
r.set('Id','rIdComments1')
r.set('Type','http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments')
r.set('Target','comments.xml')
raw = etree.tostring(rt, xml_declaration=True, encoding='UTF-8', standalone=True)
zout.writestr(it, raw)
zout.writestr('word/comments.xml', comments_xml.encode('utf-8'))
with open('OUT.docx','wb') as f: f.write(bo.getvalue())
assert all(inserted.values()), f"未全部挂靠: {inserted}"
```
## 交付前验证(缺一不可)
1. **id 四向一致**`commentRangeStart` / `commentRangeEnd` / `commentReference` 三组 id 集合 == comments.xml 的 `w:comment/@w:id` 集合。
2. **python-docx 能打开**(XML 合法)。
3. **接受修订后锚点存在**:批注挂靠的文本在去 del 后仍在。
4. **OnlyOffice 渲染**(onlyoffice-render.sh)确认批注气泡正常显示,不破坏修订标记。
## 修订与批注可共存
同一份 docx 先用 ContractEditor 做完 tracked_replace/add_clause 并 save,再在产物上跑本脚本加批注。批注的 commentRangeStart 会落在修订后的段落结构里(段首可能是 w:ins),代码已用 `(w:r, w:ins)` 兼容。
@@ -0,0 +1,42 @@
# 检测已审查文件重复处理(Workflow产出双版本问题)
## 2026-07-13 消防设施检测合同教训
### 现象
同一份合同在任务交付目录出现两个文件:
- v1: 有WB tracked changes(正确交付物)
- v2: 无tracked changes + 有WB批注(纯批注版)
### 诊断方法
```python
# 快速判断文件性质
with zipfile.ZipFile(filepath, 'r') as z:
doc_xml = z.read('word/document.xml')
# 检查tracked changes
ins_count = doc_xml.count(b'w:ins')
del_count = doc_xml.count(b'w:del')
# 检查批注
has_comments = 'word/comments.xml' in z.namelist()
if has_comments:
comments = z.read('word/comments.xml')
comment_count = comments.count(b'w:comment ')
print(f"INS: {ins_count}, DEL: {del_count}, Comments: {comment_count}")
```
### 判断标准
| 文件状态 | 性质 | 应否保留 |
|----------|------|----------|
| 有INS/DEL + 无comments | 标准修订版 | ✅ 正确交付物 |
| 有INS/DEL + 有comments | 修订+批注版 | ✅ 正确 |
| 无INS/DEL + 有comments | 纯批注版 | ⚠️ 需审查批注合规性 |
| 无INS/DEL + 无comments | 原文副本 | ❌ 不应在交付目录 |
### 纯批注版的审查要点
- 是否违反"能改就不批注"原则
- 批注立场是否正确(站甲方)
- 是否属于"提醒性批注"(禁止)
- **严重错误示例**:Comment 203建议"违约金偏高,建议设上限"——这是在帮乙方限制甲方的违约金权利,立场完全反了
### python-docx的.text陷阱
`paragraph.text`不反映批注内容。两份文件的`.text`可能100%相同但实际一份有6条批注。**判断文件是否相同必须检查comments.xml**。
@@ -0,0 +1,42 @@
# 文件版本管理纪律(2026-07-01 总结多次返工教训)
## 铁律:操作前备份,操作后验证,不覆盖不重做
### 1. 操作前必须备份
任何对 docx 文件的修改操作前,先 `cp` 一份到 `/tmp/contract-backup/` 并带时间戳:
```bash
cp /tmp/反委托_版本1.docx /tmp/contract-backup/反委托_版本1_$(date +%H%M).docx
```
2026-07-01教训:反委托代发工资协议做了7-8个版本,每次覆盖前一版,最终华诚-Z的修订痕迹差点不可恢复(在v1_doro_updated.docx中找到最后一份)。
### 2. 增量修复,不从头重做
出问题时修补当前版本,不从原文件重新做一遍。重做=覆盖=丢失中间状态。
### 3. 操作后验证完整性
每次修改 docx 后必须验证:
- comments.xml:批注数量、作者、ID 是否完整(与修改前对比)
- document.xml:tracked changes 的 author 集合是否正确
- 文件大小:是否合理(不应比修改前小太多)
### 4. 中间版本命名规范
```
反委托_版本1_v1.docx → 第一版
反委托_版本1_v2.docx → 第二版(不覆盖v1)
反委托_版本1_v3.docx → 第三版
反委托_版本1_final.docx → 确认后的最终版(覆盖上传到Nextcloud)
```
### 5. Subagent 输出必须验证
delegate_task 返回后:
- 检查 result.status 是否 "completed"
- 对文件类结果:用 zipfile 打开验证 comments/tracked changes 完整性
- 不能假设 subagent 正确——它可能丢批注、改错 author、漏条款
## 常见覆盖事故
| 事故 | 根因 | 预防 |
|------|------|------|
| 华诚-Z修订被全部改成WB | 多次重做时每次都"统一author=WB" | 备份原始含华诚-Z的版本 |
| 批注丢失(4条变2条) | 从头重建时没对比原文件的comments.xml | 修改后立即验证批注数量 |
| 字体覆盖(仿宋_GB2312→仿宋) | 重做时用了错误的字体名 | 从原文件克隆rPr,不手写 |
@@ -0,0 +1,144 @@
# Layering WB Revisions on High-Density Tracked Changes Documents
## Problem
When a document already has extensive tracked changes from another author (e.g., 华诚-Z with 170+ INS and 90+ DEL), ContractEditor's `tracked_replace` frequently fails with `ValueError: Element is not a child of this node` because the paragraph structure is heavily fragmented with interleaved `w:ins`/`w:del`/`w:r` elements.
## Solution: Direct lxml Operations
### Strategy
Use zipfile + lxml to directly manipulate the XML instead of ContractEditor library. Three operation types:
### 1. Append text to existing paragraph end
Find the paragraph, locate the last content element, and append a `w:ins` after it.
```python
# Find the last non-pPr child element in the paragraph
last_content = None
for child in p:
if child.tag != f'{WNS}pPr':
last_content = child
# Create INS element
ins = etree.SubElement(p, f'{WNS}ins')
ins.set(f'{WNS}id', str(next_id))
ins.set(f'{WNS}author', 'WB')
ins.set(f'{WNS}date', '2026-07-02T00:00:00Z')
r = etree.SubElement(ins, f'{WNS}r')
# Clone rPr from nearby run
rpr = get_reference_rpr(p) # see below
if rpr is not None:
r.insert(0, copy.deepcopy(rpr))
t = etree.SubElement(r, f'{WNS}t')
t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
t.text = "追加的文字内容"
```
### 2. Insert new paragraph (全段INS)
Clone neighboring paragraph's pPr, create a new `w:p` with all content inside `w:ins`.
```python
# Clone pPr from reference paragraph
ref_p = paras[target_idx] # the paragraph after which to insert
new_p = etree.Element(f'{WNS}p')
# Clone pPr
ref_ppr = ref_p.find(f'{WNS}pPr')
if ref_ppr is not None:
new_p.append(copy.deepcopy(ref_ppr))
# Create INS wrapping all content
ins = etree.SubElement(new_p, f'{WNS}ins')
ins.set(f'{WNS}id', str(next_id))
ins.set(f'{WNS}author', 'WB')
ins.set(f'{WNS}date', '2026-07-02T00:00:00Z')
r = etree.SubElement(ins, f'{WNS}r')
rpr = get_reference_rpr(ref_p)
if rpr is not None:
r.insert(0, copy.deepcopy(rpr))
t = etree.SubElement(r, f'{WNS}t')
t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
t.text = "新增条款全文"
# Insert after reference paragraph
ref_p.addnext(new_p)
```
### 3. Character-level replacement within high-density paragraph
When text to replace is inside an existing `w:ins` from another author (e.g., 华诚-Z), you need to split that ins element.
```python
# Find the ins element containing target text
for ins_elem in p.findall(f'{WNS}ins'):
for r in ins_elem.findall(f'{WNS}r'):
t = r.find(f'{WNS}t')
if t is not None and t.text and old_text in t.text:
# Split: keep text before, add WB del+ins for changed part, keep text after
pos = t.text.index(old_text)
before = t.text[:pos]
after = t.text[pos + len(old_text):]
# Modify existing t to keep only 'before'
t.text = before + after.replace(old_text, new_text) # simplified
# Or split into multiple elements...
```
### Getting reference rPr
```python
def get_reference_rpr(p):
"""Get rPr from first non-del run in paragraph, or from 华诚-Z ins"""
# Try plain runs first
for r in p.findall(f'{WNS}r'):
rpr = r.find(f'{WNS}rPr')
if rpr is not None:
return rpr
# Try non-WB ins elements
for ins in p.findall(f'{WNS}ins'):
if ins.get(f'{WNS}author') != 'WB':
for r in ins.findall(f'{WNS}r'):
rpr = r.find(f'{WNS}rPr')
if rpr is not None:
return rpr
# Try previous paragraph
prev = p.getprevious()
if prev is not None:
return get_reference_rpr(prev)
return None
```
## Critical: Post-save sz fix
When INS runs clone rPr from paragraphs that lack explicit `w:sz` (relying on style inheritance), the INS will render at wrong size. **Always run a post-save sweep:**
```python
# Determine dominant body sz from neighboring paragraphs
# Then fix all WB INS runs missing sz
for ins in body.iter(f'{WNS}ins'):
if ins.get(f'{WNS}author') != 'WB':
continue
for r in ins.findall(f'{WNS}r'):
rpr = r.find(f'{WNS}rPr')
if rpr is not None:
sz = rpr.find(f'{WNS}sz')
if sz is None:
sz = etree.SubElement(rpr, f'{WNS}sz')
sz.set(f'{WNS}val', dominant_sz) # e.g., '24' for 12pt
szCs = etree.SubElement(rpr, f'{WNS}szCs')
szCs.set(f'{WNS}val', dominant_sz)
```
## Author Unification
After Doro reviews and confirms, unify all authors to WB:
```bash
python scripts/unify-author-wb.py input.docx [output.docx]
```
## Lesson Learned (2026-07-02)
- Doro will edit the files in OnlyOffice after upload. Always download Doro's version before doing further work.
- "你自己要满意再给我" = self-verify before delivery, don't ask user to check.
- "认真做" = thoroughness signal. Read full contract text, verify each modification landed correctly.
- When Doro says "看看是否还有需要调整的" = compare your version vs Doro's, identify what Doro changed, assess if further work needed.
- Unifying author is a standard final step — use the script, don't hand-code each time.
@@ -0,0 +1,27 @@
---
name: lawyer-letter-formatting
description: 律师函制作格式要点。基于Watson&Band模板,logo在正文段落anchor中而非header XML。
tags: [legal, lawyer-letter, docx, formatting]
---
# 律师函制作
## 关键格式(参考_律师函模板)
- **字体**:仿宋 12pt,西文Times New Roman
- **首行缩进**:304800 EMU
- **行距**:1.25倍
- **对齐**:两端对齐(JUSTIFY)
- **列表编号**:numbering.xml中japaneseCounting格式(第一、第二、第三、)
- **送达信息**:9pt
## 关键陷阱
- **Logo不在header XML中**!是作为浮动锚点(anchor drawing)嵌在正文第一段落的run中
- 用python-docx重建段落会丢失drawing元素,必须从模板段落提取保留
- 复制模板时要保留原始段落的XML结构,不能只复制文字
## 参考文件位置
- 模板:Doro诉讼案件任务/参考文件/_律师函
## 交付位置
- 放到 Doro其他任务/交付文件/(不是待处理任务)
- 交付后@doro通知
@@ -0,0 +1,68 @@
# 在「用户已自行修订过」的合同上叠加我方修订
实战来源:金信大厦5层东部租赁合同(2026-06-25)。Maggie 本人已用修订模式改了 6 处(author="maggie jia"),要求小Maggie 在此基础上**再补几处**(模版比对后补不可抗力对等、装修残值公式、抵押救济),**保留她的全部修订一字不动**。
## 何时用本配方
- 收到的 docx **已带 track changes**(settings.xml 有 `<w:trackRevisions/>`,文中有 author≠WB/小Maggie 的 w:ins/w:del)。
- 任务是**在用户既有修订之上追加几处**,不是重审、不是从干净稿做。
- **不重跑 workflow,也不用 ContractEditor 库**——库的字符级 diff 引擎会把用户既有 w:ins/w:del 卷进来重算,破坏其修订。一律 zipfile+lxml 直接追加节点。
## 五步配方
### 1. 新修订 id 从 `maxid+1000` 起,防撞 + 便于事后过滤
```python
maxid = 0
for el in root.iter():
if el.tag in (Wq+"ins", Wq+"del"):
v = el.get(Wq+"id")
if v and v.isdigit(): maxid = max(maxid, int(v))
nextid = [maxid + 1000] # 1000 间隔:本次新增 id 全 >1000,过滤/核验时一眼区分
def newid(): nextid[0]+=1; return str(nextid[0])
```
为什么 +1000 不是 +1:核验「我的修订」与「用户的修订」时,`int(id)>1000` 直接切分两批,不必记具体数字。
### 2. 作者:沿用文档既有修订线,不强行套 WB
金信大厦案文档既有修订 author="maggie jia",本次追加**沿用同一 author**(保持修订线一致、Maggie 看就是「她那条线的延续」)。
> 注意与「author 铁律=WB」的边界:WB 是 Doro 体系合同审查的署名;当**文档已有用户自己的修订线**、任务是「在她的修订上接着改」时,沿用她的 author 让修订归并到同一作者更自然。归属按文档既有线定,不是无脑套 WB。拿不准就问。
### 3. rPr:克隆「用户已渲染正确的 INS」当样板,预防中文字体回退坑
不要自己造 rPr。找一个用户已有的、**中文显示正常的** w:ins run,读它的 rPr 当模板:
```python
# 金信大厦案模板:<w:rFonts w:ascii="Times New Roman" w:eastAsiaTheme="minorEastAsia"
# w:hAnsi="Times New Roman" w:cs="Times New Roman" w:hint="eastAsia"/>
# <w:sz w:val="21"/><w:szCs w:val="21"/>
def make_rpr():
rpr = etree.Element(Wq+"rPr")
rf = etree.SubElement(rpr, Wq+"rFonts")
rf.set(Wq+"ascii","Times New Roman"); rf.set(Wq+"eastAsiaTheme","minorEastAsia")
rf.set(Wq+"hAnsi","Times New Roman"); rf.set(Wq+"cs","Times New Roman"); rf.set(Wq+"hint","eastAsia")
sz = etree.SubElement(rpr, Wq+"sz"); sz.set(Wq+"val","21")
etree.SubElement(rpr, Wq+"szCs").set(Wq+"val","21")
return rpr
```
`eastAsiaTheme="minorEastAsia"+hint="eastAsia"` 让中文走主题回退(金信大厦回退到宋体),西文 Times New Roman——这是这类合同 INS 中文正常显示的关键,详见 SKILL.md「INS 中文字体」节。
### 4. 三种插入机制(按改动类型选)
- **纯追加**(句末补一句救济/公式):定位段落最后一个 normal run / 最后一个 ins,`last.addnext(make_ins(text, rpr))`
- **删一段换对等表述**(单向条款改双向):split 原 run → 原 run 文本保留前半、`addnext(make_del(后半, 原rpr))` → 再 `del.addnext(make_ins(对等表述, rpr))`
- **替换数值/词**(6→12 个月、percent→百分之):字符级定位,DEL 旧 + INS 新。
`make_del``<w:del><w:r><w:delText>`、克隆原 run 的 rPr 加 `rsidDel``make_ins``<w:ins><w:r><w:t xml:space="preserve">`
### 5. 只换 document.xml(settings 已开 trackRevisions 就不动它)
```python
with zipfile.ZipFile(SRC) as zin, zipfile.ZipFile(tmp,"w",zipfile.ZIP_DEFLATED) as zout:
for it in zin.namelist():
zout.writestr(it, new_doc if it=="word/document.xml" else zin.read(it))
```
## 四查验证(缺一不可)
1. **python-docx 能打开**`Document(out)`)——XML 合法。
2. **接受所有修订后文本正确**——抽出「保留 ins 内容、丢弃 del 内容」的纯文本,逐处核我改的几段语句通顺、内容对。
3. **本次新增 INS(id>1000)含中文 run 字体非 Times New Roman**——`eastAsiaTheme=="minorEastAsia" or (ea and ea!="Times New Roman")` 应全真。
4. **🔴 用户原有修订逐 id 比对一字未动**——把源文件与产物里 `id<=maxid` 的所有 ins/del 提成 `(id, tag, author, 文本)` 排序比对,必须**完全相等**。这是本配方的核心安全验证:证明我只追加、没碰用户的任何一处。
## 收口(与库路径相同)
`scripts/accept-revisions-preview.py` 生成干净版 → OnlyOffice 渲染 → vision 视觉验收。
- **vision 报「页底某句截断」先分清 PDF 分页 vs 真丢数据**:金信大厦案 vision 报抵押救济句在 P7 底部截断,实为该句跨页接到 P8 开头——①数据层读该 INS 完整内容在;②P7+P8 拍平后 grep 完整句存在 → 确认是 PDF 分页跨页,OnlyOffice 滚动查看正常,**不返工**。判据同 contract-portfolio-analysis Pitfall:数据层完整+跨页搜得到=分页现象。
- vision 对字体/‰%/小符号的误判同样适用——回数据层核,别据像素返工(见 SKILL.md「交付前视觉验收的两个已知误判」)。
@@ -0,0 +1,62 @@
# 法律文书:脚注、同源模板成稿、Doro 编辑后字体修复
contract-editor 库(zipfile+lxml)在**新成稿文书**(非修订态)上的复用。2026-06-23-24 邹家《情况反映》制作中验证。配套 `litigation-doc-tracked-changes.md`(那篇讲修订态;本篇讲脚注+新建文书+字体规范化,都不是 tracked-changes)。
## 1. 法条原文脚注——从同案"姊妹文书"克隆脚注样式(铁律:脚注样式不要凭空造)
需求场景:Doro 把正文里的法条引用("《民事诉讼法》第七十一条之规定")要求改成**脚注呈现法条全文**,且"脚注格式和申请书一样"。
正确做法是从**同案已有带脚注的文书**(如同目录的《民事诉讼监督申请书》v9)克隆脚注体例,而不是自己拼 footnotes.xml:
**脚注的两个组成**(先从姊妹文书读出样式模板):
- 正文里的**引用标**:一个 `<w:r>`,rPr 带 `<w:rStyle w:val="affb"/>` + Times New Roman + 与正文同字号(sz24),内含 `<w:footnoteReference w:id="N"/>``affb` 是 Word 默认的 FootnoteReference 字符样式 id(不同文档可能不同,**从姊妹文书正文的 footnoteReference 承载 run 实测,别硬编码**)。
- footnotes.xml 里的**脚注正文**:separator/continuationSeparator 两个特殊脚注(id=-1/0,原样复制)+ 内容脚注(id≥1)。内容脚注段落 spacing line=240,run 字号是**脚注体例 sz18(9pt,小于正文)**,法名加粗(`<w:b/>`)、条号与原文不加粗。
```python
# 从姊妹文书 sqs_v9.docx 取模板
fn_sqs = etree.fromstring(z_sqs.read('word/footnotes.xml'))
special = {ft: deepcopy(f) for f in fn_sqs.iter(qn('footnote'))
for ft in [f.get(qn('type'))] if ft in ('separator','continuationSeparator')}
content_tmpl_p = deepcopy([f.find(qn('p')) for f in fn_sqs.iter(qn('footnote'))
if f.get(qn('id'))=='1'][0])
# 从模板段落抽三种 rPr:mark(带rStyle affb)、bold(法名)、plain(原文)
# 正文引用标 rPr 则从姊妹文书 document.xml 里 footnoteReference 承载 run 抓
```
**目标 docx 必须已支持脚注**`word/_rels/document.xml.rels` 有 footnotes 关系、`[Content_Types].xml``footnotes+xml`、styles.xml 有 `affb` 样式。若目标是从同源文书演化来的(本例情况反映以申请书为母版),这三样天然齐全;若从零新建则要补。
## 2. 脚注标定位的坑:锚点跨 run 时 footnoteReference 会插错位置(本会话实犯)
把脚注标插在"第五十一条第二款"之后时,第一版用"找锚点子串→定位锚点所在 run→run 后插标",结果 ³ 插到了下游的"承办部门"后面——因为锚点文字**跨多个 run**,按 run 粒度定位会落到错误的 run。
**正解:字符流定位 + 必要时拆 run**。把全段所有 `w:t` 拼成字符流,建立 `每个字符→(t元素, 字符在t内的索引)` 映射,找到锚点**结束字符**的精确位置;若结束字符在某 run 中间,**split 该 run**(head 留原 run,tail 进新 run),脚注引用 run 插在 head 和 tail 之间。这样标精确落在"…第二款【标】所定…"。
验证只能靠 OnlyOffice x2t 渲染后看页脚——vision 一眼就抓出"³ 标在承办部门后",肉眼读 XML 容易漏。体例统一:四个脚注一律"法条号正后方"挂注(不要有的挂句末有的挂条号后)。
## 3. 用同案文书做"母版"新建文书——保证两份同源同体例
新建《情况反映》时,以同案《民事诉讼监督申请书》v9 为母版克隆,确保字体/字号/页边距/样式完全一致(Doro/Maggie 两份并排看不会有体例差):
- 从母版抽各类段落模板:title(居中bold sz30)、body(首行缩进480 sz24)、recip(顶格bold 机关名)、sign(右对齐)、date、attachment-title、attachment-item。`mk(tmpl_key, text, bold=, no_indent=)` 克隆模板段→清空 run/numPr/ins/del→重设仿宋+Times→填文字。
- 用母版的整个 docx 做容器(保留 sectPr 页面设置、styles、numbering),只重写 body 的段落序列 + 清掉 `<w:trackRevisions/>`
- **清掉母版页眉**:母版页眉可能是另一种文书的抬头(本例申请书页眉"申请监督案号/受理法院"套在情况反映上不对)。清页眉要两步:①删页眉段所有 run 文字;②**删页眉段 pPr 的 `<w:pBdr>`**(页眉那条横线来自段落下边框,只删文字会留一条孤线)。OnlyOffice 渲染确认顶部纯白到标题。
## 4. ⚠️Doro 用编辑器改过的 docx 会丢显式 eastAsia 字体属性——每轮都要补(本会话两轮各犯一次)
**现象**:Doro 在他本机编辑器改过 docx 后回传,正文中文 run 的 `rFonts` **没有显式 eastAsia 字体名**(本会话两轮分别 1283、1243 个中文字符 `eastAsia=None`,docDefaults 也空)。OnlyOffice 靠底层回退仍渲染成仿宋、肉眼看正常,但**显式字体属性缺失不符合交付标准**(我们要求中文显式仿宋)。
**判别**:交付前扫一遍——
```python
for r in root.iter(qn('r')):
rf = r.find(qn('rPr/rFonts'))
ea = rf.get(qn('eastAsia')) if rf is not None else None
# 统计含中文 run 里 ea is None 的数量;>0 就要补
```
**修复(格式规范化,不改字形/文字/Doro 内容)**:每个含文字的 run,rFonts 显式设 `eastAsia=仿宋`,缺 ascii/hAnsi 的补 `Times New Roman`;再给 `docDefaults/rPrDefault/rPr/rFonts``eastAsia=仿宋` 兜底。改完目标:CJK 全仿宋、英数全 Times。**这是和合同字体规范化同类的操作,但要点在"每次 Doro 回传都要重做一遍"**——他的编辑器每改一次就再剥一次,不是一次性问题。改完必须 OnlyOffice 重渲染确认无字形回退(字体属性动过就要重验)。
## 5. 附件/正文 Doro 自己加的内容:修错别字但不擅改实质
Doro 自己在附件加了"检查监督申请书"——①错别字"检**查**"→"检**察**"院的监督,规范名应是与正式文件名一致的《民事诉讼监督申请书》(改);②但若附件项之间有实质区分缺失(如两份《质证通知书》一份标了"3日期限版"另一份没标"15日期限版"),那是 Doro 定的内容,**只提示不擅改**。附件清单常是自动编号(numId),Doro 删手敲序号是对的,自动会续 1-6。
## 一句话
脚注从同案姊妹文书克隆样式(rStyle affb + sz18 脚注体、法名加粗)、标位置用字符流+拆run精确落在条号后;新建文书拿同案文书做母版保同源(清页眉含删 pBdr);**Doro 编辑器回传的 docx 每轮都丢显式 eastAsia,每次交付前都要全局补仿宋再渲染**。
@@ -0,0 +1,73 @@
# 诉讼文书:法条原文脚注 + 母版克隆建新文书 + 字体规范化
contract-editor 库在诉讼文书上的三组技法,2026-06-23/24 邹家「情况反映」(给法院监督部门的程序违法反映材料)制作中验证,全部 OnlyOffice x2t 渲染逐页核对过。与 `litigation-doc-tracked-changes.md`(修订态技法)互补——本文是**脚注 + 新建文书 + 字体**层面。
---
## 一、法条原文脚注(Doro 偏好:引用法律规定一律用脚注呈现原文,不删改不概括)
Doro 对引用法条的文书要求:**法条原文(一字不改、不归纳)用脚注方式写进去**。监督申请书 v9 已是这个体例,情况反映照搬。这是可复用的整套做法。
### 1. 脚注格式从同族已有文书克隆,不自造
申请书 v9 的 `word/footnotes.xml` 是现成模板。提取三样:
- **两个特殊脚注** `type=separator` / `type=continuationSeparator`(id=-1/0)——分隔线,照搬。
- **一个内容脚注的段落骨架**(id=1 的 `<w:p>`)——拿它的 `pPr`(脚注段 `spacing line=240`)和三种 run 的 `rPr` 模板:
- **mark rPr**:带 `<w:rStyle w:val="affb"/>` + Times New Roman + **sz18**(9pt 脚注体,不是正文 sz24)——脚注区那个序号。
- **bold rPr**:`<w:b/>` + sz18——法名加粗用。
- **plain rPr**:sz18 无 rStyle 无 b——条号+原文用。
- 正文里的脚注引用标(`footnoteReference` 承载 run)的 rPr 另取:从 v9 **正文** 里找 `r/footnoteReference` 那个 run 的 rPr(`rStyle=affb` + Times + **sz24**,跟正文同号,上标由 affb 样式控制)。
每条脚注 `<w:footnote id=N>` 段落结构:`[footnoteRef(mark rPr)][空格(plain)][法名(bold rPr)][条号+原文(plain rPr)]`。条号与原文之间用**全角空格**(如「第七十一条 证据应当…」)。
### 2. 目标 docx 已支持脚注则零配置
情况反映是从 v9 编辑来的,本就带 `word/footnotes.xml`、rels 里有 footnotes 关系、`[Content_Types].xml``footnotes+xml`、styles.xml 有 `affb` 样式——直接覆盖 footnotes.xml + 在 document.xml 插引用标即可,**不用补 rels/CT/style**。动手前先 grep 确认这四样齐全;若是从无脚注的 docx 起步,才需要补全四处。
### 3. 插入引用标的位置铁律 + 多 run 锚点陷阱(本次踩坑)
脚注上标要紧贴**法条号正后方**(如「第七十一条¹之规定」「第五十一条第二款³所定」),不要落在句末或下游词上。统一体例:四个脚注全部「条号后挂注」最整齐。
**陷阱**`第五十一条第二款` 这种锚点在 docx XML 里常**跨多个 w:r**(编号、款号被拆在不同 run)。若按"找到 anchor 所在 run、在该 run 后插引用"的粗定位,会把上标插到 anchor **下游某个 run 后**——本次 ³ 错插到了「承办部门」后(隔了好几个词)。OnlyOffice 渲染出来才发现,vision 核对抓到的。
**正解:字符流 + split run 精确定位**
```python
# 1) 拼接段落所有 w:t 成 full,建 map: full每个字符 -> (t_element, idx_in_t)
# 2) end = full.find(anchor) + len(anchor) - 1 # 锚点最后一个字符
# 3) t_end, k_end = map[end];把 t_end 文本 split:head=s[:k_end+1], tail=s[k_end+1:]
# 4) t_end.text=head;在 t_end 所在 run 之后 addnext 一个新 run(脚注引用);
# 若 tail 非空,再 addnext 一个同 rPr 的 run 承载 tail
```
这样上标精确落在锚点最后一字之后,不受 run 边界影响。容错:`第五十一条第二款` 找不到时退化找 `第五十一条`
### 4. 款数存疑时,脚注放全条原文
Doro 引「第五十一条**第二款**」,但权威原文里"普通程序不少于十五日"实际在**第一款**。**不擅改他的款数**——脚注内容放该条**全文(含两款)**,无论款数对错,原文都完整覆盖、不断章;款数是否要改回原文里报给 Doro 定,不自己动。
---
## 二、母版克隆建新诉讼文书(保证与同案既有文书同源)
新建一份配套文书(情况反映 vs 已有的监督申请书),要让字体/字号/页边距/样式与同案既有文书**完全同源**——直接拿那份已交付的 docx 当母版。
- **段落模板克隆**:从母版 body 抓代表性段落各一份 deepcopy 当模板——title(居中 bold sz30)、body(首行缩进 fl480 sz24)、recip(机关名顶格 bold sz24)、sign(右对齐 sz24)、date(右对齐)、attt(附件标题顶格 bold)、att(附件项 fl480)。`mk(模板, 文本, bold, no_indent)`:克隆模板→清空其 run/ins/del→(按需删 numPr/ind)→`force_font`(eastAsia=仿宋, ascii/hAnsi=Times)→写新 run。
- **清空原 body 段落**,把新段落 insert 到 `sectPr` 之前(保页边距/分节设置不变)。
- **关 trackRevisions**:新建文书是全新成稿、非修订态,settings.xml 删 `<w:trackRevisions/>`
- **页眉错配必须清**(本次踩坑):母版(监督申请书)的 `header1.xml` 带"申请监督民事诉讼案号/受理法院"这种**本文书类型专属抬头**,套到情况反映上不对路。处理:清空 header 所有 run 的文字。
- **页眉横线 = pBdr,单清文字不够**:清了页眉文字后 OnlyOffice 仍渲出一条横线——来自页眉段落的 `<w:pBdr>`(段落下边框)。遍历 header 所有 `pPr``pBdr`(本例 2 段),并去掉可能带边框的 `pStyle` 引用。styles.xml 里的 Header 样式若也挂 pBdr 一并清。重渲确认顶部纯白到标题。
---
## 三、字体规范化:源文档丢了显式 eastAsia 字体
**症状**:用户编辑过的 docx,正文中文 run 的 `rFonts` **没有 eastAsia 属性**(eastAsia=None),docDefaults 也没设。OnlyOffice 靠底层回退仍渲成仿宋,但**显式字体属性缺失**不符合"中文必须显式仿宋"的交付标准。本次 Doro 改的情况反映 1283 个中文字符全是 eastAsia=None。
**判断边界(重要)**:先比对**用户原版**——若原版本就是 eastAsia=None(不是你的编辑引入的),补齐属于**格式规范化(不改字形、不改一个文字、不动他的内容编辑)**,与历史上的字体规范化同类,可做。若是你的操作把字体搞丢的,那是 bug 要修源头。
**修法**:遍历所有含文字的 run,`rFonts``eastAsia=仿宋`,缺 ascii/hAnsi 则补 Times New Roman;再给 `docDefaults/rPrDefault/rPr/rFonts` 补 eastAsia=仿宋 兜底。改完 OnlyOffice **重渲**确认无字形回退(字体改动必重渲,vision 核"全文仿宋、无方框、无回退乱码")。核验:zipfile 统计 CJK→仿宋、LATIN→Times New Roman 计数全覆盖。
---
## 验收三件套(脚注版)
1. **正文脚注引用数** == 预期(`sum(r.find(footnoteReference) for r in runs)`)。
2. **footnotes.xml 内容数** == 引用数,逐条 print 前 50 字核法名+条号+原文。
3. **OnlyOffice x2t 渲染逐页 vision 核**:每个上标在**正确法条号正后方**(重点查多 run 锚点那条没错位)、页脚脚注区原文完整无截断、脚注字号 < 正文、法名加粗、无乱码。脚注主要落在前两页,逐页都要看。
## 一句话
法条脚注:格式克隆同族文书的 footnotes.xml(separator+内容模板,mark/bold/plain 三 rPr),引用标用 split-run 精确插在条号后(多 run 锚点必踩坑),款数存疑放全条原文不擅改。建新文书:克隆母版段落模板保同源,清错配页眉+pBdr 横线。字体:源档丢 eastAsia 时补齐属于规范化(先确认是原档状态不是自己搞丢的),改完必重渲。
@@ -0,0 +1,74 @@
# 诉讼文书的修订态技法(contract-editor 库在合同以外文书上的复用)
ContractEditor 库不止用于合同——审/改诉讼文书(监督申请书、起诉状、答辩状等)同样适用。本文记录 2026-06-23 邹家民事诉讼监督申请书审改中验证过的几招,都用 OnlyOffice x2t(Doro/Maggie 实际引擎)渲染核对过。
## 1. 大段改写用「整块 del+ins」,不用字符级 diff(markup 可读性铁律)
`tracked_replace` 是字符级 diff(CJK 每字一 token + difflib)——**补字/小改**(错别字、补一个"在"字、称谓换词)用它,markup 干净。
但**大段改写**(整句重写、换论证)若用字符级 diff,新旧文本大量字符重合,markup 会交错成一团("未经~~及~~法庭审理""一百二十八条~~切~~国家机关"),Doro 在 OnlyOffice 看修订态根本读不下去。**接受修订后的最终文本虽正确,但修订态不可读 = 不合格交付**(Doro 有格式洁癖,看的就是 markup)。
- **正解**:对整句/整段改写,做「整块删 + 整块插」——`[<w:del>旧整句</w:del>][<w:ins>新整句</w:ins>]`,markup 显示为一条删除线旧句紧跟一条下划线新句,清清楚楚。
- 实现:复制 `tracked_replace` 的定位逻辑,但不跑 difflib,直接 `_mk_del(old_text)` + `_mk_ins(new_text)` 整块插。判据:**新旧文本相似度高、改动跨度大 → 整块;纯增删几个字 → 字符级**。
## 2. 称谓/词替换也要整词块替换,别让共享字符碎裂
把"法**庭**"改"莲都法**院**"时,"法"字共享,字符级 diff 会渲染成"莲都法~~庭~~院"(接受后对,markup 脏)。
- **正解**:整词 `tracked_block_replace("本案法庭向申请人送达", "莲都法院向申请人送达")` → markup 是干净的[删旧短语][插新短语]。
- 同理坑:替换前先分类全文每处目标词——actor 指代(要改)vs 法条/术语原文(如"法庭审理""在法庭上出示"=不能动)。grep 出所有命中,逐个判,别一刀切 replace_all。
## 3. 整段删除(让自动编号重排)——库没有,需自加 `tracked_delete_paragraph`
合并两个自动编号请求项(删一项、后项自动续号)时,要的是**段落级修订删除**:段内每个 run 包进 `<w:del>`,**且段落标记也要标删**——在 `pPr/rPr` 里插一个 `<w:del>`。这样接受修订后整段连段落标记一起消失,自动编号从 一二三四 重排成 一二三。
```python
def tracked_delete_paragraph(self, search_text):
p = self.find_para(search_text)
for r in list(p.findall(qn('r'))):
# 每个run的w:t搬进新建<w:del><w:r><w:delText>
...
ppr = p.find(qn('pPr')) or 新建
rpr = ppr.find(qn('rPr')) or 新建
rpr.insert(0, <w:del author=... date=...>) # 段落标记删除标记
```
缺了"段落标记删除"那一步,接受后会残留一个空的编号项。
## 4. 半角括号→全角:改 numbering.xml 的 lvlText,一次性根治
子标题 `(一)(二)…` 是自动编号时,半角括号来自 `word/numbering.xml``<w:lvlText w:val="(%1)">`。逐段改文档没用(那是渲染出来的)。
- **正解**:遍历 numbering.xml 所有 `<w:lvlText>``val` 里的 `(``(``)``)`,一次改全文所有同源编号。本例 37 处 lvlText 一次改完。
- 注意只动含 `()` 的 lvlText,`、`分隔的(如请求"一、二、三"用 `%1、`)不受影响。
## 5. 引号"统一为仿宋全角"——根因是引号 run 的字体不是中文字体
现象:正文中文是仿宋(继承样式),但弯引号 `“”`(U+201C/U+201D) 的 run `ascii=Times New Roman, eastAsia=None`。因为弯引号是**中西文模糊字符**,OnlyOffice 对没有 eastAsia 设定的字符按 ascii 字体渲染 → 引号显示成西文 Times 的粗重样式,和仿宋正文不协调。
- **正解(彻底版)**:对**纯引号/中文 run**,把 rFonts 的 `ascii/eastAsia/hAnsi/cs` 全设为「仿宋」+ `hint="eastAsia"`,消除歧义。对**引号+数字混排 run**(如 `“2026…`),按字符**拆 run**:引号段走仿宋、数字段保留 Times New Roman。
- 只设 eastAsia 不够稳——某些渲染下仍可能按 ascii 走 Times。纯引号 run 连 ascii 一起设仿宋最保险(数字 run 才需要保留 Times)。
- 核对:OnlyOffice x2t 渲染后裁剪含引号区域,确认引号纤细、与仿宋协调(不是又粗又重的衬线引号)。
## 6. 验证三件套(同合同终审,文书一样适用)
- `ed.validate()` 返回空。**注意**:诉讼文书的「请求项」原文常是加粗的(与合同正文不加粗体例不同),validate 的"不应加粗"规则会**误报**——先读原文该段普通 run 的 `<w:b>` 状态,若原文请求项本就加粗、INS 继承同样加粗=格式一致=误报,可放行。
- 逐个 `w:ins` 核 author 正确(诉讼文书署当前文书归属人,如本例 Doro 文书上署"小Maggie"修订;合同历史署"WB"——按文书归属定)、eastAsia 字体不缺。
- OnlyOffice x2t 渲两版:**修订态**(看 markup 干净)+ **接受态**(删 w:del、解包 w:ins、删段落标记被删的空段后重渲,看编号连续、全角括号生效、无乱码)。接受态自己生成:解包所有 ins、删所有 del、删 numPr 空段。
## 一句话
合同库的修订能力对所有 docx 文书通用;诉讼文书审改的差异点是:①大改写要整块 del+ins 保 markup 可读 ②引号/括号这类「字体/编号源」问题改 styles/numbering 层不改文档层 ③validate 加粗规则对加粗请求项会误报。
## 7. 接受所有修订 → 干净版 docx(反向操作,2026-06-24 徐函任务验证)
用户给一份**带修订痕迹+批注**的 docx,要「先接受现有修订、让我看干净版本」时——不是用 Word 手点"接受全部",用 zipfile+lxml 一次处理:
```python
W='http://schemas.openxmlformats.org/wordprocessingml/2006/main'
def w(t): return f'{{{W}}}'+t
# 1) w:del → 整个元素删掉(连 delText 一起没)
for d in root.findall('.//'+w('del')): d.getparent().remove(d)
# 2) w:ins → 解包:用其子元素替换它本身(保留插入内容,去掉ins包裹)
for ins in root.findall('.//'+w('ins')):
parent=ins.getparent(); idx=list(parent).index(ins)
for child in reversed(list(ins)): parent.insert(idx, child)
parent.remove(ins)
# 3) 属性变更追踪一并清:pPrChange/rPrChange/sectPrChange/tblPrChange/tcPrChange/trPrChange
for tag in ('pPrChange','rPrChange','sectPrChange','tblPrChange','tcPrChange','trPrChange'):
for el in root.findall('.//'+w(tag)): el.getparent().remove(el)
# 4) 批注三处一起拆(用户要"干净版"= 连批注也清):
# document.xml: 删 commentRangeStart/End,删含 commentReference 的整个 run
# settings.xml: 删 <w:trackRevisions/>(让文件退出跟踪模式)
# 打包时跳过 word/comments*.xml,并从 [Content_Types].xml 和 document.xml.rels 删 comments 的 Override/Relationship
```
要点:
- **w:del 删整块、w:ins 解包**——方向别搞反(del 是要丢弃的,ins 是要保留的)。
- **务必清 settings.xml 的 trackRevisions**,否则文件仍处于"跟踪修订"模式,用户继续编辑会又开始记修订。
- **批注要三处协同删**(comments.xml 本体 + document.xml 的 range/reference 锚点 + Content_Types/rels 注册),漏一处 OnlyOffice/Word 打开可能报损坏。
- 验证:解包后 `root.findall('.//w:ins')`/`w:del`/`w:commentReference` 全为 0;`'word/comments.xml' in zip.namelist()` 为 False;python-docx 能打开;OnlyOffice x2t 渲染核对无修订痕迹无批注无错位。
- vision 核干净版时顺带抓**残留内部标记**:黄色高亮(内部校对标记)、留白占位(编号"第 号"、日期" 日")、标题英文双连字符`--`应为中文破折号`——`——这些不是修订痕迹但属"未清的内部审核稿"特征,正式交付前要清。
@@ -0,0 +1,126 @@
# lxml XML Declaration Fix for docx Files
## Problem (2026-07-01, 劳务派遣协议案)
When lxml serializes XML (via `etree.tostring()` or python-docx's `Document.save()`), it outputs:
- **Single-quote** XML declaration: `<?xml version='1.0' encoding='UTF-8' standalone='yes'?>`
- **LF** line endings (`\n`)
Original docx files (created by Word/WPS/OnlyOffice) use:
- **Double-quote** XML declaration: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`
- **CRLF** line endings (`\r\n`)
**OnlyOffice cannot open docx files with single-quote XML declarations.** The file appears structurally valid (ZIP ok, XML parses fine, python-docx loads it, even x2t can convert it to PDF), but the OnlyOffice web editor refuses to open it.
## Affected Files
Only XML files that were **re-serialized by lxml** are affected. In a typical ContractEditor workflow:
- `word/document.xml` — always re-serialized (main editing target)
- `word/settings.xml` — re-serialized if trackRevisions was added/modified
Other XML files (styles.xml, fontTable.xml, theme1.xml, etc.) that were read and written back unchanged via `zipfile` retain their original format.
## Diagnosis
```python
import zipfile
def check_docx_xml_format(docx_path):
"""Check if any XML files have problematic single-quote declarations."""
issues = []
with zipfile.ZipFile(docx_path) as z:
for name in z.namelist():
if name.endswith('.xml') or name.endswith('.rels'):
data = z.read(name).decode('utf-8')
first_line = data.split('\n')[0]
has_single_quotes = "version='1.0'" in first_line
has_lf_only = '\r\n' not in data[:200]
if has_single_quotes or has_lf_only:
issues.append((name, has_single_quotes, has_lf_only))
return issues
```
## Fix Script
```python
import zipfile
import re
import os
import tempfile
def fix_xml_declarations(docx_path, output_path=None):
"""
Fix lxml-serialized XML files inside a docx:
1. Single quotes -> double quotes in XML declaration
2. LF -> CRLF line endings (only if file has no CRLF)
If output_path is None, fixes in-place (via temp file + rename).
"""
if output_path is None:
output_path = docx_path
tmp_fd, tmp_path = tempfile.mkstemp(suffix='.docx')
os.close(tmp_fd)
try:
with zipfile.ZipFile(docx_path, 'r') as zin:
with zipfile.ZipFile(tmp_path, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
data = zin.read(item.filename)
if item.filename.endswith('.xml') or item.filename.endswith('.rels'):
text = data.decode('utf-8')
# Fix 1: Single quotes -> double quotes in XML declaration
text = re.sub(
r"<\?xml version='1\.0' encoding='UTF-8' standalone='yes'\?>",
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
text
)
# Fix 2: LF -> CRLF (only if no CRLF present)
if '\r\n' not in text and '\n' in text:
text = text.replace('\n', '\r\n')
data = text.encode('utf-8')
zout.writestr(item, data)
os.replace(tmp_path, output_path)
except:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
raise
# Usage after ContractEditor.save() or manual zipfile write:
# fix_xml_declarations('/tmp/【修】contract.docx')
```
## Integration Points
### After ContractEditor.save()
```python
ed = ContractEditor(src)
# ... edits ...
ed.save(output_path)
fix_xml_declarations(output_path) # Must run after every save
```
### After manual zipfile+lxml write
```python
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
# ... write files ...
pass
fix_xml_declarations(output_path) # Must run after ZIP is closed
```
## Key Insight
- `x2t` (OnlyOffice converter CLI) tolerates single-quote declarations — it can convert the "broken" file to PDF successfully
- The **OnlyOffice web editor** (WOPI-based document editing) does NOT tolerate single-quote declarations
- `python-docx Document()` opens the file fine (lxml parses both formats)
- Standard validation tools (zipfile.testzip(), etree.fromstring()) all pass
This makes the issue hard to diagnose — everything looks valid except OnlyOffice refuses to open it. The **only reliable test** is checking the raw bytes of the XML declaration in the ZIP.
@@ -0,0 +1,66 @@
# A类手动编号顺延 — 段落级 DEL/INS 模式
实战来源:CT维保合同-香花桥(2026-06-26)。新增"9. 第三方侵权"条款后,需将原9→10、10→11、11→12、12→13、13→14 顺延。所有条款编号均为手动文本(A类,run内w:t文字,无numPr)。
## 核心模式
对每个需顺延的段落,找到包含旧编号的 run,用**段落级** DEL/INS 替换:
```python
for old_num, new_num in renumber_map.items():
for r in p.findall(f'{{{W}}}r'):
t = r.find(f'{{{W}}}t')
if t is None or t.text is None: continue
if t.text.strip().startswith(str(old_num)):
# 1. DEL run: 旧编号
del_run = deepcopy(r)
del_run.set(f'{{{W}}}rsidDel', rsid)
del_t = del_run.find(f'{{{W}}}t')
del_t.tag = f'{{{W}}}delText'
del_t.text = str(old_num)
del_w = etree.Element(f'{{{W}}}del')
del_w.set(f'{{{W}}}id', str(nid)); nid += 1
del_w.set(f'{{{W}}}author', 'WB')
del_w.set(f'{{{W}}}date', rev_date)
del_w.append(del_run)
# 2. INS run: 新编号
ins_run = deepcopy(r)
ins_run.set(f'{{{W}}}rsidR', rsid)
ins_t = ins_run.find(f'{{{W}}}t')
ins_t.text = str(new_num)
ins_w = etree.Element(f'{{{W}}}ins')
ins_w.set(f'{{{W}}}id', str(nid)); nid += 1
ins_w.set(f'{{{W}}}author', 'WB')
ins_w.set(f'{{{W}}}date', rev_date)
ins_w.append(ins_run)
# 3. 原 run 去掉编号前缀
t.text = t.text[len(str(old_num)):]
# 4. DEL + INS 插入在原 run 之前
r.addprevious(ins_w)
r.addprevious(del_w)
break
break # 每个段落只改一个编号
```
## 关键点
1. **DEL/INS 在段落级**`w:p` 的直接子元素),不是 run 内
2. **从后往前处理**:如果用索引遍历,从后往前避免 offset 漂移
3. **只匹配run开头**`t.text.strip().startswith(str(old_num))` 确保只匹配编号前缀
4. **原 run 保留剩余文本**`t.text = t.text[len(str(old_num)):]` 去掉编号后保留标题文字
5. **ID 递增**:每个 DEL/INS 用独立 id,从 `max_id + 1`
## 与 add_clause 的区别
- `add_clause` / `add_clause_before`:创建**全新段落**(整段 w:ins)
- 本模式:修改**已有段落**的第一个 run 的编号,其余内容不动
## 适用场景
- 新增条款后,后续**手动编号**(A类)的条款需要顺延
- 不适用于自动编号(B类)——自动编号由 number.xml 引擎处理,修改 run 内文字无效
@@ -0,0 +1,60 @@
# 手动合同审查:具体修改规则(非角色约束)
> Doro 2026-07-02 明确:"我需要你遵守的是具体修改规则,不是角色。"
> 这些规则不因"手动操作"还是"workflow执行"而有任何区别。
## 十条硬规则
1. **修订精准到字,不整段 del+ins**
- 改一个字只标记一个字的 del+ins
- 不允许为了方便把整句/整段删掉重写
2. **INS run 字体/字号与原文同段落一致**
- 每个 INS run 的 rPr(sz/bold/rFonts)必须与同段落其他非INS run一致
- 签署页特别注意:"甲方:""乙方:"标签和名称可能原文字号不同,INS必须匹配标签字号
3. **格式、大小与原文保持一致**
- 段落缩进(firstLine)、行距(spacing)、段落样式(pStyle)全部与原文同级段落一致
- 新增条款标题必须继承原文条款标题的样式
4. **编号顺延要通读全文确认**
- 插入新条款后,后续条款编号必须顺延
- 必须通读全文确认编号链连续无跳号
5. **不擅自填写合同空白内容**
- 空白的商业条款(金额、期限、数量、质量标准等)不动
- 空白 = 留给签约双方自行填写,不是让审查人补充
6. **不做独立法律判断**
- 不在审查中做"这个条款合不合法"的独立判断
- 只按reviewer的issue清单执行修改
7. **不站自己的立场改客户的商业安排**
- 客户已经做出的商业决策不否定
- 例:客户选择"反委托代发工资",不能改成"乙方直接发"
- 只能在客户选择的框架内加保护条款
8. **批注只写修改方案,不写理由**
- ❌ "建议修改为……,因为……"
- ✅ "建议修改为……"
- 不加【新增】【修改】等标签前缀
9. **金额是商业条款不动**
- 无论金额看起来是否"合理",绝对不改
- 金额矛盾也只批注提示,不做修改
10. **原文批注/修订不动**
- 其他人(华诚-Z、法务、屠佳青等)的修订和批注保留原样
- 不删除、不修改、不合并他人的批注
- 除非Doro明确指示合并(如"华诚-Z的修订人改为WB")
## 核心原则
**遵守的是规则本身,不是"我现在扮演什么角色"。** 不管是workflow的editor角色执行、还是Doro直接让我手动改合同,这十条规则完全一样,不打折扣。
## 反面教材(2026-07-01)
- 反委托代发工资协议:站自己立场否定客户的反委托安排(版本1直接取消反委托)→ 违反第7条
- 填写空白的"质量保证期___个月" → 违反第5条
- 批注写理由 → 违反第8条
- 劳务派遣协议整段del+ins → 违反第1条
@@ -0,0 +1,125 @@
# Merge Layered Revisions with Priority (Accept Inner Author's Edits)
## Scenario (2026-07-03 模特合作协议案)
File has two layers of tracked changes:
- **Layer 1 (WB)**: Original review modifications
- **Layer 2 (华诚-Z)**: User edited on top of WB's tracked changes
Result: 华诚-Z's `w:del` elements are **nested inside** WB's `w:ins` elements — meaning 华诚-Z deleted portions of what WB had inserted.
User instruction: "以华诚-Z为准" (prioritize 华诚-Z), then unify all author names to WB.
## Three-Step Algorithm
### Step 1: Accept nested deletions (inner author wins)
Find all `w:del[author=华诚-Z]` nested inside `w:ins[author=WB]` and remove them (= accept the deletion):
```python
def accept_nested_deletions(body, inner_author='华诚-Z', outer_author='WB'):
for ins_elem in body.findall(f'.//{W}ins'):
if ins_elem.get(f'{W}author') != outer_author:
continue
for del_elem in ins_elem.findall(f'.//{W}del'):
if del_elem.get(f'{W}author') == inner_author:
parent = del_elem.getparent()
parent.remove(del_elem)
```
### Step 2: Remove empty outer elements
After accepting nested deletions, some WB ins elements may be empty (all their content was deleted by 华诚-Z):
```python
def remove_empty_ins(body):
for ins_elem in body.findall(f'.//{W}ins'):
has_text = False
for t in ins_elem.findall(f'.//{W}t'):
if t.text and t.text.strip():
has_text = True
break
if not has_text:
parent = ins_elem.getparent()
if parent is not None:
parent.remove(ins_elem)
```
### Step 3: Unify author names
```python
def rename_author(body, old_author, new_author):
count = 0
for elem in body.iter():
author = elem.get(f'{W}author')
if author == old_author:
elem.set(f'{W}author', new_author)
count += 1
return count
```
## Complete Flow
```python
from docx import Document
from lxml import etree
doc = Document('input.docx')
body = doc.element.body
# Step 1: Accept 华诚-Z deletions of WB content
accept_nested_deletions(body, inner_author='华诚-Z', outer_author='WB')
# Step 2: Clean up empty WB ins elements
remove_empty_ins(body)
# Step 3: Rename 华诚-Z → WB
rename_author(body, '华诚-Z', 'WB')
doc.save('output.docx')
```
## After Merge: Additional Modifications
After merging, you can continue adding new WB tracked changes on the unified file (e.g., reverting specific clauses to template wording). Use standard tracked change creation:
```python
def make_del(text, rPr=None, author='WB', date='2026-07-03T06:00:00Z'):
d = etree.Element(f'{W}del')
d.set(f'{W}id', str(abs(hash(text)) % 100000))
d.set(f'{W}author', author)
d.set(f'{W}date', date)
r = etree.SubElement(d, f'{W}r')
if rPr is not None:
r.append(deepcopy(rPr))
dt = etree.SubElement(r, f'{W}delText')
dt.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
dt.text = text
return d
def make_ins(text, rPr=None, author='WB', date='2026-07-03T06:00:00Z'):
ins = etree.Element(f'{W}ins')
ins.set(f'{W}id', str(abs(hash(text + 'ins')) % 100000))
ins.set(f'{W}author', author)
ins.set(f'{W}date', date)
r = etree.SubElement(ins, f'{W}r')
if rPr is not None:
r.append(deepcopy(rPr))
t = etree.SubElement(r, f'{W}t')
t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
t.text = text
return ins
```
## Verification
After merge:
- `set(elem.get(W+'author') for elem in body.iter() if elem.get(W+'author'))` should return `{'WB'}` only
- Count ins/del elements to confirm reasonable numbers
- Verify key clauses read correctly in "accepted" view
## Key Distinction from `unify-author-wb.py`
The `scripts/unify-author-wb.py` script **only renames authors** — it does NOT handle nested deletions. If 华诚-Z has `w:del` inside WB's `w:ins`, just running unify will rename the del to WB but **leave the deleted content still marked as deleted inside the insertion** — creating a confusing state where WB appears to both insert and delete the same text.
**Always run the three-step algorithm** when inner author has modified outer author's tracked changes.
@@ -0,0 +1,118 @@
# Mixed Inherited/Explicit Font Size Fix (Document-Wide)
## Problem (2026-07-01 生育友好宣传阵地建设协议)
Source document has **mixed font sizing** in body text:
- Some runs have explicit `sz=24` (12pt) — e.g., section headings, specific clauses
- Other runs have **no explicit sz** — inherit from Normal style (`sz=21` / 10.5pt)
- WB INS runs mostly got `sz=24` correctly, but the mix of explicit + inherited in **original** runs creates visual inconsistency
Doro complaint: "文字大小不一致,修改" — the rendered result shows mixed sizes.
## Root Cause
- `docDefaults` / Normal style = 10.5pt (sz=21)
- Many body runs (P12+) have explicit sz=24 (from original author or conversion)
- ~72 original runs have NO explicit sz → inherit 10.5pt → render smaller
- OnlyOffice renders the mix faithfully → visible inconsistency
## Diagnosis
```python
from docx import Document
from collections import Counter
doc = Document('file.docx')
print(f'Normal style sz: {doc.styles["Normal"].font.size}') # If 133350 EMU = 10.5pt
sizes = Counter()
for p in doc.paragraphs[BODY_START:BODY_END]:
for run in p.runs:
if run.text.strip():
sizes[run.font.size.pt if run.font.size else 'inherited'] += 1
# If both 'inherited' and explicit size (e.g. 12.0) appear → mixed problem
print(sizes.most_common())
```
## Fix Pattern (Full Body Range)
Unlike the INS-only sweep, this fix targets ALL runs in the body text range:
```python
import zipfile, re
from lxml import etree
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
# 1. Identify body range (skip title/preamble and signature)
BODY_START = 12 # First body content paragraph index
BODY_END = 56 # Last body paragraph (exclusive)
TARGET_SZ = '24' # From explicit runs in body (majority value)
# 2. Fix ALL runs in body range
for pidx in range(BODY_START, min(BODY_END, len(paras))):
p = paras[pidx]
# Plain runs
for r in p.findall(f'{WNS}r'):
t_elem = r.find(f'{WNS}t')
if t_elem is None or not (t_elem.text or '').strip():
continue
rpr = r.find(f'{WNS}rPr')
if rpr is None:
rpr = etree.SubElement(r, f'{WNS}rPr')
r.insert(0, rpr)
sz = rpr.find(f'{WNS}sz')
if sz is None:
sz = etree.SubElement(rpr, f'{WNS}sz')
sz.set(f'{WNS}val', TARGET_SZ)
szCs = rpr.find(f'{WNS}szCs')
if szCs is None:
szCs = etree.SubElement(rpr, f'{WNS}szCs')
szCs.set(f'{WNS}val', TARGET_SZ)
# INS runs
for ins in p.findall(f'{WNS}ins'):
for r in ins.findall(f'{WNS}r'):
# same logic as above
...
# DEL runs (for visual consistency in markup view)
for d in p.findall(f'{WNS}del'):
for r in d.findall(f'{WNS}r'):
# same logic
...
```
## Key Distinctions from INS-Only Fix
| Aspect | INS-only sweep | Full body range fix |
|--------|---------------|---------------------|
| Scope | Only WB INS runs | ALL runs (plain + INS + DEL) |
| Trigger | INS runs missing sz | Doro reports "文字大小不一致" |
| Root cause | add_clause/tracked_replace gaps | Source document mixed inheritance |
| Target sz | From neighboring runs | From majority explicit sz in body |
## When to Apply
- Doro says "文字大小不一致" on a delivered file
- `wb-ins-font-verify.py` passes (INS runs OK) but rendered output still shows mixed sizes
- Diagnostic shows body runs split between `inherited` and explicit sz
## Important: Don't Change Preamble/Signature
- Title/header (e.g., P0-P2): larger sz by design (22pt/sz=44) — don't touch
- Party info (P3-P10): may use different sz — don't touch unless in body range
- Signature area (P56+): often sz=21 (10.5pt) — don't touch
- Only fix the **body text range** where sz should be uniform
## Relationship to 格式保留铁律
This fix does NOT violate "格式保留铁律" (don't change original formatting) because:
- The original document's **intent** is uniform 12pt body text (evidenced by majority explicit sz=24)
- The missing sz is a **formatting omission** (author forgot to set explicit sz on some runs)
- The fix makes the document render as the original author intended
- This is different from "changing 仿宋_GB2312 to 仿宋" (that changes the actual format choice)
BUT: if the original document intentionally uses different sizes in body (e.g., smaller text for notes, larger for headings), don't blindly unify. Check the pattern first.
@@ -0,0 +1,204 @@
# 修改已有tracked changes的作者和文本内容
## 场景
- 合并用户在OnlyOffice中的修订(author如"华诚-Z"改为"WB")
- 修改INS元素中的文本内容(如更新法律措辞)
- 修改批注作者(comments.xml中的w:comment author属性)
## 技术实现
### 修改tracked change作者
```python
from lxml import etree
import zipfile
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
# 打开docx,修改document.xml
z_in = zipfile.ZipFile('input.docx', 'r')
z_out = zipfile.ZipFile('output.docx', 'w')
# 复制非document.xml的文件
for item in z_in.namelist():
if item != 'word/document.xml':
z_out.writestr(item, z_in.read(item))
# 修改tracked change作者
with z_in.open('word/document.xml') as f:
tree = etree.parse(f)
root = tree.getroot()
for elem in root.iter():
tag = etree.QName(elem.tag).localname
if tag in ('ins', 'del'):
old_author = elem.get(f'{WNS}author', '')
if old_author == '旧作者名':
elem.set(f'{WNS}author', 'WB')
z_out.writestr('word/document.xml', etree.tostring(tree, encoding='UTF-8', xml_declaration=True, standalone=True))
z_in.close()
z_out.close()
```
### 修改INS文本内容
```python
from copy import deepcopy
from datetime import datetime
now = datetime.now().isoformat()
# 定位特定段落中的INS元素
body = root.find(f'{WNS}body')
paras = body.findall(f'{WNS}p')
target_para = paras[12] # 按索引定位
# 删除旧的INS元素(按作者筛选)
for ins in list(target_para.findall(f'{WNS}ins')):
author = ins.get(f'{WNS}author', '')
if author == '目标作者':
target_para.remove(ins)
# 添加新的INS元素
new_ins = etree.SubElement(target_para, f'{WNS}ins')
new_ins.set(f'{WNS}author', 'WB')
new_ins.set(f'{WNS}date', now)
new_r = etree.SubElement(new_ins, f'{WNS}r')
# 从同段落的原文run复制格式
orig_runs = target_para.findall(f'{WNS}r')
if orig_runs:
orig_rpr = orig_runs[0].find(f'{WNS}rPr')
if orig_rpr is not None:
new_r.append(deepcopy(orig_rpr))
new_t = etree.SubElement(new_r, f'{WNS}t')
new_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
new_t.text = '新的插入文本'
```
### 修改批注作者
```python
# 修改comments.xml中的作者
if 'word/comments.xml' in z_in.namelist():
with z_in.open('word/comments.xml') as f:
ctree = etree.parse(f)
croot = ctree.getroot()
for c in croot.findall(f'{WNS}comment'):
if c.get(f'{WNS}author', '') == '旧作者名':
c.set(f'{WNS}author', 'WB')
z_out.writestr('word/comments.xml', etree.tostring(ctree, encoding='UTF-8', xml_declaration=True, standalone=True))
```
### 在已有WB INS元素内修改部分文本(2026-07-01 反委托代发工资协议)
当段落文本全部是WB INS(无普通w:r),需要替换其中某一句时,**不能删除整个INS重建**(会丢失该INS中其他文本的修订标记)。正确手法:**trim原INS的w:t + addnext插入DEL/INS**。
```python
old_sentence = "退回派遣员工由乙方依法自行安置处理,与甲方无涉。"
new_sentence = "派遣员工退回后由乙方依法负责安置处理。因乙方安置不当导致甲方被追究责任的,乙方应赔偿甲方因此遭受的全部损失。"
for child in list(p):
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == 'ins' and child.get(f'{WNS}author') == 'WB':
for r in child.findall(f'{WNS}r'):
for t in r.findall(f'{WNS}t'):
if t.text and old_sentence in t.text:
rpr_copy = copy.deepcopy(r.find(f'{WNS}rPr')) if r.find(f'{WNS}rPr') is not None else None
# 1. Trim原INS文本(去掉被替换的句子)
t.text = t.text.replace(old_sentence, "")
# 2. 创建DEL
del_elem = etree.Element(f'{WNS}del')
del_elem.set(f'{WNS}id', next_id())
del_elem.set(f'{WNS}author', 'WB')
del_elem.set(f'{WNS}date', rev_date)
del_r = etree.SubElement(del_elem, f'{WNS}r')
if rpr_copy: del_r.insert(0, copy.deepcopy(rpr_copy))
del_r.set(f'{WNS}rsidDel', rsid)
del_t = etree.SubElement(del_r, f'{WNS}delText')
del_t.set(XML_SPACE, 'preserve')
del_t.text = old_sentence
# 3. 创建INS
ins_elem = etree.Element(f'{WNS}ins')
ins_elem.set(f'{WNS}id', next_id())
ins_elem.set(f'{WNS}author', 'WB')
ins_elem.set(f'{WNS}date', rev_date)
ins_r = etree.SubElement(ins_elem, f'{WNS}r')
if rpr_copy: ins_r.insert(0, copy.deepcopy(rpr_copy))
ins_r.set(f'{WNS}rsidR', rsid)
ins_t = etree.SubElement(ins_r, f'{WNS}t')
ins_t.set(XML_SPACE, 'preserve')
ins_t.text = new_sentence
# 4. 插入到原INS之后(addnext保证顺序)
child.addnext(ins_elem) # 后插的在后面
child.addnext(del_elem) # 后插的在前面 → 最终: [原INS] [DEL] [INS]
```
**关键点**
- `addnext` 两次:先插INS再插DEL,后插的排前面,最终顺序:`[原INS(trimmed)] [DEL旧句] [INS新句]`
- 绝不能 `p.remove(child)` 再重建——会丢失INS中其他未改动的文本
- rPr必须从原INS的run深拷贝,不要从全文body_rpr取(字号可能不同)
### 给全INS段落补充条款编号(2026-07-01)
段落所有文本都是WB INS时,编号INS插到pPr之后:
```python
ins_num = etree.Element(f'{WNS}ins')
ins_num.set(f'{WNS}id', next_id()); ins_num.set(f'{WNS}author', 'WB'); ins_num.set(f'{WNS}date', rev_date)
ins_r = etree.SubElement(ins_num, f'{WNS}r')
ins_r.insert(0, copy.deepcopy(existing_rpr)) # 从同段落INS run深拷贝
ins_r.set(f'{WNS}rsidR', rsid)
ins_t = etree.SubElement(ins_r, f'{WNS}t')
ins_t.set(XML_SPACE, 'preserve'); ins_t.text = "第X条 "
ppr = p.find(f'{WNS}pPr')
if ppr is not None: ppr.addnext(ins_num)
else: p.insert(0, ins_num)
```
### 新INS元素eastAsia字体显式补齐
原文WB INS run可能**没有显式eastAsia属性**(靠docDefaults回退),但新INS run**必须显式设置eastAsia=宋体**,否则修订上下文中可能丢失回退。post-save sweep:
```python
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', '宋体')
```
## 验证方法
```python
# 验证所有作者已更改
content = z_out.read('word/document.xml').decode('utf-8', 'ignore')
authors = set(re.findall(r'w:author="([^"]+)"', content))
assert '旧作者名' not in authors, f"仍有旧作者: {authors}"
# 验证批注作者
if 'word/comments.xml' in z_out.namelist():
with z_out.open('word/comments.xml') as f:
ctree = etree.parse(f)
for c in ctree.getroot().findall(f'{WNS}comment'):
assert c.get(f'{WNS}author') != '旧作者名'
```
## ⚠️ 铁律
1. **修改前必须备份原文件**:覆盖含第三方修订的文件 = 不可逆丢失
2. **只改作者名,不改文本**:除非明确要求修改INS内容
3. **zipfile不能原地读写**:必须先读后写临时文件,再用os.replace
4. **保留comments.xml中的批注锚点**:只改author属性,不改id/content/anchor
## 实证(2026-07-01 反委托代发工资协议)
华诚-Z在OnlyOffice中做了3处修订(第六条去法条引用、第七条简化纠正流程、第八条加退回员工安置)。后续制作版本时覆盖了所有中间文件,导致华诚-Z修订痕迹丢失。最终通过系统化文件扫描在/tmp/v1_doro_updated.docx中找到仍含华诚-Z作者的文件,提取修订内容后在最终版本中恢复。
@@ -0,0 +1,93 @@
# Multi-Version Contract Comparison Table (三版对比表)
## When to Use
When Maggie/Doro asks to compare multiple versions of a contract (typically: template / counterparty revision / our revision), produce a structured docx comparison table.
## Pattern (2026-07-03 模特合作协议 session)
### Document Setup
- **Landscape orientation** for 4-5 columns: `section.orientation = 1; page_width=Cm(29.7); page_height=Cm(21.0)`
- Narrow margins: 1.2-1.5cm all sides
- Font size 8.5-9pt for table cells (fits more content)
### Table Structure
| 条款 | 【模版】 | 版本A(对方修订) | 版本B(我方修订) | 双方协商一致 |
|------|---------|------------------|-----------------|-------------|
### Red Font for Differences
- Column N is red when its content differs from other versions
- Use `RGBColor(0xFF, 0x00, 0x00)` on the run
- "协商一致" column: red = current text doesn't match consensus → needs modification
### Yellow Background for Consensus Column
```python
def set_cell_shading(cell, color):
tc = cell._element
tcPr = tc.find(qn('w:tcPr'))
if tcPr is None:
tcPr = OxmlElement('w:tcPr')
tc.insert(0, tcPr)
shading = OxmlElement('w:shd')
shading.set(qn('w:fill'), color) # e.g. 'FFF8E1' for light yellow
shading.set(qn('w:val'), 'clear')
tcPr.append(shading)
```
### Header Row Styling
- Blue background (`D9E2F3`)
- Bold, centered, font size 8.5pt
### Data Structure in Code
```python
# Each row: (clause_name, col1_text, col2_text, col3_text, col4_text, col2_red, col3_red, col4_red)
rows = [
('条款名',
'模版内容',
'对方修订内容',
'我方修订内容',
'协商一致内容',
True, # col2 red? (differs from others)
False, # col3 red?
True), # col4 red? (doesn't match consensus)
]
```
### Legend at Bottom
Include a legend explaining what red means in each column:
- 版本A列红色 = 与模版/我方版不一致(对方的修改)
- 版本B列红色 = 与模版不一致(我方的修改)
- 协商一致列红色 = 当前文本与协商一致不符,需要修改
## Key Lessons
1. **Read all three files from Nextcloud** using `sudo find ~/nextcloud/data/data/...` path
2. **Extract paragraph text** using python-docx: `[(i, p.text.strip()) for i, p in enumerate(doc.paragraphs) if p.text.strip()]`
3. **Check tables** separately: `doc.tables` — contracts often have signature blocks and SNS account tables
4. **Align comparison by clause semantics**, not paragraph index — different versions may have different paragraph counts
5. Also upload to Nextcloud for viewing in OnlyOffice
## Per-Run Precision for Tracked Changes (Maggie's correction)
When applying tracked changes based on comparison results, **never replace entire paragraphs**. Instead:
1. Identify the specific runs containing text to change
2. For each run: create w:del wrapping a deepcopy (converting w:t → w:delText), create w:ins with new text and cloned rPr, swap in place
3. All surrounding runs remain untouched
```python
# Find specific run by text content
for r in para_element.findall(qn('w:r')):
text = ''.join(t.text or '' for t in r.findall(qn('w:t')))
if text == '¥700,000': # exact match on this run
r_parent = r.getparent()
r_idx = list(r_parent).index(r)
# Create del wrapping copy of this run
del_elem = make_del_run_from_existing(r)
# Create ins with new value, same rPr
ins_elem = make_ins_run('¥600,000', r.find(qn('w:rPr')))
r_parent.remove(r)
r_parent.insert(r_idx, ins_elem)
r_parent.insert(r_idx, del_elem)
break
```
This produces clean tracked changes where Word/OnlyOffice shows exactly which characters changed (e.g., ~~700,000~~ → 600,000) rather than entire-paragraph replacements.
@@ -0,0 +1,232 @@
# Multi-Version Creation Pattern
## When to Use
When creating multiple versions of the same contract (e.g., 版本1法定安排 vs 版本2反委托保护) or when needing to redo a version from scratch.
## Critical Rule
**ALWAYS start from the original source file for each version. Never modify a previously modified version.**
## Step-by-Step Pattern
### 1. Preserve Original Source
```python
# First time: copy original to safe location
shutil.copy('/path/to/original.docx', '/tmp/original_backup.docx')
```
### 2. For Each Version, Start Fresh
```python
# Always reload from original
with zipfile.ZipFile('/tmp/original_backup.docx', 'r') as zin:
all_data = {n: zin.read(n) for n in zin.namelist()}
doc_xml = all_data['word/document.xml']
root = etree.fromstring(doc_xml)
body = root.find(f'{W}body')
paras = body.findall(f'{W}p')
# Get font template from original
rpr_template = None
for p in paras:
for r in p.findall(f'{W}r'):
t = r.find(f'{W}t')
if t is not None and t.text and t.text.strip():
rpr_elem = r.find(f'{W}rPr')
if rpr_elem is not None:
rpr_template = copy.deepcopy(rpr_elem)
break
if rpr_template:
break
```
### 3. Apply All Modifications in One Pass
```python
rev_id = 1000 # Start fresh revision ID counter
# Batch all replacements
replacements = [
(0, "old text", "new text"),
(5, "old text", "new text"),
# ... more replacements
]
for idx, old_text, new_text in replacements:
p = paras[idx]
# Clear runs (but NOT comment anchors!)
for child in list(p):
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == 'r': # Only remove regular runs
p.remove(child)
d, i = make_tracked_replace(old_text, new_text, rpr_template, rev_id)
rev_id += 2
p.append(d)
p.append(i)
# Insert new clauses
insert_after = paras[10]
for clause_text in new_clauses:
new_p = make_ins_paragraph(clause_text, rpr_template, rev_id)
rev_id += 1
insert_after.addnext(new_p)
insert_after = new_p
# Mark deletions (e.g., 承诺书)
for idx in range(21, 30):
p = paras[idx]
text_parts = []
for r in p.findall(f'{W}r'):
t = r.find(f'{W}t')
if t is not None and t.text:
text_parts.append(t.text)
full_text = ''.join(text_parts)
if not full_text.strip():
continue
for child in list(p):
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == 'r':
p.remove(child)
del_elem = make_tracked_delete(full_text, rpr_template, rev_id)
rev_id += 1
p.append(del_elem)
# Add comments LAST (after all structural changes)
# Comment anchors are fragile - add them at the end
```
### 4. Add Comments Carefully
```python
# Check if paragraph already has comment anchors
existing = p.find(f'{W}commentRangeStart')
if existing is None:
# Add new comment anchors
comment_start = etree.Element(f'{W}commentRangeStart')
comment_start.set(f'{W}id', str(comment_id))
p.insert(0, comment_start)
comment_end = etree.Element(f'{W}commentRangeEnd')
comment_end.set(f'{W}id', str(comment_id))
p.append(comment_end)
comment_ref_run = etree.SubElement(p, f'{W}r')
comment_ref = etree.SubElement(comment_ref_run, f'{W}commentReference')
comment_ref.set(f'{W}id', str(comment_id))
# Update comments.xml
if 'word/comments.xml' in all_data:
croot = etree.fromstring(all_data['word/comments.xml'])
else:
croot = etree.Element(f'{W}comments', nsmap={'w': W_NS})
# Add or update comment
new_comment = etree.SubElement(croot, f'{W}comment')
new_comment.set(f'{W}id', str(comment_id))
new_comment.set(f'{W}author', author)
new_comment.set(f'{W}date', datetime.now().isoformat())
p = etree.SubElement(new_comment, f'{W}p')
r = etree.SubElement(p, f'{W}r')
t = etree.SubElement(r, f'{W}t')
t.set(XML_SPACE, 'preserve')
t.text = comment_text
```
### 5. Save and Verify
```python
all_data['word/document.xml'] = etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
all_data['word/comments.xml'] = etree.tostring(croot, xml_declaration=True, encoding='UTF-8', standalone=True)
with zipfile.ZipFile('/tmp/version1.docx', 'w', zipfile.ZIP_DEFLATED) as zout:
for name, data in all_data.items():
zout.writestr(name, data)
# Verify immediately
doc = Document('/tmp/version1.docx')
print(f"OK: {len(doc.paragraphs)} paragraphs")
```
## Common Pitfalls
### ❌ Don't Do This
```python
# WRONG: Modifying v1 to create v2
shutil.copy('/tmp/v1.docx', '/tmp/v2.docx')
with zipfile.ZipFile('/tmp/v2.docx', 'r') as zin:
# ... load v1's modified structure
# This will have v1's tracked changes, comments, etc.
```
### ❌ Don't Clear Everything When Modifying
```python
# WRONG: Clears comment anchors too!
for child in list(p):
if child.tag not in (f'{W}pPr',):
p.remove(child) # Removes commentRangeStart/End!
```
### ✅ Do This Instead
```python
# RIGHT: Only clear regular runs, preserve comment anchors
for child in list(p):
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == 'r': # Only regular runs
p.remove(child)
# commentRangeStart, commentRangeEnd are preserved
```
## Preserving Original Comments
When the original document has comments (e.g., Alice, 法务, 杜律), the workflow must:
1. Read original comments.xml to get all comment IDs and content
2. Check which paragraphs have comment anchors (commentRangeStart/End)
3. When clearing runs, preserve comment anchors (they're not `w:r` elements)
4. Add new comments with NEW IDs (don't reuse original IDs)
5. Original comments remain unchanged in comments.xml
## Preserving Third-Party Tracked Changes (2026-07-01 华诚-Z案)
When a contract file contains tracked changes from someone other than WB (e.g., 华诚-Z, Crystall, or any third-party reviewer), **those files must never be overwritten**. The tracked changes represent real editorial work that cannot be reconstructed from session notes alone.
### Backup Protocol
```python
import shutil
from datetime import datetime
# BEFORE any modification to a file with third-party tracked changes:
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = f'/tmp/{filename}.bak_{ts}'
shutil.copy(source_path, backup_path)
print(f"Backed up to {backup_path}")
```
### Detection: Does This File Have Third-Party Changes?
```python
import zipfile, re
with zipfile.ZipFile(filepath) as z:
content = z.read('word/document.xml').decode('utf-8', errors='ignore')
authors = set(re.findall(r'w:author="([^"]+)"', content))
third_party = authors - {'WB'}
if third_party:
print(f"⚠️ Third-party authors found: {third_party} — BACKUP REQUIRED")
```
### Multi-Version with Third-Party Edits
When creating v1 and v2 from a file that has both original content AND third-party edits:
1. **Backup the file with third-party edits** (e.g., `华诚-Z版.bak_20260701`)
2. **Backup the pristine original** (no tracked changes at all)
3. **For each version**: start from the pristine original, then layer on:
- WB's own tracked changes
- Third-party's tracked changes (with author renamed to WB)
4. **Never modify the backup files** — they are your insurance
### What Was Lost (华诚-Z案)
- 华诚-Z made 3 tracked changes in OnlyOffice: 第六条 (removed specific legal citations), 第七条 (simplified correction process), 第八条 (added employee return placement clause)
- These intermediate files in /tmp/ were overwritten during v1/v2 creation
- Only session notes preserved the *content* of changes, not the actual tracked change markup (ids, timestamps, exact XML positions)
- **Recovery was impossible** — Doro had to accept reconstructed versions
## Real Example from This Session
- Original: 4 comments (Alice×2, 法务, 杜律)
- Version 1: 5 comments (original 4 + WB legal risk)
- Version 2: 5 comments (original 4 + WB legal risk)
Both versions created independently from original, each with their own WB comment (different content for each version).
@@ -0,0 +1,34 @@
# 新增条款pPr完整克隆铁律(2026-07-13 盈浦健康科普合同教训)
## 问题
新增条款(如"八、转包与分包"的正文P75)插入后,只考虑了numPr是否正确,但遗漏了其他pPr子元素(如`ind`首行缩进)。导致新增段落与原文同类段落格式不一致。
## 教训链(同一份合同被Doro纠正3次)
1. **第一次**:P75挂了错误的numId=11(不可抗力的序列)→ 渲染为"3."
2. **第二次**:去掉numId后,加了新的numId=14 → 单段正文不该有编号("有2才有1"规则延伸到numPr)
3. **第三次**:去掉numId后仍缺首行缩进ind=420 → 与原文"一、合作背景"(同为单段无编号正文)格式不一致
## 铁律:新增段落pPr必须完整比对原文参照段
动手前必须:
1. **找到原文中的参照段落**——格式相同的段落(同层级、同类型)
2. **逐子元素列出参照段的pPr**:spacing、ind、numPr、jc、每一个子元素
3. **逐一对比新增段落的pPr**:缺什么补什么,多什么删什么
4. 不能只看一个属性(如numPr)就认为"格式正确了"
## "有2才有1"规则在numPr上的延伸
原规则:新增条款如果下一级只有一条内容,不加子编号。
延伸到numPr:如果某章节下只有一个正文段落,且原文中同类单段正文没有numPr(如"一、合作背景"的P12无numPr),则新增的单段正文也不加numPr。
判断方法:看原文中有没有"章节标题+单段正文+无numPr"的先例。有→新增单段也不加。
## 检查清单(操作后必过)
- [ ] 新增段落的spacing与参照段一致
- [ ] 新增段落的ind与参照段一致(特别是firstLine/firstLineChars)
- [ ] 新增段落的numPr:单段→不加(参照原文同类);多段→加入对应序列
- [ ] 新增段落INS run的rPr与参照段原文run的rPr一致(无多余sz、无缺失属性)
@@ -0,0 +1,147 @@
# Strip numPr When Adding Manual Numbering to Auto-Numbered Paragraphs
## Problem (2026-07-01 反委托代发工资协议)
Original contract paragraphs have `<w:numPr>` with actual auto-numbering (e.g., `numId=3 → abstractNum decimal "%1." start=1`). When you insert manual "第X条" numbering as `w:ins` at paragraph start, OnlyOffice renders BOTH:
```
1. 第一条 乙方应严格按照... ← "1." is auto-numbering, "第一条" is your INS
```
This looks broken — two different numbering systems stacked.
## Root Cause
The paragraph's `pPr/numPr` tells the rendering engine to prepend an automatic decimal number. Your INS adds a second, manual number. They coexist independently.
Additionally, if the paragraph has `pPr/pPrChange` (tracking the old paragraph formatting), the old `numPr` inside `pPrChange` can ALSO render in markup view.
## Fix (two-step)
After inserting manual numbering INS elements, strip auto-numbering from ALL affected paragraphs:
```python
for idx in target_paragraph_indices:
p = paras[idx]
ppr = p.find(f'{WNS}pPr')
if ppr is not None:
# Step 1: Remove direct numPr
num_pr = ppr.find(f'{WNS}numPr')
if num_pr is not None:
ppr.remove(num_pr)
# Step 2: Remove numPr inside pPrChange (old formatting record)
ppr_change = ppr.find(f'{WNS}pPrChange')
if ppr_change is not None:
inner_ppr = ppr_change.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)
```
## When This Applies
- You're converting a contract from auto-numbered clauses to manual "第X条" heading-style numbering
- The original .doc/.docx used Word's list numbering for clause structure
- You're adding "第一条 " etc. as INS at paragraph start
## Verification
After fix:
1. `pdftotext -layout` of OnlyOffice render should show NO stray "1." / "2." / "3." before your "第X条"
2. Accept-revisions preview should also be clean (no residual auto-numbers)
## Scenario B: Auto-Numbering Resets Across Tracked-Deleted Paragraphs (2026-07-01 反委托代发工资协议)
### Problem
When paragraphs with `numPr` auto-numbering are interspersed with **entirely deleted paragraphs** (all content in `w:del`), OnlyOffice's auto-number counter **resets to 1** after the deleted block. This makes continuous numbering impossible with `numPr` alone.
Example structure:
```
P6: numPr=1 INS content (clause 1) → renders "1."
P7: numPr=1 continuation → renders "2." (wrong if P7 shouldn't be numbered)
P8: numPr=1 ALL w:del → renders "3." with strikethrough
P9: numPr=1 ALL w:del → renders "4." with strikethrough
P10: numPr=1 INS content (clause 2) → renders "1." ← RESETS! Should be "2."
```
The auto-numbering engine counts visible (non-deleted) items in the `numId` sequence, but deleted paragraphs **break the continuity** in OnlyOffice's rendering.
### Solution: Convert to Manual Text Numbering
Strip `numPr` from ALL paragraphs and insert "N. " as `w:ins` text at paragraph start. This gives identical visual output ("1. 2. 3. ...") without depending on the broken auto-number counter.
```python
# Step 1: Strip ALL numPr (including inside pPrChange)
for i, p in enumerate(paragraphs):
pPr = p.find(f'{{{W}}}pPr')
if pPr is not None:
numPr = pPr.find(f'{{{W}}}numPr')
if numPr is not None:
pPr.remove(numPr)
for pPrChange in pPr.findall(f'{{{W}}}pPrChange'):
old_pPr = pPrChange.find(f'{{{W}}}pPr')
if old_pPr is not None:
old_numPr = old_pPr.find(f'{{{W}}}numPr')
if old_numPr is not None:
old_pPr.remove(old_numPr)
# Step 2: Insert "N. " as w:ins text for each clause paragraph
# Only number paragraphs that have VISIBLE content (not entirely w:del)
clause_map = {6: 1, 10: 2, 11: 3, ...} # para_index: clause_number
for para_idx, clause_num in clause_map.items():
p = paragraphs[para_idx]
# Build INS element with "N. " text
ins_elem = ET.Element(f'{{{W}}}ins')
ins_elem.set(f'{{{W}}}id', str(next_rev_id()))
ins_elem.set(f'{{{W}}}author', rev_author) # from existing INS in doc
ins_elem.set(f'{{{W}}}date', rev_date)
r_elem = ET.SubElement(ins_elem, f'{{{W}}}r')
# Clone rPr from existing runs for font consistency
rPr = get_run_rPr_from_paragraph(p)
if rPr is not None:
r_elem.append(copy.deepcopy(rPr))
t_elem = ET.SubElement(r_elem, f'{{{W}}}t')
t_elem.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
t_elem.text = f"{clause_num}. "
# Insert after pPr
pPr = p.find(f'{{{W}}}pPr')
if pPr is not None:
p.insert(list(p).index(pPr) + 1, ins_elem)
else:
p.insert(0, ins_elem)
```
### When to Use This (vs Scenario A)
- **Scenario A** (above): You're CHANGING the numbering scheme (auto "1." → manual "第一条")
- **Scenario B** (this): You're KEEPING the same format ("1. 2. 3.") but converting from auto to manual because auto-numbering resets across w:del paragraphs
- **Trigger**: Original uses numPr auto-numbering + your edits create entirely-deleted paragraphs between numbered items → auto counter resets → switch to manual text
### Key Decision: Which Paragraphs to Number
Only number paragraphs that will be visible after accepting revisions:
- Paragraphs with ONLY `w:del` content → skip (they're deleted)
- Paragraphs that are continuations of the previous clause (no independent number) → skip
- New INS-only paragraphs (new clauses) → number them
- Rewritten paragraphs (mixed INS+DEL, first clause in sequence) → number them
### Verification
After conversion:
1. OnlyOffice render (x2t → PDF) should show continuous "1. 2. 3. ... 11." without resets
2. No stray auto-numbers from numPr remnants
3. Deleted paragraphs (entirely w:del) should NOT show any number
## Distinction from Existing Rules
- Rule 5 (A類 vs B類) talks about NEW clauses inheriting/stripping numPr
- Scenario A is EXISTING paragraphs where you're REPLACING their numbering scheme with a different format via INS
- Scenario B is EXISTING paragraphs where auto-numbering BREAKS due to tracked-deleted paragraphs, requiring conversion to same-format manual text
- numId=0 trap (Rule 5 sub-note) is about fake auto-numbering; BOTH scenarios here are about REAL auto-numbering that renders visible numbers
@@ -0,0 +1,54 @@
# 一页纸 docx 排版压缩配方
适用场景:创建必须严格一页的 Word 文档(合作框架、报价单、一页摘要等),通过 OnlyOffice x2t 渲染验证。
## 迭代压缩流程
x2t 渲染的行高/间距比 python-docx 估算的略宽松,不能靠"调好参数直接交付"。必须走渲染验证循环。
### 第一轮:合理起点
| 参数 | 值 |
|---|---|
| 上下边距 | 1.5–2.0 cm |
| 左右边距 | 1.8–2.0 cm |
| 正文字号 | 9–10 pt |
| 表格字号 | 8–9 pt |
| 行距 | 1.05–1.15 |
### 验证循环
```bash
bash ~/.hermes/skills/legal/contract-editor/scripts/onlyoffice-render.sh <docx> <pdf>
python3 -c "
import subprocess
r=subprocess.run(['pdftotext','<pdf>','-'],capture_output=True,text=True)
pages=r.stdout.split('\f')
print(f'页数: {len(pages)}')
"
```
- 页数=1 → 交付
- 页数=2 且末页空白 → 内容刚好溢出,微调即可
- 页数=2 且有内容 → 需要大幅压缩或精简文字
### 逐级压缩(按优先级)
1. 底部边距:1.5→1.0→0.8→0.5 cm(先砍底部,顶部保阅读感)
2. 表格字号:8→7.5→7 pt
3. 行距:1.05→1.0
4. 左右边距:1.8→1.5 cm
5. 段落间距:Pt(2)→Pt(1)→Pt(0)
6. 精简文字(最后手段)
### 已实证的可用参数(5000字内一页A4)
| 参数 | 值 |
|---|---|
| 上下边距 | 1.2 / 0.5 cm |
| 左右边距 | 1.5 cm |
| 正文字号 | 8 pt |
| 表格字号 | 7 pt |
| 行距 | 1.0 |
| 段落间距 | 0 |
## 陷阱
- x2t 对表格行高估算偏大,表内文字多时尤其明显
- 分隔线(`—`*N)占用空间,一页紧张时去掉
- 表格 `Table Grid` 样式自带内边距,无法通过 python-docx 参数完全消除
- 第二页空白但无文字 = 内容刚好溢出几像素,再砍 0.2cm 底部边距或减 0.5pt 字号即可
@@ -0,0 +1,40 @@
# ContractEditor Operation Ordering: Add Clauses Before Renumbering
## 2026-07-13 练塘硬件购销合同教训
### Problem
When interleaving `add_clause_before()` and `tracked_replace()` for renumbering, lxml throws:
```
ValueError: Element is not a child of this node.
```
in `tracked_replace()``parent.remove(runs[idx])`.
### Root Cause
`add_clause_before()` / `add_clause()` mutate the XML tree (insert new `<w:p>` elements). After insertion, previously-found element references held by later `tracked_replace()` calls may point to nodes whose parent relationship has shifted. The `parent.remove()` call inside `tracked_replace` fails because the run's parent `<w:p>` is no longer the same object the code expects.
### Fix: Two-Phase Approach (铁律)
**Phase 1 — All structural additions:**
- `add_clause()` / `add_clause_before()` for new clauses
- Content-level `tracked_replace()` that don't touch clause titles being renumbered
**Phase 2 — Renumbering (after all additions are done):**
- `tracked_replace('第七条不可抗力', '第九条不可抗力')` etc.
### Example (correct order)
```python
# Phase 1: add new clauses
editor.add_clause_before('第七条 转包与分包\n...', before_search='第七条不可抗力')
editor.add_clause_before('第八条 第三方侵权\n...', before_search='第七条不可抗力')
# Phase 2: renumber old clauses (all additions done)
editor.tracked_replace('第七条不可抗力', '第九条不可抗力')
editor.tracked_replace('第八条争议解决', '第十条争议解决')
```
### WPS/DOC File Handling
WPS `.wps` and `.doc` files must be converted to `.docx` before ContractEditor can process them:
```bash
libreoffice --headless --convert-to docx input.wps --outdir /tmp/contract-review/
```
Then copy to a simple ASCII filename to avoid python-docx path issues with Chinese characters.
@@ -0,0 +1,60 @@
# Paragraph Deletion via Tracked Changes (WB)
When a reviewer instructs you to delete an entire clause/paragraph, use **paragraph-level deletion** — not just deleting the text but marking the entire paragraph as removed in tracked changes.
## Two-Part Deletion
### Part 1: Paragraph Mark Deletion
Add a `w:del` element inside the paragraph's `w:pPr/w:rPr`:
```xml
<w:pPr>
<w:rPr>
<w:del w:id="7777" w:author="WB" w:date="2026-06-26T00:00:00Z"/>
</w:rPr>
</w:pPr>
```
This marks the paragraph marker (¶) as deleted, so the paragraph doesn't leave an empty line.
### Part 2: Content Deletion
Wrap every text run in the paragraph inside `w:del` elements, converting `w:t` to `w:delText`:
```python
for child in list(paragraph):
tag = child.tag.split('}')[-1]
if tag in ('r', 'ins'):
paragraph.remove(child)
del_elem = etree.SubElement(paragraph, f'{{{W}}}del')
del_elem.set(f'{{{W}}}id', '7777')
del_elem.set(f'{{{W}}}author', 'WB')
del_elem.set(f'{{{W}}}date', '2026-06-26T00:00:00Z')
target_runs = child.findall(f'{{{W}}}r') if tag == 'ins' else [child]
for r in target_runs:
t = r.find(f'{{{W}}}t')
if t is not None:
r.remove(t)
dt = etree.SubElement(r, f'{{{W}}}delText')
dt.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
dt.text = t.text
r.set(f'{{{W}}}rsidDel', new_rsid())
del_elem.append(r)
```
### Key Points
- **Same del id** for both the paragraph mark and content dels (e.g., `7777`) — they're part of the same deletion operation
- **Handle existing INS runs**: If the paragraph has runs inside `w:ins` (from previous revisions), extract them and wrap in `w:del` too
- **Leave existing DEL runs untouched** — they're already deleted
- **Copy rPr**: If the original run had `rPr`, copy it into the del run so the strikethrough text renders with correct font/size
- **Use unique ids**: Pick an id that doesn't collide with existing del/ins ids in the document. Check `max(del_ids) + 1000` if unsure
### Verification
After deletion, render with OnlyOffice and check:
1. The deleted paragraph appears with strikethrough in markup view
2. Accepting all revisions removes the paragraph entirely (no empty line)
3. The paragraph mark (¶) is also deleted — no gap between surrounding paragraphs
@@ -0,0 +1,50 @@
# Per-Paragraph Font Matching(2026-07-13 消防设施检测合同教训)
## 问题
同一份合同中,不同段落的原文runs可能有完全不同的字体属性方案:
- P20 runs: `ascii=宋体, hAnsi=宋体, cs=宋体, sz=24, eastAsia=None, hint=None`
- P36/P41 runs: `rPr=None`(完全无字体属性,靠docDefaults/style继承)
如果对所有WB INS runs统一设置一种字体属性,必然导致某些段落mismatch。
## 错误做法
```python
# ❌ 全局统一设置
for ins in all_wb_ins:
rf.set('ascii', '宋体')
rf.set('sz', '24')
```
## 正确做法
```python
# ✅ 按段落匹配原文第一个非INS/非DEL run的rPr
for p in paras:
# 找到同段落的第一个orig run
orig_run = None
for child in p:
if child.tag == f'{WNS}r':
orig_run = child
break
if orig_run is None:
continue # 整段INS,无参照
orig_rpr = orig_run.find(f'{WNS}rPr')
orig_rf = orig_rpr.find(f'{WNS}rFonts') if orig_rpr is not None else None
# INS的rPr应该与orig_run的rPr完全匹配
# 如果orig没有rFonts → INS也不该有
# 如果orig有ascii=宋体但没有eastAsia → INS也是这样
```
## 整段INS段落(无同段原文可比)
- wb-ins-font-verify.py会报"MISSING HINT (无同段原文可比)"
- 这是**已知假阳性**,不算真问题
- 整段INS段落的字体应参照**相邻段落**(前后各2段)的原文run格式
- 如果相邻原文runs有explicit属性(ascii=宋体 sz=24),INS也设
- 如果相邻原文runs无rPr,INS也不设
## 2026-07-13 消防设施检测合同实证
- 第一次修复:全局strip eastAsia/hint → P20报ASCII MISMATCH(原文有ascii=宋体)
- 第二次修复:全局设ascii=宋体 → P36/P41报ASCII MISMATCH(原文无rFonts)
- 正确修复:per-paragraph检查orig_run是否有ascii → 有则INS也设,无则INS也不设
@@ -0,0 +1,46 @@
# 反委托代发工资法律风险
## 核心法律规定
| 法律依据 | 条文 | 效力 |
|----------|------|------|
| 《劳务派遣暂行规定》第8条第(三)项 | 派遣单位应当依法支付被派遣劳动者的劳动报酬 | 强制性规定 |
| 《劳动合同法》第58条 | 派遣单位是用人单位,应履行用人单位义务 | 法律 |
| 《劳动合同法》第92条第2款 | 用工单位给被派遣劳动者造成损害的,派遣单位与用工单位承担连带赔偿责任 | 法律 |
| 《劳务派遣暂行规定》第24条 | 用工单位违法退回的,按劳动合同法第92条第2款执行 | 部门规章 |
| 劳社部发〔2005〕12号第2条 | 工资支付凭证是认定事实劳动关系的首要证据 | 规范性文件 |
## 核心结论
1. **代发工资 = 事实劳动关系首要证据**:用工单位直接向派遣员工发工资,违反《劳务派遣暂行规定》第8条强制性规定
2. **协议不能免除法定责任**:甲乙之间的内部追偿条款不能对抗劳动者和行政机关
3. **退回条款限制**:用工单位只能在法定三种情形下退回(客观情况重大变化/经济性裁员、破产/解散、协议期满)
4. **"与甲方无涉"条款有法律风险**:可能因违反《劳动合同法》第26条第2款(免除法定责任)被认定无效
## 关键判例
- **广东高院(2022)粤民再30号**:汽车公司以咨询公司名义签劳动合同,工资由汽车公司直接发放。认定汽车公司与劳动者存在事实劳动关系。
- **(2019)沪0109民初12453号**:用工单位违法退回导致派遣公司违法解除的,用工单位承担连带赔偿责任。
- **(2022)鲁0322民初834号**:甲公司将工资计算后交乙公司发放,法院认定甲公司存在经济依附性,构成事实劳动关系。
## 审查建议
### 推荐方案(版本1:回归法定安排)
- 删除代发工资条款,由乙方(派遣公司)直接支付工资
- 添加甲方监督权、扣款权、违约金条款
- 添加乙方资质维持、用工管理义务、退回权、保密条款
### 替代方案(版本2:反委托保护)
如甲方坚持代发,最大化保护措施:
1. 鉴于条款定性为"委托代发",明确甲方仅为代理人
2. 三方签署要求(甲方、乙方、派遣员工)
3. 事实劳动关系兜底:乙方十日内赔偿甲方全部损失
4. 履约保证金(金额留空)或银行保函
5. 税务责任限定:甲方仅承担自身原因导致的差额
6. 社保义务对等
7. 劳动关系确认条款
8. 乙方资质维持 + 用工管理义务
### 退回条款措辞
❌ "退回派遣员工由乙方依法自行安置处理,与甲方无涉"(有法律风险)
✅ "派遣员工退回后由乙方依法负责安置处理。因乙方安置不当导致甲方被追究责任的,乙方应赔偿甲方因此遭受的全部损失。"
@@ -0,0 +1,120 @@
# 审查意见文档字体强制设置
## 背景(2026-07-02 肃言+恭兴合同返工)
review-rules.md 规定审查意见文档:中文统一仿宋体,英文Times New Roman。
**问题**:模板文件(`朱家角 审查意见【模板】.docx`)的表头行有显式eastAsia=仿宋,但新建的数据行字体设置不一致:
- 恭兴审查意见:editor给数据行设了 ascii=仿宋 hAnsi=仿宋(错:英文也变仿宋了)
- 肃言审查意见:editor压根没给数据行设ascii/hAnsi(只有eastAsia=仿宋)
**根因**:LLM每次独立session生成代码,字体设置逻辑不稳定。
## 强制修复代码(生成审查意见后必跑)
```python
from docx import Document
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
def enforce_review_opinion_fonts(doc_path, save=True):
"""审查意见文档生成后强制设置所有run的字体。
中文=仿宋, 英文=Times New Roman
"""
doc = Document(doc_path)
fixed = 0
# Fix all table cells
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for p in cell.paragraphs:
for run in p.runs:
fixed += _fix_run_font(run._element)
# Fix all paragraphs outside tables
for p in doc.paragraphs:
for run in p.runs:
fixed += _fix_run_font(run._element)
if save:
doc.save(doc_path)
return fixed
def _fix_run_font(run_element):
"""Ensure run has eastAsia=仿宋, ascii/hAnsi=Times New Roman"""
rPr = run_element.find(qn('w:rPr'))
if rPr is None:
rPr = OxmlElement('w:rPr')
run_element.insert(0, rPr)
rFonts = rPr.find(qn('w:rFonts'))
if rFonts is None:
rFonts = OxmlElement('w:rFonts')
rPr.insert(0, rFonts)
changed = False
# eastAsia must be 仿宋
if rFonts.get(qn('w:eastAsia')) != '仿宋':
rFonts.set(qn('w:eastAsia'), '仿宋')
changed = True
# ascii must be Times New Roman (NOT 仿宋)
if rFonts.get(qn('w:ascii')) != 'Times New Roman':
rFonts.set(qn('w:ascii'), 'Times New Roman')
changed = True
# hAnsi must be Times New Roman (NOT 仿宋)
if rFonts.get(qn('w:hAnsi')) != 'Times New Roman':
rFonts.set(qn('w:hAnsi'), 'Times New Roman')
changed = True
return 1 if changed else 0
```
## 验证方法
```python
def verify_review_opinion_fonts(doc_path):
"""验证审查意见文档字体全部正确"""
from docx import Document
from docx.oxml.ns import qn
doc = Document(doc_path)
errors = []
for table in doc.tables:
for i, row in enumerate(table.rows):
for j, cell in enumerate(row.cells):
for p in cell.paragraphs:
for run in p.runs:
rpr = run._element.find(qn('w:rPr'))
if rpr is None:
errors.append(f'Row{i}Col{j}: no rPr')
continue
rf = rpr.find(qn('w:rFonts'))
if rf is None:
errors.append(f'Row{i}Col{j}: no rFonts')
continue
ea = rf.get(qn('w:eastAsia'))
ascii_f = rf.get(qn('w:ascii'))
hAnsi = rf.get(qn('w:hAnsi'))
if ea != '仿宋':
errors.append(f'Row{i}Col{j}: eastAsia={ea} (should be 仿宋)')
if ascii_f != 'Times New Roman':
errors.append(f'Row{i}Col{j}: ascii={ascii_f} (should be TNR)')
if hAnsi != 'Times New Roman':
errors.append(f'Row{i}Col{j}: hAnsi={hAnsi} (should be TNR)')
return errors
```
## 常见错误
| 错误 | 后果 | 根因 |
|------|------|------|
| ascii/hAnsi=仿宋 | 英文/数字渲染用仿宋(无西文字形,显示异常) | LLM把"统一仿宋"理解为所有属性都设仿宋 |
| 数据行无ascii/hAnsi | 英文回退系统默认字体(可能是宋体/黑体) | 只设了eastAsia,忘设西文字体 |
| 只有表头有字体 | 数据行全部回退默认 | 模板限制:只有表头行有显式字体 |
## 预防方案
最佳方案:修改模板文件的**Normal样式或Table Grid样式**定义,预设完整字体。但模板可能被多场景共用,最稳妥还是生成后强制设置。
@@ -0,0 +1,40 @@
# 审查意见文档格式检查清单 (2026-07-13 Doro纠正)
## 生成后必做三项格式修复
### 1. 删除表格中的空白行
- "空白行"指**表格中**三列全为空的row(模板占位行)
- 不是文档段落的空行
- 代码:遍历table rows,检查所有cells文本为空的row,删除
### 2. 页眉日期改为修订当日
- 读取 header*.xml,找到日期文本(如"2019/3")
- ⚠️ 日期经常被拆成多个run(如"201"+"9"+"/"+"3")
- 需逐run处理:拼出完整日期字符串 → 替换为当日(如"2026/7")
- 格式:YYYY/M(不补零)
### 3. 标题必须用合同正文全称
- 读合同docx正文P0(或前几段)获取合同标题全称
- 填入审查意见文档的《》内
- ❌ 不能用文件名(文件名可能是简称、带前缀、有(1)后缀)
- ✅ 必须是合同正文中出现的完整合同名称
## 内容规则(不变)
- 只写原文和修订后内容,不做理由说明
- 不写(注:...)
- 行顺序按条款号排列
- 有修改意见时删除"无法律修改意见。"段落
- 批注内容也要体现在表格中
## 字体硬规则
| 位置 | eastAsia | ascii | hAnsi | sz | bold | hint |
|------|----------|-------|-------|-----|------|------|
| 标题 | 仿宋 | - | - | 32(16pt) | True | eastAsia |
| 表头 | 仿宋 | TNR | TNR | 24(12pt) | True | eastAsia |
| 数据行 | 仿宋 | TNR | TNR | 24(12pt) | False | eastAsia |
| 签名 | 仿宋 | TNR | TNR | 24(12pt) | False | eastAsia |
## 同模板合同审查意见一致性
- 共有修订行内容完全一致
- 行顺序统一(按条款号)
- 个案差异行按各合同实际情况(如金额批注)
@@ -0,0 +1,44 @@
# 审查意见文档格式规则 (2026-07-13 Doro纠正)
## 规则来源
朱家角 review-rules.md "审查意见格式要求" 章节 (2026-07-13 更新)
## 规则内容
### 1. 删除表格中的空白行
- "空白行"指的是审查意见表格中**三列全空**的行(条文/原文/修订后都没有内容)
- 不是正文段落的空白行——正文段落空行是排版问题,表格空行才是Doro说的"删除空白行"
- 2026-07-13教训:Doro说"删除空白行",小Maggie误解为删正文段落空行,被纠正后才看到是表格Row1-Row4全空
### 2. 页眉日期改为修订当日
- 审查意见模板页眉中有日期(如 header2.xml 中 "2019/3")
- 生成审查意见时必须更新为**修订当日**的年/月(如"2026/7")
- 注意:页眉中的日期可能拆分在多个run中(如"201"+"9"+"/"+"3"),需逐run定位修改
### 3. 标题必须用合同正文全称
- 规则:"标题《》内填写所审查的合同名称"
- "合同名称" = 合同正文第一段的标题(如"医疗设备器械购销合同"),**不是文件名**
- 文件名可能是简写(如"医疗合同(2).doc"),但审查意见标题必须写全称
- 2026-07-13教训:文件名"医疗合同(2)",合同正文标题是"医疗设备器械购销合同",审查意见标题应为"关于《医疗设备器械购销合同》的审查意见"
## Workflow重复处理检测(2026-07-13 香花桥安全生产合同教训)
### 问题
待审查目录的文件可能**已含WB tracked changes**(上一轮workflow产出被放回了待审查)。workflow不做去重检测,会在已有修订上再跑一遍,导致:
- 文字重复(如"全部损失全部损失")
- 相同内容被双重标记为INS(冗余修订痕迹)
### 检测方法
修复/审查前**第一步**:检查待审查文件是否已有author=WB的tracked changes
```python
for ins in body.iter(f'{WNS}ins'):
if ins.get(f'{WNS}author') == 'WB':
# 文件已被处理过!
```
### 正确做法
如果待审查文件已有WB修订:
1. **以待审查版为基底**(它的第一轮修订是正确的)
2. 只在此基础上补充缺失的修订(如名称统一)
3. **不使用任务交付目录的二次处理版本**(它有重复)
4. 排查是否是auto_notify重复触发或手动误操作导致
@@ -0,0 +1,83 @@
# 审查意见文档生成模式(2026-07-02 确立)
## 核心原则
1. **只体现差异,不做理由说明** — 表格三列(条文|原文|修订后)只写文字差异
2. **字体必须显式设置** — 不依赖模板继承,每个run四属性齐全
3. **同模板合同内容必须一致** — 行顺序按条款号,模板级修订表述相同
## 字体规则
```python
def set_cell_font(cell, text, east_asia='仿宋', ascii_font='Times New Roman', h_ansi='Times New Roman', bold=False):
"""Set cell text with proper font - every run must have explicit rFonts"""
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
# Clear existing content
for p in cell.paragraphs[1:]:
cell._element.remove(p._element)
p = cell.paragraphs[0]
for r in p._element.findall(qn('w:r')):
p._element.remove(r)
# Set paragraph alignment to justify
pPr = p._element.find(qn('w:pPr'))
if pPr is None:
pPr = OxmlElement('w:pPr')
p._element.insert(0, pPr)
jc = pPr.find(qn('w:jc'))
if jc is None:
jc = OxmlElement('w:jc')
pPr.append(jc)
jc.set(qn('w:val'), 'both')
# Add run with explicit font settings
run = p.add_run(text)
rPr = run._element.find(qn('w:rPr'))
if rPr is None:
rPr = OxmlElement('w:rPr')
run._element.insert(0, rPr)
rFonts = OxmlElement('w:rFonts')
rFonts.set(qn('w:eastAsia'), east_asia)
rFonts.set(qn('w:ascii'), ascii_font)
rFonts.set(qn('w:hAnsi'), h_ansi)
rPr.insert(0, rFonts)
if bold:
b = OxmlElement('w:b')
rPr.append(b)
```
## 内容格式
### ✅ 正确(只体现差异)
| 条文 | 原文 | 修订后 |
|------|------|--------|
| 第1条 | 买方同意向卖方购买,同时卖方同意授予买方以下器械 | 甲方同意向乙方购买,同时乙方同意向甲方出售以下器械 |
| 第7.1.2条 | 按照器械的疵劣程度 | 按照器械的瑕疵程度 |
### ❌ 错误(带理由说明)
| 条文 | 原文 | 修订后 |
|------|------|--------|
| 第1条 | ... | 甲方同意向乙方购买……(注:统一称谓为甲方/乙方,"授予"修改为"出售"以准确反映买卖关系) |
## 同模板合同一致性保证
当同一顾问单位有多份同模板合同时:
1. 先确定模板级修订点列表(所有同模板合同共享的问题)
2. 每份合同的审查意见必须包含**全部**模板级修订点
3. 行顺序统一按条款号排列
4. 个案差异(如金额问题)在统一行之外单独加行
5. 修订后列的文字必须完全一致(逐字对比)
## 验证清单
生成完成后必须验证:
- [ ] 所有数据行的每个run都有eastAsia=仿宋 + ascii/hAnsi=Times New Roman
- [ ] 修订后列无(注:...)、无理由解释
- [ ] 行顺序按条款号排列
- [ ] 同模板合同的审查意见行数一致(除个案差异行外)
- [ ] 标题包含合同全称
@@ -0,0 +1,64 @@
# 审查意见文档生成规则(朱家角模板)
## 2026-07-02 Doro多次纠正后确立
### 模板结构
路径:`~/.hermes/shared/模版库/朱家角 审查意见【模板】.docx`
(新模板参考:`Doro合同审查任务/参考文件/朱家角 审查意见【新模板】.docx`
1. 空行(P0, 居中)
2. 标题:`关于《XX》的审查意见`(居中、仿宋 **16pt**(sz=32) **加粗**
3. `无法律修改意见。`(有审查意见时**删除此行**)
4. `审查意见:`(仿宋 12pt)
5. 表格(条文|原文|修订后)
6. 空行
7. 签名:`邱庭 律师`(右对齐、仿宋 12pt)
### 字体规格(从模板XML实际读取,非推测)
- 标题:eastAsia=仿宋, sz=32(16pt), bold=True, **ascii=None**(模板未设)
- 表头行:eastAsia=仿宋, sz=24(12pt), bold=True
- 数据行:eastAsia=仿宋, ascii=Times New Roman, hAnsi=Times New Roman, sz=24(12pt), bold=False, hint=eastAsia
- 正文段:eastAsia=仿宋, sz=24(12pt)
⚠️ 模板本身只设了`eastAsia=仿宋`没有设`ascii`。按review-rules.md要求英文用TNR,生成时应显式设ascii/hAnsi=Times New Roman。
### 文档格式处理(2026-07-12 Doro要求)
- **删除空白行**:文档中所有无内容的空段落必须删除(模板自带的空行也删)
- **页眉日期改为修订当日**:header*.xml中如有日期(如"2019/3"),改为当日日期(如"2026/7")。注意日期可能被拆分为多个run(如"201"+"9"+"/"+"3"),需逐run处理
- **标题用合同正文中的实际标题**:从合同docx正文提取合同全称(如"医疗设备器械购销合同"),填入《》内。绝不用文件名代替(文件名可能是简称如"医疗合同(2)")
- **标题格式**:`关于《XX合同全称》的审查意见`,确保无多余占位符残留
### 内容规则(2026-07-02 Doro明确)
- **只写原文和修订后的内容(包括批注内容),不做理由说明**
- ❌ 不写 (注:统一称谓…)
- ❌ 不写 (注:原引用法规…)
- ✅ 条文列:第X条 / 第X.X条 / 新增X.X条(主题)
- ✅ 原文列:合同原文
- ✅ 修订后列:修订后文字 / 批注内容(如"请注意确认金额")
- 行顺序按条款号排列
- 新增条款:条文栏写"新增X.X条(主题)",修订后栏直接写条文内容
### 同模板合同的审查意见统一规则
- 格式、字体、行顺序统一
- 共有修订行内容完全相同
- 个案差异行(如金额批注)按各合同实际情况处理
- 没有问题的合同不要强加批注行
### 生成代码要点
```python
# 字体设置(数据行)
def set_run_font(run_elem, east_asia='仿宋', ascii_font='Times New Roman', h_ansi='Times New Roman', sz_val=24):
rFonts.set(qn('w:eastAsia'), east_asia)
rFonts.set(qn('w:ascii'), ascii_font)
rFonts.set(qn('w:hAnsi'), h_ansi)
rFonts.set(qn('w:hint'), 'eastAsia')
sz.set(qn('w:val'), str(sz_val)) # 24 = 12pt
szCs.set(qn('w:val'), str(sz_val))
```
### 常见错误(本session犯过的)
1. 未设sz_val → 回退到默认字号
2. ascii设成仿宋 → 英文也变仿宋
3. 用bytes literal写中文 → unicode转义不解析显示乱码
4. 忘删"无法律修改意见。" → 矛盾
5. 写(注:...)理由 → 规则禁止
@@ -0,0 +1,32 @@
# 同模板合同审查一致性规则
## 2026-07-02 朱家角恭兴+肃言合同教训
### 问题
两份同模板购销合同(结构完全一致,仅乙方名称和设备清单不同),workflow串行审查后修订不一致:
- 恭兴发现了6.4条(药监局法规过时)但遗漏7.3条(侵权兜底)
- 肃言发现了7.3条但遗漏6.4条
### 根因
workflow是逐份串行处理(relay-runner),每份合同完全独立走reviewer→editor→deliverer,各session之间零状态共享。LLM每次独立推理,对同一段文字的优先级判断有随机性。
### 解决办法(已实施)
1. **review-rules.md增加同模板一致性规则**(已做)
2. **reviewer skill增加前置检查**:审查前检查同目录是否有同模板已审查的合同,对齐修订点
3. **手动审查时的铁律**:同批同模板合同必须先审完一份→确认修订点→后续合同按同样标准执行
### 判断"同模板"的方法
- 合同标题完全相同
- 正文条款结构一致(前20行匹配度>80%)
- 甲方相同,乙方不同
- 区别仅在商业条款(金额、设备清单、乙方信息等)
### 审查意见的统一要求
- 共有修订行:内容必须完全一致
- 行顺序:按条款号排列
- 个案差异:只有真实存在的问题才加行(如恭兴金额有误加批注行,肃言金额正确则不加)
- 格式:字体/字号/对齐统一
### 批注的统一原则
"统一"是审查逻辑统一,不是机械复制。金额有问题的合同加批注,没问题的不加——逻辑一致即可。不能给正确的合同强加问题批注。
@@ -0,0 +1,189 @@
# Same-Template Revision Transfer (同模板修订参照)
When Doro says "参照X合同的修订进行修订" — apply the same WB revisions from a reference contract to another contract using the same template.
## ⚠️ Doro 强制验证纪律(2026-07-08 明确要求)
Doro 明确要求做同模板修订参照时必须走完以下步骤,缺一不可:
1. **先确认模板一致性**:逐段对比两份合同原文(去掉修订后的文本),确认段落数一致、差异仅限业务内容(项目名称、单价等),其余结构完全相同。打印差异段数/总段数(如"9/62段有差异")。
2. **参照修订**:提取参照合同的WB修订→适配目标合同业务语境→应用
3. **格式/字体/编号全检**:所有INS的rPr必须与前后邻居run一致(逐个检查sz/rFonts/bold)。遵守workflow规则(author=WB、精准到字、不整段del+ins)
4. **全文阅读审查合理性**:渲染accept后全文,逐段通读确认修订逻辑合理、不破坏上下文语义
5. **交付前检查**:python-docx可打开、无异常字符、修订数与参照合同一致、他人修订保持不动
**不能跳步直接做修订然后上传。** Doro原话:"你先确认:两个合同是不是模板一样,内容一样;如果一样,参照修改;你修改的格式、字体、编号等,都要遵守workflow的规则;全文阅读,修订是否合理。最后检查交付。"
## Workflow
### Step 1: Extract revisions from reference contract
```python
import zipfile
from lxml import etree
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
def extract_wb_revisions(filepath):
"""Extract all WB-authored INS and DEL from a contract."""
with zipfile.ZipFile(filepath, 'r') as z:
content = z.read('word/document.xml')
tree = etree.fromstring(content)
revisions = []
for ins in tree.iter(f'{{{ns}}}ins'):
if ins.get(f'{{{ns}}}author') != 'WB':
continue
texts = [t.text for t in ins.iter(f'{{{ns}}}t') if t.text]
# Get parent paragraph for context
parent_p = ins
while parent_p is not None and parent_p.tag != f'{{{ns}}}p':
parent_p = parent_p.getparent()
p_texts = [t.text for t in parent_p.iter(f'{{{ns}}}t') if t.text] if parent_p is not None else []
revisions.append({
'type': 'INS', 'text': ''.join(texts),
'para_context': ''.join(p_texts)[:120]
})
for d in tree.iter(f'{{{ns}}}del'):
if d.get(f'{{{ns}}}author') != 'WB':
continue
texts = [t.text for t in d.iter(f'{{{ns}}}delText') if t.text]
parent_p = d
while parent_p is not None and parent_p.tag != f'{{{ns}}}p':
parent_p = parent_p.getparent()
p_texts = [t.text for t in parent_p.iter(f'{{{ns}}}t') if t.text] if parent_p is not None else []
revisions.append({
'type': 'DEL', 'text': ''.join(texts),
'para_context': ''.join(p_texts)[:120]
})
return revisions
```
### Step 2: Identify modification patterns
Group INS/DEL pairs by paragraph context to understand what was changed:
- Simple text replacement: DEL "协议" + INS "合同" in same paragraph
- Text insertion: INS without corresponding DEL (e.g., data ownership sentence)
- Prefix insertion: INS "上海市" before existing text
### Step 3: Context adaptation
When the template is shared but service content differs, adapt context-specific terms:
- "体检服务" → "口腔检查服务"
- "学生个人信息、健康检查结果" → "个人信息、检查结果"
- Keep legal boilerplate identical (e.g., "归甲方所有", "合同期满")
### Step 4: Apply to target contract (zipfile+lxml)
Use three operations:
#### A. Tracked replace (DEL old + INS new)
```python
def do_tracked_replace(para, find_text, replace_text):
"""Find text in paragraph runs, create DEL + INS."""
# Build character map from runs (skip ins/del elements)
char_map = []
for elem in para:
if elem.tag == f'{{{ns}}}r':
t = elem.find(f'{{{ns}}}t')
if t is not None and t.text:
for ci in range(len(t.text)):
char_map.append((elem, t, ci))
full_text = ''.join(cm[1].text[cm[2]] for cm in char_map)
pos = full_text.find(find_text)
if pos == -1:
return False
# Verify single-run containment, then split into before/DEL/INS/after
# ... (see session code for full implementation)
```
#### B. Insert before text
```python
def do_tracked_insert_before(para, anchor_text, insert_text):
"""Insert INS element right before anchor_text."""
for elem in para:
if elem.tag == f'{{{ns}}}r':
t = elem.find(f'{{{ns}}}t')
if t is not None and t.text and anchor_text in t.text:
# Split run, insert INS before anchor portion
...
```
#### C. Insert after text
```python
def do_tracked_insert_after(para, anchor_text, insert_text):
"""Insert INS element right after anchor_text."""
for elem in para:
if elem.tag == f'{{{ns}}}r':
t = elem.find(f'{{{ns}}}t')
if t is not None and t.text and anchor_text in t.text:
# Split run, insert INS after anchor portion
...
```
## Data Attribution Rule (2026-07-08 Doro clarification)
When writing data ownership/attribution clauses across same-template contracts:
**LOCKED (identical across all contracts)**: 归属表述 = "归甲方或相关权利方所有"
- NOT "归甲方所有" (excludes data subjects' rights under PIPL)
- The phrase "甲方或相关权利方" covers both: data甲方 owns (aggregated stats, service outputs) AND personal info that belongs to data subjects
**NOT LOCKED (varies by contract)**: The descriptive content before the attribution phrase
- 体检合同: "乙方在提供体检服务过程中获取和产生的全部数据(包括但不限于学生个人信息、健康检查结果等)"
- 口腔检查合同: "乙方在提供口腔检查服务过程中获取和产生的全部数据(包括但不限于个人信息、检查结果等)"
- Other contracts: adapt to the specific service/data context
**Doro原话**: "我只需要涉及到数据权利的归属时,把归属谁改成'归甲方或相关权利方所有',其他的内容不同合同会不同,所以你不能写死。"
**Rule source**: `review-rules.md` §4 保密/数据 (updated 2026-07-08)
## Pitfalls
### 1. `<w:proofErr>` splits runs
Text like "青浦区练塘镇" may be split into multiple runs separated by `<w:proofErr>` elements:
```xml
<w:r><w:t>青浦区练塘</w:t></w:r>
<w:proofErr w:type="gramStart"/>
<w:r><w:t>镇社区卫生服务中心</w:t></w:r>
```
**Fix**: Search at individual run level (`for elem in para: if elem.tag == w:r`), not at full paragraph text level. Insert INS before the run containing the anchor, not at a text position within full paragraph text.
### 2. "达成如下协议" — not all "协议" should be replaced
In the reference contract, "达成如下协议:" was NOT changed (it's a formulaic expression meaning "reached the following agreement"). Only contextual uses of "协议" meaning "this agreement/contract" were changed to "合同"/"本合同".
**Rule**: Compare reference contract's accepted text to determine which instances were changed and which were left alone.
### 3. INS rPr must clone from target run (not reference)
The target contract's runs may have different formatting than the reference. Always clone rPr from the **target paragraph's existing run**, not from the reference contract.
### 4. Order of operations matters
Do replacements BEFORE insertions. Insertions change paragraph structure and character positions, which can break subsequent text searches.
Recommended order:
1. All `do_tracked_replace` calls (these only split existing runs)
2. All `do_tracked_insert_after` / `do_tracked_insert_before` calls (these add new elements)
### 5. Verify with accept-all view
After applying all revisions, verify by building accepted text (skip DEL, include INS) for key paragraphs and comparing against the reference contract's accepted text.
## 2026-07-08 实证:练塘口腔检查合同
Reference: 【修】2026年学生体检外包合同--练塘(1).docx
Target: 2026年口腔检查外包合同--练塘.docx
Modifications applied:
| # | Type | Content | Adaptation |
|---|------|---------|------------|
| 1 | INS before "青浦区" | "上海市" | None (identical) |
| 2a | INS after "保密义务。" | Data ownership sentence | "体检服务"→"口腔检查服务", "学生个人信息、健康检查结果"→"个人信息、检查结果" |
| 2b | DEL "协议" + INS "合同" | "协议期满"→"合同期满" | None |
| 2c | DEL "服务协议" + INS "本合同" | "服务协议解除"→"本合同解除" | None |
| 2d | DEL "本协议" + INS "本合同" | "本协议的履行"→"本合同的履行" | None |
| 3 | DEL "本协议" + INS "本合同" | "本协议一式"→"本合同一式" | None |
proofErr pitfall encountered: "青浦区练塘" split by `<w:proofErr>` — had to insert at run level rather than text-position level.
@@ -0,0 +1,32 @@
# 单段正文章节不加numPr(2026-07-12 盈浦健康科普合同)
## 规则
当新增的章节(如"八、转包与分包")下只有**一段**正文时,该段落**不设numPr**。
## 判断方法
1. 看原文中同样只有一段正文的章节是否有numPr
2. 如果原文单段章节无numPr(如"一、合作背景"P12无numPr),新增也不加
3. 多段正文章节有numPr(如"七、不可抗力"两段都有numId=11)
4. 这是"有2才有1"规则在numPr层面的体现
## 实证
盈浦健康科普服务合同:
- 原文"一、合作背景": 1段正文 → 无numPr
- 原文"七、不可抗力": 2段正文 → numId=11
- 新增"八、转包与分包": 1段正文 → 不应有numPr
错误地加了numId=14(新建abstractNum),导致OnlyOffice渲染出孤零零的"1."
## 同时要检查的段落格式
新增段落的pPr必须与原文同类型段落**完整匹配**:
- `w:ind`(firstLine/firstLineChars)—— 首行缩进
- `w:spacing`(line/lineRule)
- 不能只有spacing没有ind
实证:原文正文段有 `ind firstLine=420 firstLineChars=200`,workflow新增P75只有spacing缺ind → 渲染无首行缩进。
## heading run的sz继承陷阱
原文Heading 1样式定义sz=48(24pt),heading段落的plain run**没有显式sz**(靠样式继承)。
workflow/ContractEditor操作后可能给某个run添加spurious `sz=20`(从szCs误取),导致该run从24pt变成10pt。
检查:修订后Heading段落的所有plain run不应有新增的显式sz。
@@ -0,0 +1,175 @@
# 拆分合并的标题+正文段落为两个独立INS段落
## 场景
Reviewer发现新增条款的标题和正文被合并在一个`<w:p>`段落中(通过`<w:t>`内的换行符分隔),要求拆分为两个独立段落——标题段和正文段,各有独立的格式。
## 判别
- 目标段落是一个`<w:p>`,内含一个`<w:ins author="WB">``<w:ins>`内只有一个`<w:r>``<w:t>`文本包含换行符(`\n`)分隔标题和正文
- 标题格式要求:参照原文同级标题段落(如"第四条"或"第六条")
- 正文格式要求:参照原文同层级正文段落(如"一、施工期限")
## 操作步骤
### 1. 读取原文并定位目标段落
```python
import zipfile
from lxml import etree
import copy
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
with zipfile.ZipFile(docx_path, 'r') as zf:
doc_xml = etree.parse(zf.open('word/document.xml'))
all_files = {name: zf.read(name) for name in zf.namelist()}
body = doc_xml.getroot().find(f'{{{W}}}body')
paragraphs = list(body.findall(f'{{{W}}}p'))
# 找到目标段落
for i, p in enumerate(paragraphs):
texts = []
for elem in p.iter(f'{{{W}}}t'):
texts.append(elem.text or '')
full_text = ''.join(texts)
if '第五条' in full_text and '转包' in full_text:
target_idx = i
break
```
### 2. 提取标题和正文文本
```python
full_text = ''
for elem in target_p.iter(f'{{{W}}}t'):
full_text += elem.text or ''
lines = full_text.split('\n')
title_text = lines[0].strip() # "第五条 转包与分包"
body_text = '\n'.join(lines[1:]).strip() # 正文内容
```
### 3. 找到参照段落并克隆pPr
标题段pPr从紧邻的同级标题段落克隆(如"第六条"),正文段pPr从同层级正文段落克隆(如"一、施工期限")。
```python
# 标题参照段落(如"第六条")
ref_title_p = paragraphs[24] # 原文"第六条"的索引
ref_title_pPr = ref_title_p.find(f'{{{W}}}pPr')
title_pPr = copy.deepcopy(ref_title_pPr)
# 正文参照段落(如"一、施工期限")
ref_body_p = paragraphs[13] # 原文"一、施工期限"的索引
ref_body_pPr = ref_body_p.find(f'{{{W}}}pPr')
body_pPr = copy.deepcopy(ref_body_pPr)
```
### 4. 构建标题段落
```python
title_p = etree.Element(f'{{{W}}}p', nsmap=target_p.nsmap)
title_p.append(title_pPr)
# 标题rPr:黑体四属性 + hint=eastAsia + sz=24 + bold
title_rPr = etree.Element(f'{{{W}}}rPr')
rFonts = etree.SubElement(title_rPr, f'{{{W}}}rFonts')
for attr in ['ascii', 'hAnsi', 'eastAsia', 'cs']:
rFonts.set(f'{{{W}}}{attr}', '黑体')
rFonts.set(f'{{{W}}}hint', 'eastAsia')
etree.SubElement(title_rPr, f'{{{W}}}spacing').set(f'{{{W}}}val', '-6')
etree.SubElement(title_rPr, f'{{{W}}}sz').set(f'{{{W}}}val', '24')
etree.SubElement(title_rPr, f'{{{W}}}szCs').set(f'{{{W}}}val', '24')
etree.SubElement(title_rPr, f'{{{W}}}b') # 加粗
title_ins = etree.SubElement(title_p, f'{{{W}}}ins')
title_ins.set(f'{{{W}}}id', str(new_ins_id))
title_ins.set(f'{{{W}}}author', 'WB')
title_ins.set(f'{{{W}}}date', '2026-06-26T14:00:00Z')
title_r = etree.SubElement(title_ins, f'{{{W}}}r')
title_r.set(f'{{{W}}}rsidR', '00AA0001')
title_r.append(copy.deepcopy(title_rPr))
title_t = etree.SubElement(title_r, f'{{{W}}}t')
title_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
title_t.text = title_text
```
### 5. 构建正文段落
```python
body_p = etree.Element(f'{{{W}}}p', nsmap=target_p.nsmap)
body_p.append(body_pPr)
# 正文rPr:宋体四属性 + hint=eastAsia + sz=21
body_rPr = etree.Element(f'{{{W}}}rPr')
body_rFonts = etree.SubElement(body_rPr, f'{{{W}}}rFonts')
for attr in ['ascii', 'hAnsi', 'eastAsia', 'cs']:
body_rFonts.set(f'{{{W}}}{attr}', '宋体')
body_rFonts.set(f'{{{W}}}hint', 'eastAsia')
etree.SubElement(body_rPr, f'{{{W}}}spacing').set(f'{{{W}}}val', '-4')
etree.SubElement(body_rPr, f'{{{W}}}sz').set(f'{{{W}}}val', '21')
body_ins = etree.SubElement(body_p, f'{{{W}}}ins')
body_ins.set(f'{{{W}}}id', str(new_ins_id + 1))
body_ins.set(f'{{{W}}}author', 'WB')
body_ins.set(f'{{{W}}}date', '2026-06-26T14:00:00Z')
body_r = etree.SubElement(body_ins, f'{{{W}}}r')
body_r.set(f'{{{W}}}rsidR', '00AA0001')
body_r.append(copy.deepcopy(body_rPr))
body_t = etree.SubElement(body_r, f'{{{W}}}t')
body_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
body_t.text = body_text
```
### 6. 插入并删除原段落(⚠️ addprevious顺序陷阱)
**关键**`addprevious`将元素插入到目标元素的**紧邻前一个**位置。要得到 [title, body, old_p] 的顺序,必须:
```python
old_p = paragraphs[target_idx]
old_p.addprevious(body_p) # 先插入body → 顺序: body, old_p
body_p.addprevious(title_p) # 再在body前插入title → 顺序: title, body, old_p
body.remove(old_p) # 删除原段落 → 顺序: title, body, ...
```
**错误做法**(会导致顺序反转):
```python
# ❌ 错误:title在body之后
old_p.addprevious(title_p) # title, old_p
old_p.addprevious(body_p) # title, body, old_p ← 看起来对但实际是 body, title, old_p
```
原理:`addprevious`始终插入到目标元素的紧邻前一个位置。`old_p.addprevious(body_p)` 后 body_p 是 old_p 的前一个兄弟;`old_p.addprevious(title_p)` 后 title_p 成为 old_p 的前一个兄弟,body_p 被推到 title_p 之前。
### 7. 保存
```python
new_doc_xml = etree.tostring(doc_xml.getroot(), xml_declaration=True, encoding='UTF-8', standalone=True)
with zipfile.ZipFile(docx_path, 'w', zipfile.ZIP_DEFLATED) as zf_out:
for name, data in all_files.items():
if name == 'word/document.xml':
zf_out.writestr(name, new_doc_xml)
else:
zf_out.writestr(name, data)
```
## 验证
1. **段落顺序**:确认 [title_idx] 是标题文本,[title_idx+1] 是正文文本
2. **字体属性**:标题 rPr 含 rFonts四属性(黑体) + hint=eastAsia + sz=24 + bold;正文 rPr 含 rFonts四属性(宋体) + hint=eastAsia + sz=21
3. **INS属性**:author=WB, 有 rsidR, 有唯一id
4. **OnlyOffice渲染**:x2t渲染为PDF,pdftotext确认标题和正文各占一行,正文有缩进
5. **validate()**:运行ContractEditor的validate(),区分预存误报和本轮新增问题
## 注意事项
- 标题和正文的pPr应从**紧邻的原文同级段落**克隆,而非从目标段落自身克隆
- rFonts必须设置四属性(ascii, hAnsi, eastAsia, cs),仅设hint=eastAsia是不够的
- 标题的bold属性按照reviewer的指令设置(注意:原文标题可能不加粗,但reviewer可能要求加粗)
- 两个INS段落使用不同的id(从文档中max_ins_id+1开始递增)
- 操作前先备份原文件
@@ -0,0 +1,60 @@
# Split-Run Numbering in docx XML
## Problem
Contract numbering like `(5)` is often split across multiple `<w:r>` runs in the XML:
```xml
<w:r><w:t></w:t></w:r>
<w:r><w:t>5</w:t></w:r>
<w:r><w:t>)委托方</w:t></w:r>
```
A naive `tracked_replace("(5)", "(6)")` searching for the complete string in a single `<w:t>` will **silently fail** — no match, no error, no renumbering.
## Solution: Multi-run concatenation + split
### Algorithm
```python
def tracked_replace_split_number(p, old_num, new_num):
"""Handle (old_num) spread across multiple runs."""
target = f'{old_num}'
new_target = f'{new_num}'
# 1. Collect all plain runs (not inside w:ins or w:del)
plain_runs = [(index, run, text) for each child of p]
# 2. Slide a window: concatenate adjacent run texts until target is found
for start in range(len(plain_runs)):
concat = ""
for end in range(start, start+4): # max 4 runs for a number
concat += plain_runs[end].text
if target in concat:
# Found! Extract before/after text around the number
runs_to_wrap = plain_runs[start:end+1]
# ...proceed to replace
# 3. Remove original runs, insert:
# - [before_run if text before number]
# - DEL element with delText=target
# - INS element with t=new_target
# - [after_run if text after number, e.g. "委托方"]
# 4. Set rsid attributes: rsidDel on DEL runs, rsidR on INS runs
```
### Critical: Process order
**Always renumber from bottom to top** (last paragraph first) to avoid index shifting:
```python
# CORRECT
renumber = [(P113, '10', '11'), (P112, '9', '10'), (P111, '7', '8')]
# WRONG - P112 was already renumbered when we get to it
renumber = [(P111, '7', '8'), (P112, '9', '10'), (P113, '10', '11')]
```
### Edge cases encountered (2026-06-08)
- `(` + `10)` (two runs, not three) — the closing `)` merged with the digit
- `(` + `5` + `)委托方` — closing `)` merged with following text, must split run to preserve "委托方"
- Copy `w:rPr` from original runs to all new DEL/INS runs to preserve font/size
## Lesson
This was the root cause of a terminal review failure where 3 new clauses were inserted without numbering, and subsequent numbering was not renumbered. The `tracked_replace` function matched nothing because it expected `(5)` as a single text node.
@@ -0,0 +1,87 @@
# Standalone Char-Level Tracked Changes + Comments (non-workflow)
When modifying contracts **outside** the Doro/邱律师 workflow (e.g. Maggie directly asks to revise a client's agreement), the full `ContractEditor` library + review-rules machinery is overkill. Use this lightweight pattern instead.
## When to use
- Maggie sends a contract and says "帮我改一下" / "修改这份协议"
- No workflow, no reviewer, no deliverer — just direct revision
- Still must produce Word-native tracked changes (del/ins) + comments
## Core technique: `difflib.SequenceMatcher` char-level diff
```python
import difflib
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
def char_level_replace(para, new_text, author="WB", date="2026-07-07T10:00:00Z"):
"""Replace paragraph text with char-level tracked changes.
Unchanged chars → normal w:r (preserved).
Deleted chars → w:del + w:delText.
Inserted chars → w:ins + w:t.
"""
p = para._element
old_text = para.text
if old_text == new_text:
return
# Remove existing runs (preserve pPr)
for child in list(p):
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag in ('r', 'ins', 'del', 'hyperlink'):
p.remove(child)
sm = difflib.SequenceMatcher(None, old_text, new_text)
for op, i1, i2, j1, j2 in sm.get_opcodes():
if op == 'equal':
p.append(make_run(old_text[i1:i2]))
elif op == 'delete':
p.append(make_del_run(old_text[i1:i2], author, date))
elif op == 'insert':
p.append(make_ins_run(new_text[j1:j2], author, date))
elif op == 'replace':
p.append(make_del_run(old_text[i1:i2], author, date))
p.append(make_ins_run(new_text[j1:j2], author, date))
```
## Comments injection (bypassing python-docx limitations)
python-docx has no native comment support. Inject manually:
1. Add `commentRangeStart` + `commentRangeEnd` + `commentReference` run to target paragraph
2. Build `word/comments.xml` as a plain string (proper namespace, no lxml serialization quirks)
3. Inject into the docx ZIP: update `[Content_Types].xml` + `word/_rels/document.xml.rels`
### Critical: comments.xml namespace
**Wrong** (causes "reuse of xmlns" error):
```python
comments_xml = etree.Element(qn('w:comments'))
comments_xml.set(qn('xmlns:w'), WNS) # ❌ double declaration
```
**Right** (build as plain string):
```python
def build_comments_xml(comments_list):
lines = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>']
lines.append('<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'
' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">')
for cid, text in comments_list:
safe = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
lines.append(f' <w:comment w:id="{cid}" w:author="WB" w:date="..." w:initials="WB">')
lines.append(f' <w:p><w:r><w:t>{safe}</w:t></w:r></w:p>')
lines.append(f' </w:comment>')
lines.append('</w:comments>')
return "\n".join(lines)
```
## Pitfalls learned (2026-07-07 退休返聘案)
1. **lxml etree serialization breaks Word**: `etree.tostring()` produces `xmlns:ns0=...` prefix notation that Word/OnlyOffice cannot parse. Always build comments.xml as a plain string.
2. **Entire-paragraph del+ins is unacceptable**: Maggie and Doro both require char-level precision. "原文相同的部分保留,不一样的用修订" — this is non-negotiable.
3. **New paragraphs (fully inserted)**: Use `pPr/rPr/ins` mark to flag the ¶ itself as inserted, plus `w:ins` wrapping the text run. Both are needed for Word to show the full paragraph as tracked insertion.
4. **Verify files open correctly**: After save, always `Document(path)` to confirm no XML parse errors.
## Template (full working script structure)
See `/tmp/modify_v3_charlevel.py` from the 2026-07-07 session — processes two contracts (full-time + part-time) with char-level diff + comments injection. Pattern: `process_contract(input, output, is_fulltime=bool)`.
@@ -0,0 +1,93 @@
# Strip numPr Before Inserting Manual Numbering (2026-07-01)
## Problem
When adding manual numbering (e.g. "第一条 ") as `w:ins` at the beginning of a paragraph that already has `<w:numPr>` (automatic numbering like "%1." decimal format), OnlyOffice renders BOTH:
- The automatic number: "1."
- The manual INS text: "第一条"
Result: "1. 第一条 乙方应严格按照..."
## Root Cause
`<w:numPr>` in pPr tells the rendering engine to prepend an auto-generated number. The `w:ins` text is just another run in the paragraph — it doesn't suppress the auto-numbering.
Additionally, `<w:pPrChange>` records the pre-revision pPr state. If pPrChange still contains `<w:numPr>`, some renderers will show the old numbering in markup view.
## Affected Scenarios
1. **反委托代发工资协议 (2026-07-01)**: Original paragraphs P6-P12, P19 had `numId=1` or `numId=3` (decimal "%1." format). After adding "第一条" through "第十一条" as INS, OnlyOffice showed "1. 第一条", "2. 第二条", etc.
2. **Any contract where the original used auto-numbering**: Check `numbering.xml` for active numId references with `numFmt=decimal` or `numFmt=chineseCounting`.
## Fix Pattern
```python
import zipfile
from lxml import etree
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
with zipfile.ZipFile(docx_path, 'r') as z:
all_files = {name: z.read(name) for name in z.namelist()}
doc = etree.fromstring(all_files['word/document.xml'])
body = doc.find(f'{WNS}body')
paras = body.findall(f'{WNS}p')
# Identify paragraphs where we added manual numbering INS
# These are paragraphs that have both:
# 1. A w:ins with author=WB containing "第X条" text
# 2. A pPr with numPr
for i, p in enumerate(paras):
ppr = p.find(f'{WNS}pPr')
if ppr is None:
continue
# Check if this paragraph has our manual numbering INS
has_manual_numbering = False
for child in p:
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == 'ins' and child.get(f'{WNS}author') == 'WB':
text = ''.join(t.text for t in child.iter(f'{WNS}t') if t.text)
if '' in text and '' in text:
has_manual_numbering = True
break
if not has_manual_numbering:
continue
# Strip numPr from pPr
num_pr = ppr.find(f'{WNS}numPr')
if num_pr is not None:
ppr.remove(num_pr)
print(f"P{i}: Stripped numPr from pPr")
# Strip numPr from pPrChange
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)
print(f"P{i}: Stripped numPr from pPrChange")
# Save back
all_files['word/document.xml'] = etree.tostring(doc, xml_declaration=True, encoding='UTF-8', standalone=True)
# Write to temp file then replace (never write to same zip you're reading)
```
## Verification
After stripping, render with OnlyOffice and confirm:
1. Markup view shows only the manual numbering (no "1." prefix)
2. Accepted-revisions view shows clean "第一条" through "第十一条"
3. python-docx can still open the file without errors
## Edge Cases
- **DEL-only empty paragraphs** (e.g. P8, P9 where all content is w:del): These may still have numPr. If they render a visible "3." or "4." in the gap, strip those too.
- **Cross-paragraph clauses** (P6+P7 = one clause): P7 may have its own independent numPr even though it's a continuation paragraph. Strip it.
- **numId=0 (disabled numbering)**: `numId=0` in OOXML means "numbering OFF" — it doesn't render anything. Only strip numPr where `numId > 0` and the corresponding abstractNum has a visible numFmt (decimal, chineseCounting, etc.).
@@ -0,0 +1,121 @@
# Systematic File Recovery for Lost Author Markers
When intermediate files have been overwritten during iterative editing (e.g., multiple versions of a contract revision), and you need to find a specific version that contains tracked changes by a particular author (e.g., "华诚-Z"), use this systematic scan approach.
## Scenario
- You made multiple intermediate files (v1, v2, v3...) in `/tmp/` during contract editing
- You overwrote files, losing the version with a specific author's tracked changes
- You need to find ANY surviving file that still has that author's `w:author` attribute
## Recovery Technique
### Step 1: List all candidate files
Find all `.docx` files in the working directory that are newer than the original source file:
```bash
find /tmp -name '*.docx' -newer /tmp/original_file.docx 2>/dev/null | sort
```
### Step 2: Check each file for the target author
```python
import zipfile, re, os
from datetime import datetime
target_author = '华诚-Z' # or whatever author you're looking for
files = [
"/tmp/v1_clean.docx",
"/tmp/v1_final.docx",
# ... list all candidate files from Step 1
]
for f in files:
if not os.path.exists(f):
continue
try:
z = zipfile.ZipFile(f)
content = z.read('word/document.xml').decode('utf-8', 'ignore')
authors = set(re.findall(r'w:author="([^"]+)"', content))
mt = datetime.fromtimestamp(os.path.getmtime(f)).strftime('%m-%d %H:%M')
has_target = target_author in authors
marker = '' if has_target else ' '
print(f"{marker} {os.path.basename(f):35s} {mt} authors={sorted(authors)}")
z.close()
except Exception as e:
print(f" ERROR {f}: {e}")
```
### Step 3: Extract the target author's changes
Once you find the file with the target author, extract their specific tracked changes:
```python
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
z = zipfile.ZipFile('/tmp/file_with_target_author.docx')
with z.open('word/document.xml') as f:
tree = etree.parse(f)
root = tree.getroot()
body = root.find(f'{WNS}body')
paras = body.findall(f'{WNS}p')
for i, p in enumerate(paras):
has_target = False
parts = []
for child in p:
tag = etree.QName(child.tag).localname
if tag == 'r':
t = child.find(f'{WNS}t')
if t is not None and t.text:
parts.append(('RUN', t.text, None))
elif tag == 'ins':
author = child.get(f'{WNS}author', '?')
if target_author in author:
has_target = True
ins_texts = []
for r in child.findall(f'{WNS}r'):
t = r.find(f'{WNS}t')
if t is not None and t.text:
ins_texts.append(t.text)
if ins_texts:
parts.append(('INS', ''.join(ins_texts), author))
elif tag == 'del':
author = child.get(f'{WNS}author', '?')
if target_author in author:
has_target = True
del_texts = []
for r in child.findall(f'{WNS}r'):
t = r.find(f'{WNS}delText')
if t is not None and t.text:
del_texts.append(t.text)
if del_texts:
parts.append(('DEL', ''.join(del_texts), author))
if has_target:
print(f"\n★ P{i}:")
for kind, text, author in parts:
if kind == 'RUN':
print(f" [原文] {repr(text)}")
else:
print(f" [{kind} by {author}] {repr(text)}")
z.close()
```
## Empirical Case (2026-07-01 反委托代发工资协议)
- Made ~15 intermediate files in `/tmp/` during iterative editing
- Overwrote all files, changing all `w:author` attributes to "WB"
- User (Doro) demanded recovery of 华诚-Z's tracked changes
- Systematic scan found `/tmp/v1_doro_updated.docx` with `authors=['WB', '华诚-Z']`
- Extracted 华诚-Z's 3 specific changes:
- P6: INS "等" (between WB's "《劳务派遣暂行规定》" and "规定,")
- P12: INS "退回派遣员工" + INS "由乙方依法自行安置处理,与甲方无涉。"
## Key Pitfalls
1. **Don't assume the file is gone** — check ALL intermediate files, not just the ones you expect
2. **Check timestamps** — the file you need might be an early intermediate, not the latest
3. **Use `w:author` attribute** — this is the definitive marker, not file content or naming
4. **Comments may also be lost** — the recovered file might have lost some original comments (see `references/comment-restoration-from-original.md`)
## Prevention (Better Than Recovery)
The existing skill already covers this, but worth repeating: **改前必备份** — before modifying any file with third-party tracked changes, save a timestamped backup:
```bash
cp file_with_third_party.docx file_with_third_party.bak_$(date +%Y%m%d_%H%M%S).docx
```
@@ -0,0 +1,69 @@
# 表格单元格编辑:保持格式不被破坏
## 问题
编辑 docx 表格单元格中的文字时,两种常见错误做法都会破坏格式:
1. **`cell.paragraphs[0].clear()` + `add_run()`**:把多段结构压成一段,丢失加粗、字号、字体
2. **XML 层全 cell 文字重分片**:把修改后的文字均匀分配到所有 `w:t` 元素,破坏段落边界和编号
## 正确做法:段落级精确定位 + 只改目标段
```python
from docx import Document
from docx.shared import Pt
doc = Document('file.docx')
table = doc.tables[0]
cell = table.rows[7].cells[1]
# 1. 定位目标段落(按索引)
paras = cell.paragraphs
target_p = paras[4] # 例如 P4 是你要改的段落
# 2. 保存首 run 格式
first_run = target_p.runs[0]
saved = {
'name': first_run.font.name,
'size': first_run.font.size,
'bold': first_run.font.bold,
'italic': first_run.font.italic,
}
# 3. 文字替换
old_text = target_p.text
new_text = old_text.replace('要被替换的文字', '新文字')
# 4. 清空该段 → 重写(保留格式)
target_p.clear()
run = target_p.add_run(new_text)
run.font.name = saved['name']
run.font.size = saved['size']
run.font.bold = saved['bold']
run.font.italic = saved['italic']
# 5. 合并单元格:同步更新同行其他 cell
for col_idx in [2, 3]:
cell2 = table.rows[7].cells[col_idx]
p = cell2.paragraphs[4]
p.clear()
run = p.add_run(new_text)
run.font.name = saved['name']
run.font.size = saved['size']
doc.save('output.docx')
```
## 关键原则
- **不碰其他段落**:只改目标索引的段落,其余段落原封不动
- **不压多段为一段**:每个段落独立处理,保持 `P0/P1/P2/P3/P4` 结构不变
- **合并单元格全同步**:`row[7].cells[1]` 改了什么,`cells[2]``cells[3]` 也要同步
- **先读后改**:改前用 `cell.paragraphs[i].text` 确认内容,用 `cell.paragraphs[i].runs[0].font` 确认格式
## 本次教训
2026-06-26 预算绩效分析表:两轮都搞坏格式。
- 第一轮:`clear()` + `add_run()` 把 Row 7 的 P0-P10 多段结构压成一段
- 第二轮:XML 全 cell 文字重分片把 "2.成本核算分析" 变成 ".成本核算分析"(编号丢失)
- 第三轮(正确):定位到 P4(成本优化段),只改它,保留 P0-P3 不动
@@ -0,0 +1,65 @@
# tracked_replace 被 DEL 元素打断(2026-06-26 CT维保合同-香花桥实证)
## 症状
`tracked_replace(old, new)` 对跨 DEL 元素的文本静默失败(不报错但也不修改)。
## 根因
当匹配文本被拆成多个 run,且中间夹着 `<w:del>` 元素时,`tracked_replace` 无法跨元素边界匹配完整字符串。
## 实证
**场景**:原文"一 年"(中间有空格),需改为"一年"。
实际 XML 结构:
```xml
<w:r><w:t></w:t></w:r>
<w:del><w:r><w:delText> </w:delText></w:r></w:del>
<w:r><w:t></w:t></w:r>
```
`tracked_replace("一 年", "一年")` 无法匹配("一 年" 不连续存在于任何单一 run 中)。
## 修法
直接 zipfile+lxml 操作,移除 DEL 元素:
```python
for elem in list(paragraph):
if elem.tag.split('}')[-1] == 'del':
for t in elem.iter():
if t.tag == f'{{{W}}}delText' and t.text == ' ':
paragraph.remove(elem)
break
```
## 同类变体:INS 需插入在 DEL 之后
**场景**:原文"与济损失"("与"为错字),上一轮已将"与"包进 DEL,但未补 INS "经"。
实际 XML 结构:
```xml
<w:del><w:r><w:delText></w:delText></w:r></w:del>
<w:r><w:t>济损失的...</w:t></w:r>
```
`tracked_replace("与济损失", "经济损失")` 静默失败("与"在 DEL 内)。
**修法**:zipfile+lxml 在 DEL 元素后插入 INS:
```python
ins = etree.SubElement(paragraph, f'{{{W}}}ins')
ins.set(f'{{{W}}}id', str(new_id))
ins.set(f'{{{W}}}author', 'WB')
ins.set(f'{{{W}}}date', date_str)
del_elem.addnext(ins) # INS 紧跟在 DEL 之后
r = etree.SubElement(ins, f'{{{W}}}r')
r.append(copy.deepcopy(ref_rPr)) # 从同级 run 克隆 rPr
t = etree.SubElement(r, f'{{{W}}}t')
t.text = ''
```
## 判别
改前先遍历目标段落子元素,看是否有 `w:del``w:ins` 元素分割了匹配文本。有则不用 `tracked_replace`,改用 zipfile+lxml 直接操作。
@@ -0,0 +1,127 @@
# tracked_replace 跨 w:ins 元素失败的处理
## 症状
`tracked_replace(old, new)` 抛出 `ValueError: Element is not a child of this node`
发生在 `contract_docx_lib.py` 第368行 `parent.remove(runs[idx])`
## 根因
合同已经过上一轮 workflow 修订,原文段落中插入了 `w:ins(author="WB")` 元素。
目标匹配文本跨越了 `w:r``w:ins` 边界:
```
w:r: "...若甲方在双"
w:ins(author="WB"): "方"
w:r: "核对消费金额时未提出异议的..."
```
`tracked_replace``w:r``w:ins` 下的 `w:r` 都收集到 `runs` 列表,
`parent.remove(runs[idx])` 时,`w:ins` 内的 `w:r` 的 parent 是 `w:ins`,不是 `w:p` → 报错。
## 判别
修改前先遍历目标段落的子元素,看是否有 `w:ins` 分割了匹配文本:
```python
for elem in paragraph:
tag = elem.tag.split('}')[-1]
if tag in ('r', 'ins', 'del'):
print(f" {tag}: '{''.join(t.text or '' for t in elem.findall('.//{W}t'))}'")
```
## 修法:zipfile+lxml 直接操作
不用 `tracked_replace`,改用 zipfile+lxml 四步操作:
### 步骤1:裁掉第一段 run 中跨越的部分
```python
# 如:w:r 末尾是 "...核对确认,若甲方在双" → 裁掉 "若甲方在双"
assert r1_t.text.endswith('若甲方在双')
r1_t.text = r1_t.text[:-6] # 移除6个字符
```
### 步骤2:创建 DEL 包裹被裁掉的文字
```python
del_elem = etree.Element(qn('del'))
del_elem.set(qn('id'), str(next_del_id))
del_elem.set(qn('author'), 'WB')
del_elem.set(qn('date'), revision_date)
del_run = etree.SubElement(del_elem, qn('r'))
del_rpr = etree.SubElement(del_run, qn('rPr'))
# 从被裁 run 复制 rPr
for child in r1_elem.find(qn('rPr')):
del_rpr.append(copy.deepcopy(child))
del_text = etree.SubElement(del_run, qn('delText'))
del_text.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
del_text.text = '若甲方在双方' # 被裁文字 + w:ins 内容
```
### 步骤3:移除原来的 w:ins 元素
```python
p.remove(ins_elem)
```
### 步骤4:DEL旧文字 + INS新文字
```python
# DEL 旧文字(第三段 run 的完整内容)
del_elem2 = ... # 同上模式,delText = r3_t.text
# INS 新文字
ins_elem = etree.Element(qn('ins'))
ins_elem.set(qn('id'), str(next_ins_id))
ins_elem.set(qn('author'), 'WB')
ins_elem.set(qn('date'), revision_date)
ins_run = etree.SubElement(ins_elem, qn('r'))
# 从原 run 复制 rPr
ins_rpr = etree.SubElement(ins_run, qn('rPr'))
for child in r3_elem.find(qn('rPr')):
ins_rpr.append(copy.deepcopy(child))
ins_t = etree.SubElement(ins_run, qn('t'))
ins_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
ins_t.text = new_text
# 替换:在 r3 位置前插入 DEL 和 INS,再删除 r3
r3_pos = list(p).index(r3_elem)
p.insert(r3_pos, del_elem2)
p.insert(r3_pos + 1, ins_elem)
p.remove(r3_elem)
```
### 步骤5:写回
```python
doc_xml_modified = etree.tostring(tree, encoding='UTF-8', xml_declaration=True)
with zipfile.ZipFile(src, 'r') as zf:
file_data = {f: zf.read(f) for f in zf.namelist()}
with zipfile.ZipFile(src, 'w', zipfile.ZIP_DEFLATED) as zf:
for f, data in file_data.items():
zf.writestr(f, doc_xml_modified if f == 'word/document.xml' else data)
```
## 易错点
### 1. 中文切片长度
`[:-3]` 移除3个**字符**(不是字节)。"但本" = 2个中文字符 → 用 `[:-2]`
`[:-3]` 会多切一个字(如把";"也切掉)。
**⚠️ 全角标点也是1个字符(2026-06-26 健康积分兑换协议教训)**:`(四)` 是3个字符——`(`(U+FF08全角左括号=1字)、`四`(1字)、`)`(U+FF09全角右括号=1字)。用 `[4:]` 切片会多切掉1个中文字符,导致 DEL 文本缺字("甲方"→"方")。**判别**:数切片偏移时,全角括号/标点(()、【】、《》、。,!?等)每符1字,不因"看起来宽"就计为多个。切片前 `print(repr(text[:10]))` 确认边界。
### 2. 旧文末尾与新文开头重复
原文 run 裁掉部分后,末尾可能与 INS 新文字开头重复。
如:原文末尾是"核对确认",新文字开头也是"核对确认" → 出现"核对确认核对确认"。
**修法**:从 INS 新文字中去掉重复前缀。
### 3. 标点符号归属
裁掉 run 末尾文字时,确保标点符号(;。等)留在正确位置。
如原文"费用;但本"裁掉"但本"后应为"费用;"(保留分号)。
## 验证
1. `python-docx` 能打开(`Document(out)` 不抛异常)
2. `wb-ins-font-verify.py` 所有 WB INS 字体一致
3. 用 `ContractEditor.get_para_text()` 读段落文本,确认无重复/缺字
@@ -0,0 +1,67 @@
# 移植workflow修订到同名不同版本合同 (2026-07-08)
## 场景
邱律师同日发了两份同名文件(如"2026年华新镇公立中小学生健康体检服务合同.docx"),内容有实质差异(不同版本/条款)。第一份被workflow正常审查交付,第二份因queue-runner同名跳过逻辑被遗漏。Doro要求"把workflow第1份的修订内容直接修订到第2份里,但要注意相关修订在第2份里是否合理"。
## 操作步骤
### 1. 提取第1份的WB修订清单
从已交付文件提取所有 `author=WB``w:ins``w:del`
```python
from zipfile import ZipFile
from lxml import etree
with ZipFile(delivered_path, 'r') as z:
content = z.read('word/document.xml').decode('utf-8')
root = etree.fromstring(content.encode('utf-8'))
# 遍历所有 WB INS/DEL,记录:段落索引、INS文本、DEL文本、上下文
```
### 2. 对比两份合同差异
`difflib` 对比两版全文,定位哪些段落内容不同。特别关注:
- 修订涉及的段落在第2份中是否存在
- 如果存在,文本是否与第1份中的"修订前"文本一致
### 3. 逐条判断修订是否适用于第2份
| 修订类型 | 判断方法 |
|---------|---------|
| 术语统一(如"协议"→"合同") | 在第2份中搜索同一术语,存在则同样修改 |
| 新增保护条款(如数据归属、转包连带责任) | 检查第2份对应位置是否缺同样的保护,缺则加 |
| 金额/支付相关修订 | 第2份的支付条款可能完全不同(如本案),需独立判断是否需要新的修订 |
| 合同期限相关修订 | 第2份期限可能不同,独立判断 |
### 4. 对第2份执行修订
使用ContractEditor库,与正常审查相同流程:
```python
ed = ContractEditor(second_file)
ed.tracked_replace(old, new) # 逐条适用的修订
errors = ed.validate()
ed.save(output)
```
### 5. 字体验证 + 上传
- `wb-ins-font-verify.py` 必须PASS
- 上传替换NC任务交付目录中的同名文件
- 同时确保第2份原始文件在待审查目录
## 2026-07-08 华新镇体检合同实证
**两版差异:**
| 条款 | 第1份 (11:05) | 第2份 (16:04) |
|------|--------------|--------------|
| 项目内容 | "公立中小学生健康检查工作" | "华新镇公立中小学生健康体检工作" |
| 支付方式 | 按实际人数结算,无金额上限 | 按实际完成人数+考核表结算,费用上限17万 |
| 合同期限 | 9月10日起 | 9月1日起 |
**移植的修订(全部适用):**
1. 保密条款:数据归属+合同期满扩大+协议→合同统一 ✅ 第2份保密条款内容相同
2. 转包限制:增加甲方书面同意+连带责任 ✅ 第2份P44文本相同
3. 效力条款:本协议→本合同 ✅ 第2份P51文本相同
**不需要额外修订的原因:**
- 第2份的支付条款已更完善(有上限、有考核、有一次性付清约定)
- 违约责任、争议解决条款相同且已足够
## 注意事项
- 第2份被上传后会**替换**第1份的交付文件(同名),tracker中seq=262的记录对应的实际内容变了
- hint mismatch 在 tracked_replace 生成的长INS文本中常见(库不自动加hint到多段INS),需post-fix
@@ -0,0 +1,59 @@
# 版本管理反模式(2026-07-01 反委托代发工资协议惨痛教训)
## 事件回顾
反委托代发工资协议需要制作两个版本(法定安排 vs 反委托保护),同时保留华诚-Z的修订痕迹。
### 灾难链条
1. 原始文件有华诚-Z的修订(author="华诚-Z")+ 批注
2. 我制作WB版本时,把所有author改成了WB
3. 又做了一版合并版本,再次覆盖
4. 之后Doro说"你把华诚-Z修订痕迹的版本放进去"
5. 发现/tmp里所有文件都只有WB作为author
6. Nextcloud版本历史也没有(只保留了一个.v文件,也是WB)
7. 最终在 `/tmp/v1_doro_updated.docx` 找到——这是一个中间版本,纯属侥幸
### 反模式清单
| 反模式 | 后果 |
|--------|------|
| 修改author前不备份 | 原始修订痕迹不可逆丢失 |
| 覆盖式保存(同文件名) | 中间版本消失 |
| 从头重做而非增量修补 | 每次重做都覆盖上一版 |
| 不验证就交付 | 批注丢了3条没发现 |
| 多轮操作共用/tmp目录 | 后续操作的文件名与前面冲突 |
### 正确做法
```python
import shutil
from datetime import datetime
# 操作前备份
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
shutil.copy(source, f"{source}.bak_{timestamp}")
# 操作后验证
import zipfile, re
z = zipfile.ZipFile(output)
content = z.read('word/document.xml').decode('utf-8')
authors = set(re.findall(r'w:author="([^"]+)"', content))
assert '华诚-Z' in authors, "华诚-Z author LOST!"
# 批注验证
if 'word/comments.xml' in z.namelist():
comments_xml = z.read('word/comments.xml').decode('utf-8')
comment_count = len(re.findall(r'<w:comment ', comments_xml))
assert comment_count >= expected_count, f"Comments lost: {comment_count} < {expected_count}"
```
### 文件命名规范(防覆盖)
不要用 `_v2.docx` `_v3.docx` 这种递增命名——容易忘记当前版本是几。用语义+时间戳:
```
反委托_华诚Z原版_20260701_0320.docx # 带华诚-Z修订的版本
反委托_WB合并版_20260701_0341.docx # WB+华诚-Z合并后
反委托_V1法定安排_FINAL_20260701.docx # 最终交付
```
@@ -0,0 +1,56 @@
# WB自加手动编号与前序自动编号撞号 — 诊断与修复
实战来源:端午节福利品采购合同(2026-06-16)。Maggie:"转包责任应该是7,上一个编号是6。手动修复上传。"
## 场景识别
- 我们(WB)用 `w:ins` 新增了若干尾部条款(转包/违约/争议…),编号是**手动文字**写在 run 文本开头("6、转包限制…")。
- 紧邻的前一条是**原文自带的自动编号**条款(pPr 有 `<w:numPr>`,编号由 numbering.xml 的 `<w:start>` 生成,文字里**没有**编号)。
- 二者渲染数字撞号:自动编号末值=6,我方手动也从6起 → 接受修订后出现两个6。
## 诊断步骤(顺序不可颠倒,OnlyOffice为准)
1. 取交付版(任务交付/【修】…docx)+原文(待审查/…doc),各用 `scripts/onlyoffice-render.sh` 渲染 PDF。
2. `pdftotext -layout x.pdf - | grep -E "^\s*\f?[0-9]+、"` 数出**完整可见编号链**(注意"5、结算"可能挤在第4条段内、自动编号条款文字里无编号——肉眼易漏)。
3. 读 numbering.xml 确认前序自动条款的 numId→abstractNumId→lvl0 的 `start` 值,得知它渲染成几(端午节:numId=3, start=6 →"6")。
4. 读 document.xml,确认我方各条是 `w:ins author=WB`,编号"6、""7、""8、"在 ins 首个 w:r 的 w:t 开头。
## 判定
前序自动编号末值 = N → 我方手动编号应从 **N+1** 起顺延。端午节:售后=6 → 转包=7、违约=8、争议=9。
**有Maggie明确指示 + 完整核对 → 执行。** 不要因"擅改编号"的旧教训而拒绝正确修复(区别在:当初错在没核对没确认,不在方向)。
## 修复(纯 zipfile+lxml,最干净)
只改 ins 首个 w:t 的编号前缀,不拆 run、不碰 rPr、不转 numbering:
```python
import zipfile, os
from lxml import etree
W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
src='deliver.docx'; out='deliver_FIXED.docx'
root = etree.fromstring(zipfile.ZipFile(src).read('word/document.xml'))
paras = root.find(f'{W}body').findall(f'{W}p')
changes = {19:('6、','7、','转包'), 20:('7、','8、','违约'), 21:('8、','9、','争议')} # 段索引→(旧号,新号,关键词)
for idx,(old,new,kw) in changes.items():
ins = paras[idx].find(f'{W}ins')
assert ins is not None and ins.get(f'{W}author')=='WB', f"{idx}非WB的ins!" # 铁律:绝不改他人ins
t = ins.find(f'{W}r').find(f'{W}t')
assert t.text.startswith(old) and kw in t.text[:6]
t.text = new + t.text[len(old):]
new_doc = etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
tmp=out+'.tmp'
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(tmp,'w',zipfile.ZIP_DEFLATED) as zout:
for it in zin.infolist():
zout.writestr(it, new_doc if it.filename=='word/document.xml' else zin.read(it.filename))
os.replace(tmp,out)
```
## 交付前四查(vision不可用时的强制验证,缺一不可)
1. **逐段markup diff vs交付源**:提取两版每个 w:p 的 markup 文本(含 delText),断言**只有目标N段不同**、其余全部零改动(端午节33段只动3段)。
2. **OnlyOffice渲染PDF编号链**:pdftotext 数出 1,2,3,4,(5),6,7,8,9 连续无双号。
3. **INS run rPr 改前==改后**`etree.tostring(rpr)` 逐段比对,确认字体/字号一字未动。
4. **python-docx 能打开** + 接受修订后(去 del、解包 ins)编号链连续,证明 XML 合法、WB 修订标记完整保留。
## 交付(Editor到此为止则交deliverer;本例Maggie直接要"上传"故一并做)
- 文件名**一字不动**:覆盖 `Doro合同审查任务/任务交付/【修】<原名>.docx`
- `docker cp` 进 nextcloud-nextcloud-1 → `chown www-data``occ files:scan --path=...`
- 落盘 md5 == 修复版 md5 才算成功。
- 清 OnlyOffice 缓存:`docker exec nextcloud-onlyoffice-1 rm -rf .../App_Data/cache/files/*`
- 已 pass 登记过的合同仅编号订正:tracker/xlsx 记录不变动。
@@ -0,0 +1,127 @@
# ContractEditor 原文Run属性污染诊断与修复
## 2026-07-13 洋励合同实证
### 问题描述
ContractEditor(contract_docx_lib.py)在处理文档时,不仅给WB INS runs添加多余属性,还会**修改原文runs**的rPr——给本来靠docDefaults/style继承的orig runs添加显式eastAsia/cs/sz。
### 典型污染模式
| 属性 | 原文(待审查) | 被污染后(交付物中的orig run) | WB INS run |
|------|--------------|-------------------------------|-----------|
| eastAsia | None (继承minorEastAsia) | **宋体** (被加) | None |
| cs | None | **宋体** (被加) | None |
| sz | None (继承docDefaults=22) | **21** (被加且值错) | None |
| ascii | 宋体 | 宋体 | None |
| hint | eastAsia (部分有) | eastAsia | None |
### 后果
1. 原文所有文字从11pt(docDefaults sz=22)变成10.5pt(显式sz=21) — 整体缩小0.5pt
2. INS文字没有任何属性 → 走docDefaults 11pt → 与被改小的原文不一致
3. 字体验证脚本(wb-ins-font-verify.py)报INS缺属性,但实际问题是orig被污染
### 诊断步骤
```bash
# 1. 读原文代表性段落run rPr
python3 -c "
import zipfile
from lxml import etree
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
with zipfile.ZipFile('原文.docx', 'r') as z:
...
# 检查: eastAsia=None? sz=None?
# 如果是 → 原文靠继承
# 2. 读交付物同段落orig run rPr
# 检查: 是否多了eastAsia/cs/sz?
# 如果是 → 被污染
```
### 修复代码模板
```python
import zipfile, tempfile, shutil
from lxml import etree
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
with zipfile.ZipFile(filepath, 'r') as z:
all_files = {n: z.read(n) for n in z.namelist()}
tree = etree.fromstring(all_files['word/document.xml'])
body = tree.find(f'{WNS}body')
# Step 1: Strip contaminated attributes from ALL orig runs
for p in body.findall(f'{WNS}p'):
for r in p.findall(f'{WNS}r'): # Only direct child runs (not inside ins/del)
rpr = r.find(f'{WNS}rPr')
if rpr is None:
continue
rf = rpr.find(f'{WNS}rFonts')
if rf is not None:
# Strip eastAsia (original didn't have it)
if f'{WNS}eastAsia' in rf.attrib:
del rf.attrib[f'{WNS}eastAsia']
# Strip cs (original didn't have it)
if f'{WNS}cs' in rf.attrib:
del rf.attrib[f'{WNS}cs']
# Strip sz (original relies on docDefaults)
sz = rpr.find(f'{WNS}sz')
if sz is not None:
rpr.remove(sz)
# Step 2: Fix INS runs to match REAL original format
for ins in body.findall(f'.//{WNS}ins'):
if ins.get(f'{WNS}author') != 'WB':
continue
for r in ins.findall(f'{WNS}r'):
rpr = r.find(f'{WNS}rPr')
if rpr is None:
continue
rf = rpr.find(f'{WNS}rFonts')
if rf is None:
rf = etree.SubElement(rpr, f'{WNS}rFonts')
# Match real original: ascii=宋体, hAnsi=宋体, NO eastAsia
rf.set(f'{WNS}ascii', '宋体')
rf.set(f'{WNS}hAnsi', '宋体')
if f'{WNS}eastAsia' in rf.attrib:
del rf.attrib[f'{WNS}eastAsia']
# Remove sz (let it inherit)
sz = rpr.find(f'{WNS}sz')
if sz is not None:
rpr.remove(sz)
# Step 3: Per-paragraph hint matching
for p in body.findall(f'{WNS}p'):
# Get orig run's hint
orig_hint = None
for r in p.findall(f'{WNS}r'):
rpr = r.find(f'{WNS}rPr')
if rpr is not None:
rf = rpr.find(f'{WNS}rFonts')
orig_hint = rf.get(f'{WNS}hint') if rf is not None else None
break
# Apply to INS runs in same paragraph
for ins in p.findall(f'.//{WNS}ins'):
if ins.get(f'{WNS}author') != 'WB':
continue
for r in ins.findall(f'{WNS}r'):
rpr = r.find(f'{WNS}rPr')
if rpr is None: continue
rf = rpr.find(f'{WNS}rFonts')
if rf is None: continue
if orig_hint:
rf.set(f'{WNS}hint', orig_hint)
elif f'{WNS}hint' in rf.attrib:
del rf.attrib[f'{WNS}hint']
```
### 注意事项
1. **必须对比原文确定被污染了哪些属性** — 不同合同模板的原文属性不同
2. **不是所有合同都有此问题** — 取决于原文是否靠继承(有显式属性的不会被"污染",因为值相同)
3. **Step 1必须在Step 2之前** — 否则wb-ins-font-verify仍会报INS与(被污染的)orig不一致
4. **hint要逐段处理** — 同一文档不同段落的orig runs可能有的有hint有的没有
@@ -0,0 +1,70 @@
# Workflow INS Format Repair — ContractEditor Font Contamination Pattern
## 2026-07-13 洋励/安全生产/消防设施检测 连续验证
### 问题根因
ContractEditor库在处理文档时会**污染原文runs**——给原本没有显式属性的runs添加`eastAsia``cs``sz`
典型对比:
```
原文(待审查): rFonts={ascii=宋体, hAnsi=宋体, hint=eastAsia}, szCs=21, NO sz, NO eastAsia
v1中orig runs: rFonts={ascii=宋体, hAnsi=宋体, hint=eastAsia, cs=宋体, eastAsia=宋体}, szCs=21, sz=21
v1中WB INS: rPr=空 (什么属性都没有)
```
**后果**
1. 原文字号从继承docDefaults(如sz=22=11pt)变为显式sz=21(10.5pt)——整体缩小0.5pt
2. INS runs无属性→走docDefaults继承→11pt,与被改小的orig runs(10.5pt)不一致
3. wb-ins-font-verify报"orig=宋体/21 wb=None"——但这个"orig"已被污染,不是真实原文
### 诊断铁律
**永远对比待审查目录的原文,不信v1中的orig runs**
```python
# 对比同一段落的run属性
for label, path in [('待审查原文', orig_path), ('交付v1', v1_path)]:
# 读P4 first run rPr的所有子元素
# 如果v1比原文多了eastAsia/cs/sz → 被污染
```
### 修复方法(三步)
**Step 1:清除orig runs的污染属性**
```python
for r in p.findall(f'{WNS}r'): # 只处理原文runs(不在ins/del内的)
rpr = r.find(f'{WNS}rPr')
rf = rpr.find(f'{WNS}rFonts')
if rf is not None:
# 如果原文没有eastAsia,strip之
if f'{WNS}eastAsia' in rf.attrib:
del rf.attrib[f'{WNS}eastAsia']
if f'{WNS}cs' in rf.attrib:
del rf.attrib[f'{WNS}cs']
# 如果原文没有sz(靠继承),strip之
sz = rpr.find(f'{WNS}sz')
if sz is not None:
rpr.remove(sz)
```
**Step 2:设INS runs匹配真实原文**
```python
for ins in p.findall(f'.//{WNS}ins'):
if ins.get(f'{WNS}author') != 'WB': continue
for r in ins.findall(f'{WNS}r'):
rf = rpr.find(f'{WNS}rFonts')
# 设置为原文实际有的属性(如ascii=宋体, hAnsi=宋体)
# 不设原文没有的(如eastAsia, cs)
# hint按同段orig run的值设
```
**Step 3:逐段匹配hint**
不同段落的orig runs hint状态不同(有的有hint=eastAsia,有的没有)。必须逐段检查并匹配。
### 注意事项
- **不能一刀切**:同一文档不同段落的orig run属性可能不同(P4有hint, P19没hint; P20有完整rFonts, P36完全没rFonts)
- **docDefaults是真正的参考基准**:检查`word/styles.xml``docDefaults/rPrDefault`了解继承值
- **"MISSING HINT (无同段原文可比)"是已知限制**:整段WB INS的新增段落没有orig run对比,脚本报MISSING不是真实错误
- **洋励案实证**:120处orig runs被污染,修复后INS只剩14个MISSING HINT(全是新增段落)
@@ -0,0 +1,92 @@
# Workflow交付件逐份审查检查清单 (2026-07-13 Doro要求)
## 触发条件
Doro说"逐一审查已交付合同的修订有哪些问题"或类似指令。
## 操作流程
1. 全文阅读通用review-rules.md + 对应顾问单位特殊规则
2. 列出今天交付的全部文件(`sudo find ... -newermt`
3. 一份一份审查,报告问题,等Doro说pass再做下一份
## 每份检查项
### A0. 文件完整性(先于内容)
- `zipfile``word/comments.xml`看有无批注
- 统计`w:ins`/`w:del`数量确认有修订痕迹
- 如果有多版本(v1/v2),每个都要独立检查性质
### A. 格式验证
- `wb-ins-font-verify.py` — 必须PASS
- 原文同段run属性 vs INS run属性逐一比对
- 新增段落pPr(ind/spacing/numPr)vs原文邻近段落
### B. 审查清单覆盖(10条逐条)
1. 主体条款
2. 违约责任(含赔偿上限删除、维权费用)
3. 争议解决/管辖(甲方所在地法院)
4. 保密/数据(归属+存续+泄露赔偿 三要素)
5. 知识产权/系统
6. 第三方侵权(全责+赔偿甲方损失)
7. 转包/分包(限制+连带)
8. 价款条款
9. 服务成果持续使用权(仅持续性服务适用)
10. 条款逻辑
### C. 同模板一致性
- 同批同模板合同的修订是否完全统一
- 特别检查:编号顺延方式、章节结构、措辞、天数
### D. 特殊交付物
- 读该顾问单位review-rules.md确认是否要求审查意见文档
- 缺失则标记
### E. 批注审查
- 每条WB批注逐一比对规则
- 立场是否正确(站甲方)
- 是否违反"能改就不批注"
- 是否属于提醒性批注(禁止)
## 报告格式
```
## 【修】合同名称
**字体验证:** PASS/FAIL
**修订内容:** 逐条列出WB INS/DEL
**问题:**
1. [严重/一般] 具体问题描述
2. ...
**结论:** pass建议/需修复
```
### F. 编号顺延完整性(2026-07-13 璞石合同教训)
- 章节标题编号顺延后(如七→八),**子编号也必须顺延**(7.1→8.1, 7.2→8.2...)
- Workflow常见遗漏:只改了章节标题的汉字编号(第七条→第八条),但内部子条款的阿拉伯数字编号(7.1/7.2/7.3/7.4)原封不动
- **检查方法**:accepted text中搜索所有"X.Y"格式编号,确认X与所属章节标题的序号一致
- 修复方法:子编号通常拆为两个run(如"7" + ".1 "),只需DEL第一个run("7")+INS新数字("8")
### G. 内容去重(2026-07-13 璞石合同教训)
- 新增的保密存续条款是否与原文已有的类似表述重复
- 典型:原文已有"乙方的保密义务不因合同解除或终止而免除",WB又插入"本条保密义务不因本合同的终止或解除而终止"——语义完全重复
### H. 赔偿上限全面检查(2026-07-13 璞石合同教训)
- 规则"赔偿上限能删就删"不仅适用于乙方赔偿甲方的上限
- **双向条款中的上限也要删**:如"任何一方违约,违约金额为合同总金额的20%"——此上限同时限制了甲方可获赔偿
- P43甲方自身违约金上限保留是正确的(保护甲方),但P49双向上限应删除
- **判断方法**:上限是否限制了对方向甲方赔偿?是→删;上限是否限制了甲方向对方赔偿?是→保留
### I. 新增标题段落样式(2026-07-13 璞石合同教训)
- WB新增的章节标题段(如"第七条 转包与分包")的pStyle必须与原文标题段一致
- 原文标题用Heading4→新增也用Heading4,不能用Style15(正文首行缩进)
- 标题文字中"第X条"与名称之间是否有空格?原文无空格("第六条违约责任")则新增也不加空格
### J. 原文已有修订保持不动
- 对照原文确认:WB-1/86187/杨丽/富强等原文修订人的INS/DEL/批注是否全部原样保留
- WB的修改不能意外覆盖或嵌套进原文修订
- P29金额"27900"的sz=22是原文86187的修订→不是workflow问题
## 铁律
- 先tool call读文件再下结论(验证指令铁律)
- 不凭上一份的印象判断下一份
- comments.xml必须检查(2026-07-13教训)
- **原文自带【修】前缀的文件**:按命名规则应为【修】【修】...,workflow通常不做双重前缀——记录为已知缺陷
- **Doro说"看清楚前后文再回复"**:意思是你漏了问题或误判了严重性,必须重新逐属性检查
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""生成"接受所有修订后"的干净 docx,用于 OnlyOffice 渲染做字体/排版的决定性视觉验证。
为什么需要:OnlyOffice 渲染修订态文字(w:ins,紫色+下划线)时视觉上常显示为类无衬线、
看起来字体/粗细与正文不同——这是 track-changes 的渲染特性,不是真实字体差异。vision 工具
会据此误报"字体不一致",导致无谓返工。把所有修订接受、批注去掉后再渲染,才能在无修订
颜色干扰下看到插入文字与正文的真实字体一致性。
用法: python accept-revisions-preview.py <in.docx> <out.docx>
处理: 解包所有 w:ins(保留内容)+ 删除所有 w:del(连内容)+ 移除批注锚点标记。
注意: 产物仅供"渲染核对",不是正式交付物(交付的是带修订痕迹的版本)。
"""
import sys, zipfile, io
from lxml import etree
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
Wq = '{' + W + '}'
def accept_revisions(in_path, out_path):
with open(in_path, 'rb') as f:
data = f.read()
bin_, bout = io.BytesIO(data), io.BytesIO()
with zipfile.ZipFile(bin_) as zin, zipfile.ZipFile(bout, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
raw = zin.read(item.filename)
if item.filename == 'word/document.xml':
root = etree.fromstring(raw)
# 删除所有 w:del(含内容)
for d in [e for e in root.iter(Wq + 'del')]:
d.getparent().remove(d)
# 解包所有 w:ins:把子元素提到 ins 的位置后删除 ins 壳
for ins in [e for e in root.iter(Wq + 'ins')]:
parent = ins.getparent()
idx = list(parent).index(ins)
for child in reversed(list(ins)):
parent.insert(idx, child)
parent.remove(ins)
# 移除批注锚点标记
for tag in ('commentRangeStart', 'commentRangeEnd'):
for e in [x for x in root.iter(Wq + tag)]:
e.getparent().remove(e)
for r in [x for x in root.iter(Wq + 'r')]:
if r.find(Wq + 'commentReference') is not None:
r.getparent().remove(r)
raw = etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
zout.writestr(item, raw)
with open(out_path, 'wb') as f:
f.write(bout.getvalue())
print(f'接受修订版已生成: {out_path}')
if __name__ == '__main__':
if len(sys.argv) != 3:
print('用法: python accept-revisions-preview.py <in.docx> <out.docx>')
sys.exit(1)
accept_revisions(sys.argv[1], sys.argv[2])
@@ -0,0 +1,684 @@
#!/usr/bin/env python3
"""
contract_docx_lib.py — 合同修订核心库
固化验证通过的docx XML操作,不再每次重写。
用法:
from contract_docx_lib import ContractEditor
editor = ContractEditor("原文件.docx")
editor.tracked_replace("原文片段", "新文片段")
editor.add_clause("19.服务成果持续使用权", "条款内容...", after_clause=18)
editor.renumber(19, 20) # 原19→20
errors = editor.validate()
if not errors:
editor.save("【修】原文件.docx")
关键操作顺序(renumber和新增条款):
1. 先做所有 tracked_replace(文本修改)
2. 再做 add_clause(新增子条款,如15.4)
3. 再做 renumber_range(先腾出编号空间)
4. 最后做 add_clause_before(插入新主条款,用已腾出的编号)
5. validate() 验证
6. save() 保存
"""
import zipfile, io, copy, re, difflib
from lxml import etree
from datetime import datetime
from pathlib import Path
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
WP = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'
XML_SPACE = '{http://www.w3.org/XML/1998/namespace}space'
WNS = '{' + W + '}'
def qn(tag):
return f'{WNS}{tag}'
def cjk_tokenize(text):
"""CJK每字一token,ASCII连续一token,标点单独token。
经验证的分词策略,不要改。"""
tokens = []
i = 0
while i < len(text):
ch = text[i]
if '\u4e00' <= ch <= '\u9fff' or '\u3000' <= ch <= '\u303f' 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
class ContractEditor:
"""合同修订编辑器。一个实例对应一份合同文件。"""
def __init__(self, filepath):
self.filepath = Path(filepath)
with open(filepath, 'rb') as f:
self.original_bytes = f.read()
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as z:
self.doc_xml = z.read('word/document.xml')
self.tree = etree.fromstring(self.doc_xml)
self.body = self.tree.find(qn('body'))
self._rev_id = 100
self._revision_date = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
self._author = 'WB'
self._rsid = '00AA0001'
# 提取原文格式(核心:避免每次猜错格式)
self._body_rpr = None # 正文格式(最常见的非加粗rPr)
self._title_rpr = None # 条款标题格式(加粗的rPr)
self._body_ppr = None
self._extract_formats()
def _extract_formats(self):
"""从原文提取正文和标题的rPr。
策略:
- 正文格式:统计所有run的rPr,取出现最多的非加粗rPr
- 标题格式:优先从条款编号标题段落(如"7.索赔条款")提取rPr,
而非简单取第一个加粗run(可能是合同大标题,字号不同)
- 如果条款标题不加粗,标题格式回退到正文格式"""
import re
rpr_map = {} # serialized_rpr -> (count, rpr_element)
clause_title_rpr = None # 从条款编号标题提取的格式
first_bold_rpr = None # 第一个加粗run的格式(fallback)
for p in self.body.findall(qn('p')):
# 获取段落全文,判断是否是条款编号标题(如 "7.索赔条款" "5.伴随服务")
p_text = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}')).strip()
is_clause_title = bool(re.match(r'^\d+[..、]\s*\S', p_text)) and len(p_text) < 30
for r in p.findall(qn('r')):
rpr = r.find(qn('rPr'))
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
if not txt.strip() or len(txt) < 3:
continue
if rpr is not None:
is_bold = rpr.find(qn('b')) is not None
key = etree.tostring(rpr, encoding='unicode')
if is_bold and first_bold_rpr is None:
first_bold_rpr = rpr
# 优先从条款标题段落提取标题格式
if is_clause_title and clause_title_rpr is None:
clause_title_rpr = rpr
if not is_bold:
if key not in rpr_map:
rpr_map[key] = [0, rpr]
rpr_map[key][0] += 1
if self._body_ppr is None:
ppr = p.find(qn('pPr'))
txt = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
if ppr is not None and len(txt) > 20:
self._body_ppr = ppr
if rpr_map:
best = max(rpr_map.values(), key=lambda x: x[0])
self._body_rpr = best[1]
# 标题格式优先级:条款编号标题 > 第一个加粗run > 正文格式
self._title_rpr = clause_title_rpr or first_bold_rpr or self._body_rpr
if self._title_rpr is None and self._body_rpr is not None:
self._title_rpr = copy.deepcopy(self._body_rpr)
etree.SubElement(self._title_rpr, qn('b'))
def _next_id(self):
self._rev_id += 1
return str(self._rev_id)
def _mk_del(self, text, rpr=None):
d = etree.Element(qn('del'))
d.set(qn('id'), self._next_id())
d.set(qn('author'), self._author)
d.set(qn('date'), self._revision_date)
r = etree.SubElement(d, qn('r'))
r.set(qn('rsidDel'), self._rsid)
if rpr is not None:
r.append(copy.deepcopy(rpr))
t = etree.SubElement(r, qn('delText'))
t.set(XML_SPACE, 'preserve')
t.text = text
return d
def _mk_ins(self, text, rpr=None):
i = etree.Element(qn('ins'))
i.set(qn('id'), self._next_id())
i.set(qn('author'), self._author)
i.set(qn('date'), self._revision_date)
r = etree.SubElement(i, qn('r'))
r.set(qn('rsidR'), self._rsid)
if rpr is not None:
r.append(copy.deepcopy(rpr))
t = etree.SubElement(r, qn('t'))
t.set(XML_SPACE, 'preserve')
t.text = text
return i
def _mk_run(self, text, rpr=None):
r = etree.Element(qn('r'))
if rpr is not None:
r.append(copy.deepcopy(rpr))
t = etree.SubElement(r, qn('t'))
t.set(XML_SPACE, 'preserve')
t.text = text
return r
def get_para_text(self, p):
"""获取段落的原始文本(不含删除标记中的文本)"""
return ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
def find_para(self, search_text):
"""查找包含指定文本的段落"""
for p in self.body.findall(qn('p')):
if search_text in self.get_para_text(p):
return p
return None
def tracked_replace(self, old_text, new_text):
"""在整个文档中查找old_text并用修订模式替换为new_text。
使用字符级tokenizer+difflib实现精准修订。
返回True如果成功。"""
for p in self.body.findall(qn('p')):
runs = p.findall(f'.//{qn("r")}')
if not runs:
continue
full = ''.join(
''.join(t.text or '' for t in r.findall(qn('t')))
for r in runs
)
if old_text not in full:
continue
start = full.index(old_text)
end = start + len(old_text)
# 获取匹配位置的rPr
rpr = None
pos = 0
for r in runs:
rt = ''.join(t.text or '' for t in r.findall(qn('t')))
if pos + len(rt) > start:
rpr = r.find(qn('rPr'))
break
pos += len(rt)
# 生成diff元素
if new_text == '':
elems = [self._mk_del(old_text, rpr)]
else:
ot = cjk_tokenize(old_text)
nt = cjk_tokenize(new_text)
matcher = difflib.SequenceMatcher(None, ot, nt)
elems = []
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == 'equal':
elems.append(self._mk_run(''.join(ot[i1:i2]), rpr))
elif tag == 'delete':
elems.append(self._mk_del(''.join(ot[i1:i2]), rpr))
elif tag == 'insert':
elems.append(self._mk_ins(''.join(nt[j1:j2]), rpr))
elif tag == 'replace':
elems.append(self._mk_del(''.join(ot[i1:i2]), rpr))
elems.append(self._mk_ins(''.join(nt[j1:j2]), rpr))
# 定位受影响的runs并替换
pos = 0
first = last = None
prefix_text = suffix_text = ""
for idx, r in enumerate(runs):
rt = ''.join(t.text or '' for t in r.findall(qn('t')))
run_end = pos + len(rt)
if run_end > start and pos < end:
if first is None:
first = idx
prefix_text = full[pos:start]
last = idx
suffix_text = full[end:run_end] if run_end > end else ""
pos = run_end
if first is None:
continue
ref = runs[first]
# Find the actual paragraph (w:p) element to insert into
para_elem = p
# Determine insert position: find ref or its ancestor that is a direct child of p
ref_ancestor = ref
while ref_ancestor.getparent() is not para_elem and ref_ancestor.getparent() is not None:
ref_ancestor = ref_ancestor.getparent()
insert_pos = list(para_elem).index(ref_ancestor)
# Remove runs (each from its own parent)
for idx in range(last, first - 1, -1):
r = runs[idx]
r_parent = r.getparent()
r_parent.remove(r)
# If parent (e.g. w:ins) is now empty, remove it too
if r_parent is not para_elem and len(r_parent) == 0:
gp = r_parent.getparent()
if gp is not None:
gp.remove(r_parent)
ip = insert_pos
if prefix_text:
para_elem.insert(ip, self._mk_run(prefix_text, rpr))
ip += 1
for e in elems:
para_elem.insert(ip, e)
ip += 1
if suffix_text:
para_elem.insert(ip, self._mk_run(suffix_text, rpr))
return True
return False
def _get_leading_whitespace(self, para):
"""从段落中提取前导空格/tab模式。
很多中文文档的缩进不是通过w:ind实现的,而是通过文本中的空格字符。"""
for r in para.findall(qn('r')):
# Skip deleted runs
if r.getparent().tag == qn('del'):
continue
for t in r.findall(qn('t')):
if t.text:
# Extract leading whitespace
stripped = t.text.lstrip()
if stripped: # Has actual content after whitespace
return t.text[:len(t.text) - len(stripped)]
elif t.text.isspace(): # Entire run is whitespace
return t.text
return ''
def add_clause(self, full_text, after_search, use_title_format=False):
"""在包含after_search的段落之后插入新条款段落。
full_text: 新条款全文
after_search: 在包含此文本的段落之后插入
use_title_format: True=标题格式(加粗),False=正文格式
"""
ref_para = self.find_para(after_search)
if ref_para is None:
return False
rpr = self._title_rpr if use_title_format else self._body_rpr
ppr = ref_para.find(qn('pPr')) or self._body_ppr
# 复制相邻段落的前导空格模式
leading_ws = self._get_leading_whitespace(ref_para)
if leading_ws and not full_text.startswith(leading_ws):
full_text = leading_ws + full_text
new_p = etree.Element(qn('p'))
if ppr is not None:
new_p.append(copy.deepcopy(ppr))
new_p.append(self._mk_ins(full_text, rpr))
idx = list(self.body).index(ref_para)
self.body.insert(idx + 1, new_p)
return True
def add_clause_before(self, full_text, before_search, use_title_format=False):
"""在包含before_search的段落之前插入新条款段落。"""
ref_para = self.find_para(before_search)
if ref_para is None:
return False
rpr = self._title_rpr if use_title_format else self._body_rpr
ppr = ref_para.find(qn('pPr')) or self._body_ppr
# 复制相邻段落的前导空格模式
leading_ws = self._get_leading_whitespace(ref_para)
if leading_ws and not full_text.startswith(leading_ws):
full_text = leading_ws + full_text
new_p = etree.Element(qn('p'))
if ppr is not None:
new_p.append(copy.deepcopy(ppr))
new_p.append(self._mk_ins(full_text, rpr))
idx = list(self.body).index(ref_para)
self.body.insert(idx, new_p)
return True
def add_mixed_clause(self, title_text, content_text, after_search):
"""插入标题加粗+内容不加粗的新条款(两个段落)。
用于原文标题和内容分行的合同格式。"""
ref_para = self.find_para(after_search)
if ref_para is None:
return False
ppr = ref_para.find(qn('pPr')) or self._body_ppr
idx = list(self.body).index(ref_para)
# 复制相邻段落的前导空格模式
leading_ws = self._get_leading_whitespace(ref_para)
if leading_ws:
if not title_text.startswith(leading_ws):
title_text = leading_ws + title_text
if not content_text.startswith(leading_ws):
content_text = leading_ws + content_text
p_title = etree.Element(qn('p'))
if ppr: p_title.append(copy.deepcopy(ppr))
p_title.append(self._mk_ins(title_text, self._title_rpr))
self.body.insert(idx + 1, p_title)
p_content = etree.Element(qn('p'))
if ppr: p_content.append(copy.deepcopy(ppr))
p_content.append(self._mk_ins(content_text, self._body_rpr))
self.body.insert(idx + 2, p_content)
return True
def renumber_clause(self, old_num, new_num):
"""把条款编号从old_num改为new_num(修订模式)。
从后往前扫描,避免重复修改。"""
changed = 0
for p in reversed(self.body.findall(qn('p'))):
runs = p.findall(f'.//{qn("r")}')
for r in runs:
for t in r.findall(qn('t')):
if t.text and old_num in t.text:
rpr_e = r.find(qn('rPr'))
parent = r.getparent()
idx_r = list(parent).index(r)
pos = t.text.index(old_num)
prefix = t.text[:pos]
suffix = t.text[pos + len(old_num):]
parent.remove(r)
ip = idx_r
if prefix:
parent.insert(ip, self._mk_run(prefix, rpr_e))
ip += 1
parent.insert(ip, self._mk_del(old_num, rpr_e))
ip += 1
parent.insert(ip, self._mk_ins(new_num, rpr_e))
ip += 1
if suffix:
parent.insert(ip, self._mk_run(suffix, rpr_e))
changed += 1
break
return changed
def renumber_range(self, start, shift=1):
"""从start开始,所有现有条款编号+shift。从后往前处理。
注意:先调用此方法腾出编号空间,再插入新条款。
例:要在18后插入新19条:
editor.renumber_range(19, 1) # 19→20, 20→21, 21→22
editor.add_clause_before("19.新条款内容", before_search="20.合同生效")
"""
max_num = 0
for p in self.body.findall(qn('p')):
txt = self.get_para_text(p)
for m in re.finditer(r'(\d+)[..]', txt):
n = int(m.group(1))
if n > max_num:
max_num = n
for n in range(max_num, start - 1, -1):
self.renumber_clause(f'{n}', f'{n + shift}')
self.renumber_clause(f'{n}.', f'{n + shift}.')
def renumber_chinese(self, old_cn, new_cn):
"""中文编号顺延,如 "第十三条""第十四条""""
return self.renumber_clause(old_cn, new_cn)
def validate(self):
"""交付前验证。返回错误列表,空列表=通过。"""
errors = []
# 1. 编号连续性
clause_nums = []
for p in self.body.findall(qn('p')):
accepted = ''
for child in p:
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == 'r':
accepted += ''.join(t.text or '' for t in child.findall(qn('t')))
elif tag == 'ins':
accepted += ''.join(t.text or '' for t in child.findall(f'.//{qn("t")}'))
m = re.match(r'^(\d+)[..]', accepted.strip())
if m:
clause_nums.append(int(m.group(1)))
main_clauses = sorted(set(clause_nums))
for i in range(1, len(main_clauses)):
if main_clauses[i] - main_clauses[i-1] > 1:
errors.append(f"编号跳跃: {main_clauses[i-1]}{main_clauses[i]},缺少{main_clauses[i-1]+1}")
# 2. 字号一致性(WB的ins内容 vs 原文正文)
if self._body_rpr is not None:
body_sz = None
sz_elem = self._body_rpr.find(qn('sz'))
if sz_elem is not None:
body_sz = sz_elem.get(qn('val'))
if body_sz:
for ins in self.tree.findall(f'.//{qn("ins")}'):
if ins.get(qn('author')) != self._author:
continue
for r in ins.findall(qn('r')):
rpr = r.find(qn('rPr'))
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
if not txt.strip():
continue
if rpr is not None:
ins_sz = rpr.find(qn('sz'))
if ins_sz is not None:
val = ins_sz.get(qn('val'))
is_bold = rpr.find(qn('b')) is not None
if val != body_sz and not is_bold:
errors.append(f"字号不一致: ins sz={val} vs 原文sz={body_sz}'{txt[:30]}'")
# 3. 加粗规则(内容不应加粗)
for ins in self.tree.findall(f'.//{qn("ins")}'):
if ins.get(qn('author')) != self._author:
continue
for r in ins.findall(qn('r')):
rpr = r.find(qn('rPr'))
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
if not txt.strip() or len(txt.strip()) < 5:
continue
is_bold = rpr is not None and rpr.find(qn('b')) is not None
is_clause_title = bool(re.match(r'^\d+[..]\S', txt.strip())) or bool(re.match(r'^第.{1,3}条', txt.strip())) or bool(re.match(r'^[一二三四五六七八九十]{1,3}、', txt.strip()))
if is_bold and not is_clause_title:
errors.append(f"不应加粗: '{txt[:40]}'")
return errors
def dump_numbering(self):
"""输出accepted view的编号序列,用于人工确认"""
result = []
for p in self.body.findall(qn('p')):
accepted = ''
for child in p:
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == 'r':
accepted += ''.join(t.text or '' for t in child.findall(qn('t')))
elif tag == 'ins':
accepted += ''.join(t.text or '' for t in child.findall(f'.//{qn("t")}'))
m = re.match(r'^(\d+)[..]', accepted.strip())
if m:
result.append(f"{m.group(1)}. {accepted.strip()[:60]}")
return result
def save(self, output_path):
"""保存修订后的文件"""
new_doc_xml = etree.tostring(self.tree, xml_declaration=True,
encoding='UTF-8', standalone=True)
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as z:
settings = z.read('word/settings.xml')
stree = etree.fromstring(settings)
if stree.find(f'.//{qn("trackRevisions")}') is None:
stree.append(etree.Element(qn('trackRevisions')))
new_settings = etree.tostring(stree, xml_declaration=True,
encoding='UTF-8', standalone=True)
buf = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as zin:
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename == 'word/document.xml':
zout.writestr(item, new_doc_xml)
elif item.filename == 'word/settings.xml':
zout.writestr(item, new_settings)
else:
zout.writestr(item, zin.read(item.filename))
with open(output_path, 'wb') as f:
f.write(buf.getvalue())
return output_path
class ZhujiajaoOpinion:
"""朱家角审查意见表格填写器。严格使用模板结构,不自创格式。"""
TEMPLATE_PATH = Path.home() / ".hermes/shared/模版库/朱家角 审查意见【模板】.docx"
def __init__(self, template_path=None):
tpath = Path(template_path) if template_path else self.TEMPLATE_PATH
with open(tpath, 'rb') as f:
self.tmpl_bytes = f.read()
with zipfile.ZipFile(io.BytesIO(self.tmpl_bytes)) as z:
self.doc_xml = z.read('word/document.xml')
self.tree = etree.fromstring(self.doc_xml)
self.body = self.tree.find(qn('body'))
def fill(self, contract_name, items, has_modifications=True):
"""填写审查意见。
contract_name: 合同名称(填入标题《》中间)
items: [(条文位置, 原文, 修订后), ...]
has_modifications: False则保留"无法律修改意见"
"""
# 1. 填标题——找到空格run替换
for p in self.body.findall(qn('p')):
runs = p.findall(f'.//{qn("r")}')
for r in runs:
for t in r.findall(qn('t')):
if t.text and t.text.strip() == '' and len(t.text) >= 2:
parent_txt = ''.join(
tt.text or '' for rr in runs for tt in rr.findall(qn('t'))
)
if '关于《' in parent_txt:
t.text = contract_name
# 2. 处理"无法律修改意见"
if has_modifications:
for p in self.body.findall(qn('p')):
txt = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
if '无法律修改意见' in txt:
for r in p.findall(f'.//{qn("r")}'):
for t in r.findall(qn('t')):
if '无法律修改意见' in (t.text or ''):
t.text = ''
# 3. 填表格
if not items:
return
tbl = self.body.find(qn('tbl'))
if tbl is None:
return
rows = tbl.findall(qn('tr'))
# Row 0 = header, Row 1+ = data rows
# 获取表头rPr
header_rpr = None
for hc in rows[0].findall(qn('tc')):
for hr in hc.findall(f'.//{qn("r")}'):
rr = hr.find(qn('rPr'))
if rr:
header_rpr = rr
break
if header_rpr:
break
# 确保有足够数据行
template_row = rows[1] if len(rows) > 1 else None
while len(tbl.findall(qn('tr'))) - 1 < len(items):
if template_row is not None:
tbl.append(copy.deepcopy(template_row))
rows = tbl.findall(qn('tr'))
# 填写数据
for i, (clause, orig_text, modified_text) in enumerate(items):
if i + 1 >= len(rows):
break
row = rows[i + 1]
cells = row.findall(qn('tc'))
if len(cells) < 3:
continue
for ci, text in enumerate([clause, orig_text, modified_text]):
cell = cells[ci]
p = cell.find(qn('p'))
if p is None:
p = etree.SubElement(cell, qn('p'))
for r in p.findall(qn('r')):
p.remove(r)
r = etree.SubElement(p, qn('r'))
if header_rpr:
new_rpr = copy.deepcopy(header_rpr)
b = new_rpr.find(qn('b'))
if b is not None:
new_rpr.remove(b)
if '注:' in text:
color = new_rpr.find(qn('color'))
if color is None:
color = etree.SubElement(new_rpr, qn('color'))
color.set(qn('val'), 'FF0000')
r.append(new_rpr)
t = etree.SubElement(r, qn('t'))
t.set(XML_SPACE, 'preserve')
t.text = text
# 删除多余空行
rows = tbl.findall(qn('tr'))
for i in range(len(rows) - 1, len(items), -1):
tbl.remove(rows[i])
def save(self, output_path):
new_doc = etree.tostring(self.tree, xml_declaration=True,
encoding='UTF-8', standalone=True)
buf = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(self.tmpl_bytes)) as zin:
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename == 'word/document.xml':
zout.writestr(item, new_doc)
else:
zout.writestr(item, zin.read(item.filename))
with open(output_path, 'wb') as f:
f.write(buf.getvalue())
return output_path
@@ -0,0 +1,684 @@
#!/usr/bin/env python3
"""
contract_docx_lib.py — 合同修订核心库
固化验证通过的docx XML操作,不再每次重写。
用法:
from contract_docx_lib import ContractEditor
editor = ContractEditor("原文件.docx")
editor.tracked_replace("原文片段", "新文片段")
editor.add_clause("19.服务成果持续使用权", "条款内容...", after_clause=18)
editor.renumber(19, 20) # 原19→20
errors = editor.validate()
if not errors:
editor.save("【修】原文件.docx")
关键操作顺序(renumber和新增条款):
1. 先做所有 tracked_replace(文本修改)
2. 再做 add_clause(新增子条款,如15.4)
3. 再做 renumber_range(先腾出编号空间)
4. 最后做 add_clause_before(插入新主条款,用已腾出的编号)
5. validate() 验证
6. save() 保存
"""
import zipfile, io, copy, re, difflib
from lxml import etree
from datetime import datetime
from pathlib import Path
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
WP = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'
XML_SPACE = '{http://www.w3.org/XML/1998/namespace}space'
WNS = '{' + W + '}'
def qn(tag):
return f'{WNS}{tag}'
def cjk_tokenize(text):
"""CJK每字一token,ASCII连续一token,标点单独token。
经验证的分词策略,不要改。"""
tokens = []
i = 0
while i < len(text):
ch = text[i]
if '\u4e00' <= ch <= '\u9fff' or '\u3000' <= ch <= '\u303f' 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
class ContractEditor:
"""合同修订编辑器。一个实例对应一份合同文件。"""
def __init__(self, filepath):
self.filepath = Path(filepath)
with open(filepath, 'rb') as f:
self.original_bytes = f.read()
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as z:
self.doc_xml = z.read('word/document.xml')
self.tree = etree.fromstring(self.doc_xml)
self.body = self.tree.find(qn('body'))
self._rev_id = 100
self._revision_date = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
self._author = 'WB'
self._rsid = '00AA0001'
# 提取原文格式(核心:避免每次猜错格式)
self._body_rpr = None # 正文格式(最常见的非加粗rPr)
self._title_rpr = None # 条款标题格式(加粗的rPr)
self._body_ppr = None
self._extract_formats()
def _extract_formats(self):
"""从原文提取正文和标题的rPr。
策略:
- 正文格式:统计所有run的rPr,取出现最多的非加粗rPr
- 标题格式:优先从条款编号标题段落(如"7.索赔条款")提取rPr,
而非简单取第一个加粗run(可能是合同大标题,字号不同)
- 如果条款标题不加粗,标题格式回退到正文格式"""
import re
rpr_map = {} # serialized_rpr -> (count, rpr_element)
clause_title_rpr = None # 从条款编号标题提取的格式
first_bold_rpr = None # 第一个加粗run的格式(fallback)
for p in self.body.findall(qn('p')):
# 获取段落全文,判断是否是条款编号标题(如 "7.索赔条款" "5.伴随服务")
p_text = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}')).strip()
is_clause_title = bool(re.match(r'^\d+[..、]\s*\S', p_text)) and len(p_text) < 30
for r in p.findall(qn('r')):
rpr = r.find(qn('rPr'))
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
if not txt.strip() or len(txt) < 3:
continue
if rpr is not None:
is_bold = rpr.find(qn('b')) is not None
key = etree.tostring(rpr, encoding='unicode')
if is_bold and first_bold_rpr is None:
first_bold_rpr = rpr
# 优先从条款标题段落提取标题格式
if is_clause_title and clause_title_rpr is None:
clause_title_rpr = rpr
if not is_bold:
if key not in rpr_map:
rpr_map[key] = [0, rpr]
rpr_map[key][0] += 1
if self._body_ppr is None:
ppr = p.find(qn('pPr'))
txt = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
if ppr is not None and len(txt) > 20:
self._body_ppr = ppr
if rpr_map:
best = max(rpr_map.values(), key=lambda x: x[0])
self._body_rpr = best[1]
# 标题格式优先级:条款编号标题 > 第一个加粗run > 正文格式
self._title_rpr = clause_title_rpr or first_bold_rpr or self._body_rpr
if self._title_rpr is None and self._body_rpr is not None:
self._title_rpr = copy.deepcopy(self._body_rpr)
etree.SubElement(self._title_rpr, qn('b'))
def _next_id(self):
self._rev_id += 1
return str(self._rev_id)
def _mk_del(self, text, rpr=None):
d = etree.Element(qn('del'))
d.set(qn('id'), self._next_id())
d.set(qn('author'), self._author)
d.set(qn('date'), self._revision_date)
r = etree.SubElement(d, qn('r'))
r.set(qn('rsidDel'), self._rsid)
if rpr is not None:
r.append(copy.deepcopy(rpr))
t = etree.SubElement(r, qn('delText'))
t.set(XML_SPACE, 'preserve')
t.text = text
return d
def _mk_ins(self, text, rpr=None):
i = etree.Element(qn('ins'))
i.set(qn('id'), self._next_id())
i.set(qn('author'), self._author)
i.set(qn('date'), self._revision_date)
r = etree.SubElement(i, qn('r'))
r.set(qn('rsidR'), self._rsid)
if rpr is not None:
r.append(copy.deepcopy(rpr))
t = etree.SubElement(r, qn('t'))
t.set(XML_SPACE, 'preserve')
t.text = text
return i
def _mk_run(self, text, rpr=None):
r = etree.Element(qn('r'))
if rpr is not None:
r.append(copy.deepcopy(rpr))
t = etree.SubElement(r, qn('t'))
t.set(XML_SPACE, 'preserve')
t.text = text
return r
def get_para_text(self, p):
"""获取段落的原始文本(不含删除标记中的文本)"""
return ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
def find_para(self, search_text):
"""查找包含指定文本的段落"""
for p in self.body.findall(qn('p')):
if search_text in self.get_para_text(p):
return p
return None
def tracked_replace(self, old_text, new_text):
"""在整个文档中查找old_text并用修订模式替换为new_text。
使用字符级tokenizer+difflib实现精准修订。
返回True如果成功。"""
for p in self.body.findall(qn('p')):
runs = p.findall(f'.//{qn("r")}')
if not runs:
continue
full = ''.join(
''.join(t.text or '' for t in r.findall(qn('t')))
for r in runs
)
if old_text not in full:
continue
start = full.index(old_text)
end = start + len(old_text)
# 获取匹配位置的rPr
rpr = None
pos = 0
for r in runs:
rt = ''.join(t.text or '' for t in r.findall(qn('t')))
if pos + len(rt) > start:
rpr = r.find(qn('rPr'))
break
pos += len(rt)
# 生成diff元素
if new_text == '':
elems = [self._mk_del(old_text, rpr)]
else:
ot = cjk_tokenize(old_text)
nt = cjk_tokenize(new_text)
matcher = difflib.SequenceMatcher(None, ot, nt)
elems = []
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == 'equal':
elems.append(self._mk_run(''.join(ot[i1:i2]), rpr))
elif tag == 'delete':
elems.append(self._mk_del(''.join(ot[i1:i2]), rpr))
elif tag == 'insert':
elems.append(self._mk_ins(''.join(nt[j1:j2]), rpr))
elif tag == 'replace':
elems.append(self._mk_del(''.join(ot[i1:i2]), rpr))
elems.append(self._mk_ins(''.join(nt[j1:j2]), rpr))
# 定位受影响的runs并替换
pos = 0
first = last = None
prefix_text = suffix_text = ""
for idx, r in enumerate(runs):
rt = ''.join(t.text or '' for t in r.findall(qn('t')))
run_end = pos + len(rt)
if run_end > start and pos < end:
if first is None:
first = idx
prefix_text = full[pos:start]
last = idx
suffix_text = full[end:run_end] if run_end > end else ""
pos = run_end
if first is None:
continue
ref = runs[first]
# Find the actual paragraph (w:p) element to insert into
para_elem = p
# Determine insert position: find ref or its ancestor that is a direct child of p
ref_ancestor = ref
while ref_ancestor.getparent() is not para_elem and ref_ancestor.getparent() is not None:
ref_ancestor = ref_ancestor.getparent()
insert_pos = list(para_elem).index(ref_ancestor)
# Remove runs (each from its own parent)
for idx in range(last, first - 1, -1):
r = runs[idx]
r_parent = r.getparent()
r_parent.remove(r)
# If parent (e.g. w:ins) is now empty, remove it too
if r_parent is not para_elem and len(r_parent) == 0:
gp = r_parent.getparent()
if gp is not None:
gp.remove(r_parent)
ip = insert_pos
if prefix_text:
para_elem.insert(ip, self._mk_run(prefix_text, rpr))
ip += 1
for e in elems:
para_elem.insert(ip, e)
ip += 1
if suffix_text:
para_elem.insert(ip, self._mk_run(suffix_text, rpr))
return True
return False
def _get_leading_whitespace(self, para):
"""从段落中提取前导空格/tab模式。
很多中文文档的缩进不是通过w:ind实现的,而是通过文本中的空格字符。"""
for r in para.findall(qn('r')):
# Skip deleted runs
if r.getparent().tag == qn('del'):
continue
for t in r.findall(qn('t')):
if t.text:
# Extract leading whitespace
stripped = t.text.lstrip()
if stripped: # Has actual content after whitespace
return t.text[:len(t.text) - len(stripped)]
elif t.text.isspace(): # Entire run is whitespace
return t.text
return ''
def add_clause(self, full_text, after_search, use_title_format=False):
"""在包含after_search的段落之后插入新条款段落。
full_text: 新条款全文
after_search: 在包含此文本的段落之后插入
use_title_format: True=标题格式(加粗),False=正文格式
"""
ref_para = self.find_para(after_search)
if ref_para is None:
return False
rpr = self._title_rpr if use_title_format else self._body_rpr
ppr = ref_para.find(qn('pPr')) or self._body_ppr
# 复制相邻段落的前导空格模式
leading_ws = self._get_leading_whitespace(ref_para)
if leading_ws and not full_text.startswith(leading_ws):
full_text = leading_ws + full_text
new_p = etree.Element(qn('p'))
if ppr is not None:
new_p.append(copy.deepcopy(ppr))
new_p.append(self._mk_ins(full_text, rpr))
idx = list(self.body).index(ref_para)
self.body.insert(idx + 1, new_p)
return True
def add_clause_before(self, full_text, before_search, use_title_format=False):
"""在包含before_search的段落之前插入新条款段落。"""
ref_para = self.find_para(before_search)
if ref_para is None:
return False
rpr = self._title_rpr if use_title_format else self._body_rpr
ppr = ref_para.find(qn('pPr')) or self._body_ppr
# 复制相邻段落的前导空格模式
leading_ws = self._get_leading_whitespace(ref_para)
if leading_ws and not full_text.startswith(leading_ws):
full_text = leading_ws + full_text
new_p = etree.Element(qn('p'))
if ppr is not None:
new_p.append(copy.deepcopy(ppr))
new_p.append(self._mk_ins(full_text, rpr))
idx = list(self.body).index(ref_para)
self.body.insert(idx, new_p)
return True
def add_mixed_clause(self, title_text, content_text, after_search):
"""插入标题加粗+内容不加粗的新条款(两个段落)。
用于原文标题和内容分行的合同格式。"""
ref_para = self.find_para(after_search)
if ref_para is None:
return False
ppr = ref_para.find(qn('pPr')) or self._body_ppr
idx = list(self.body).index(ref_para)
# 复制相邻段落的前导空格模式
leading_ws = self._get_leading_whitespace(ref_para)
if leading_ws:
if not title_text.startswith(leading_ws):
title_text = leading_ws + title_text
if not content_text.startswith(leading_ws):
content_text = leading_ws + content_text
p_title = etree.Element(qn('p'))
if ppr: p_title.append(copy.deepcopy(ppr))
p_title.append(self._mk_ins(title_text, self._title_rpr))
self.body.insert(idx + 1, p_title)
p_content = etree.Element(qn('p'))
if ppr: p_content.append(copy.deepcopy(ppr))
p_content.append(self._mk_ins(content_text, self._body_rpr))
self.body.insert(idx + 2, p_content)
return True
def renumber_clause(self, old_num, new_num):
"""把条款编号从old_num改为new_num(修订模式)。
从后往前扫描,避免重复修改。"""
changed = 0
for p in reversed(self.body.findall(qn('p'))):
runs = p.findall(f'.//{qn("r")}')
for r in runs:
for t in r.findall(qn('t')):
if t.text and old_num in t.text:
rpr_e = r.find(qn('rPr'))
parent = r.getparent()
idx_r = list(parent).index(r)
pos = t.text.index(old_num)
prefix = t.text[:pos]
suffix = t.text[pos + len(old_num):]
parent.remove(r)
ip = idx_r
if prefix:
parent.insert(ip, self._mk_run(prefix, rpr_e))
ip += 1
parent.insert(ip, self._mk_del(old_num, rpr_e))
ip += 1
parent.insert(ip, self._mk_ins(new_num, rpr_e))
ip += 1
if suffix:
parent.insert(ip, self._mk_run(suffix, rpr_e))
changed += 1
break
return changed
def renumber_range(self, start, shift=1):
"""从start开始,所有现有条款编号+shift。从后往前处理。
注意:先调用此方法腾出编号空间,再插入新条款。
例:要在18后插入新19条:
editor.renumber_range(19, 1) # 19→20, 20→21, 21→22
editor.add_clause_before("19.新条款内容", before_search="20.合同生效")
"""
max_num = 0
for p in self.body.findall(qn('p')):
txt = self.get_para_text(p)
for m in re.finditer(r'(\d+)[..]', txt):
n = int(m.group(1))
if n > max_num:
max_num = n
for n in range(max_num, start - 1, -1):
self.renumber_clause(f'{n}.', f'{n + shift}.')
self.renumber_clause(f'{n}.', f'{n + shift}.')
def renumber_chinese(self, old_cn, new_cn):
"""中文编号顺延,如 "第十三条" → "第十四条"。"""
return self.renumber_clause(old_cn, new_cn)
def validate(self):
"""交付前验证。返回错误列表,空列表=通过。"""
errors = []
# 1. 编号连续性
clause_nums = []
for p in self.body.findall(qn('p')):
accepted = ''
for child in p:
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == 'r':
accepted += ''.join(t.text or '' for t in child.findall(qn('t')))
elif tag == 'ins':
accepted += ''.join(t.text or '' for t in child.findall(f'.//{qn("t")}'))
m = re.match(r'^(\d+)[..]', accepted.strip())
if m:
clause_nums.append(int(m.group(1)))
main_clauses = sorted(set(clause_nums))
for i in range(1, len(main_clauses)):
if main_clauses[i] - main_clauses[i-1] > 1:
errors.append(f"编号跳跃: {main_clauses[i-1]}→{main_clauses[i]},缺少{main_clauses[i-1]+1}")
# 2. 字号一致性(WB的ins内容 vs 原文正文)
if self._body_rpr is not None:
body_sz = None
sz_elem = self._body_rpr.find(qn('sz'))
if sz_elem is not None:
body_sz = sz_elem.get(qn('val'))
if body_sz:
for ins in self.tree.findall(f'.//{qn("ins")}'):
if ins.get(qn('author')) != self._author:
continue
for r in ins.findall(qn('r')):
rpr = r.find(qn('rPr'))
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
if not txt.strip():
continue
if rpr is not None:
ins_sz = rpr.find(qn('sz'))
if ins_sz is not None:
val = ins_sz.get(qn('val'))
is_bold = rpr.find(qn('b')) is not None
if val != body_sz and not is_bold:
errors.append(f"字号不一致: ins sz={val} vs 原文sz={body_sz},'{txt[:30]}'")
# 3. 加粗规则(内容不应加粗)
for ins in self.tree.findall(f'.//{qn("ins")}'):
if ins.get(qn('author')) != self._author:
continue
for r in ins.findall(qn('r')):
rpr = r.find(qn('rPr'))
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
if not txt.strip() or len(txt.strip()) < 5:
continue
is_bold = rpr is not None and rpr.find(qn('b')) is not None
is_clause_title = bool(re.match(r'^\d+[..]\S', txt.strip())) or bool(re.match(r'^第.{1,3}条', txt.strip()))
if is_bold and not is_clause_title:
errors.append(f"不应加粗: '{txt[:40]}'")
return errors
def dump_numbering(self):
"""输出accepted view的编号序列,用于人工确认"""
result = []
for p in self.body.findall(qn('p')):
accepted = ''
for child in p:
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == 'r':
accepted += ''.join(t.text or '' for t in child.findall(qn('t')))
elif tag == 'ins':
accepted += ''.join(t.text or '' for t in child.findall(f'.//{qn("t")}'))
m = re.match(r'^(\d+)[..]', accepted.strip())
if m:
result.append(f"{m.group(1)}. {accepted.strip()[:60]}")
return result
def save(self, output_path):
"""保存修订后的文件"""
new_doc_xml = etree.tostring(self.tree, xml_declaration=True,
encoding='UTF-8', standalone=True)
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as z:
settings = z.read('word/settings.xml')
stree = etree.fromstring(settings)
if stree.find(f'.//{qn("trackRevisions")}') is None:
stree.append(etree.Element(qn('trackRevisions')))
new_settings = etree.tostring(stree, xml_declaration=True,
encoding='UTF-8', standalone=True)
buf = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as zin:
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename == 'word/document.xml':
zout.writestr(item, new_doc_xml)
elif item.filename == 'word/settings.xml':
zout.writestr(item, new_settings)
else:
zout.writestr(item, zin.read(item.filename))
with open(output_path, 'wb') as f:
f.write(buf.getvalue())
return output_path
class ZhujiajaoOpinion:
"""朱家角审查意见表格填写器。严格使用模板结构,不自创格式。"""
TEMPLATE_PATH = Path.home() / ".hermes/shared/模版库/朱家角 审查意见【模板】.docx"
def __init__(self, template_path=None):
tpath = Path(template_path) if template_path else self.TEMPLATE_PATH
with open(tpath, 'rb') as f:
self.tmpl_bytes = f.read()
with zipfile.ZipFile(io.BytesIO(self.tmpl_bytes)) as z:
self.doc_xml = z.read('word/document.xml')
self.tree = etree.fromstring(self.doc_xml)
self.body = self.tree.find(qn('body'))
def fill(self, contract_name, items, has_modifications=True):
"""填写审查意见。
contract_name: 合同名称(填入标题《》中间)
items: [(条文位置, 原文, 修订后), ...]
has_modifications: False则保留"无法律修改意见"
"""
# 1. 填标题——找到空格run替换
for p in self.body.findall(qn('p')):
runs = p.findall(f'.//{qn("r")}')
for r in runs:
for t in r.findall(qn('t')):
if t.text and t.text.strip() == '' and len(t.text) >= 2:
parent_txt = ''.join(
tt.text or '' for rr in runs for tt in rr.findall(qn('t'))
)
if '关于《' in parent_txt:
t.text = contract_name
# 2. 处理"无法律修改意见"
if has_modifications:
for p in self.body.findall(qn('p')):
txt = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
if '无法律修改意见' in txt:
for r in p.findall(f'.//{qn("r")}'):
for t in r.findall(qn('t')):
if '无法律修改意见' in (t.text or ''):
t.text = ''
# 3. 填表格
if not items:
return
tbl = self.body.find(qn('tbl'))
if tbl is None:
return
rows = tbl.findall(qn('tr'))
# Row 0 = header, Row 1+ = data rows
# 获取表头rPr
header_rpr = None
for hc in rows[0].findall(qn('tc')):
for hr in hc.findall(f'.//{qn("r")}'):
rr = hr.find(qn('rPr'))
if rr:
header_rpr = rr
break
if header_rpr:
break
# 确保有足够数据行
template_row = rows[1] if len(rows) > 1 else None
while len(tbl.findall(qn('tr'))) - 1 < len(items):
if template_row is not None:
tbl.append(copy.deepcopy(template_row))
rows = tbl.findall(qn('tr'))
# 填写数据
for i, (clause, orig_text, modified_text) in enumerate(items):
if i + 1 >= len(rows):
break
row = rows[i + 1]
cells = row.findall(qn('tc'))
if len(cells) < 3:
continue
for ci, text in enumerate([clause, orig_text, modified_text]):
cell = cells[ci]
p = cell.find(qn('p'))
if p is None:
p = etree.SubElement(cell, qn('p'))
for r in p.findall(qn('r')):
p.remove(r)
r = etree.SubElement(p, qn('r'))
if header_rpr:
new_rpr = copy.deepcopy(header_rpr)
b = new_rpr.find(qn('b'))
if b is not None:
new_rpr.remove(b)
if '注:' in text:
color = new_rpr.find(qn('color'))
if color is None:
color = etree.SubElement(new_rpr, qn('color'))
color.set(qn('val'), 'FF0000')
r.append(new_rpr)
t = etree.SubElement(r, qn('t'))
t.set(XML_SPACE, 'preserve')
t.text = text
# 删除多余空行
rows = tbl.findall(qn('tr'))
for i in range(len(rows) - 1, len(items), -1):
tbl.remove(rows[i])
def save(self, output_path):
new_doc = etree.tostring(self.tree, xml_declaration=True,
encoding='UTF-8', standalone=True)
buf = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(self.tmpl_bytes)) as zin:
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename == 'word/document.xml':
zout.writestr(item, new_doc)
else:
zout.writestr(item, zin.read(item.filename))
with open(output_path, 'wb') as f:
f.write(buf.getvalue())
return output_path
@@ -0,0 +1,316 @@
#!/usr/bin/env python3
"""
contract_preprocess.py — 合同预处理:检测并切割非审查图片内容
用途:在workflow审查前,检测合同末尾的纯图片附件(如招标公告截图、中标通知书等),
切割出来保存,审查完后再还原。
判断逻辑:
1. 扫描文件结构:文字段落数 vs 图片段落数
2. 全文/大部分是图片(扫描件合同)→ 不切割,标记需OCR
3. 正文文字+末尾图片附件 → 切割末尾图片区域
4. 切割点:从最后一个"纯文字附件"结束后,到第一个"纯图片附件"开始
输出:
- {basename}_stripped.docx — 去掉图片附件的版本(供workflow处理)
- {basename}_cutdata.json — 切割信息(供还原用)
"""
import zipfile, json, os, sys, re
from lxml import etree
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
R_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
A_NS = 'http://schemas.openxmlformats.org/drawingml/2006/main'
def analyze_contract(docx_path):
"""Analyze contract structure, return analysis dict"""
with zipfile.ZipFile(docx_path) as z:
doc = etree.fromstring(z.read('word/document.xml'))
media_files = {n: z.getinfo(n).file_size for n in z.namelist() if n.startswith('word/media/')}
body = doc.find(f'{{{W}}}body')
paras = body.findall(f'{{{W}}}p')
paragraphs = []
total_text_chars = 0
total_img_paras = 0
for i, p in enumerate(paras):
texts = p.findall(f'.//{{{W}}}t')
text = ''.join(t.text or '' for t in texts).strip()
has_img = any('drawing' in (e.tag if isinstance(e.tag, str) else '') for e in p.iter())
blips = list(p.iter(f'{{{A_NS}}}blip'))
img_rids = [b.get(f'{{{R_NS}}}embed', '') for b in blips]
total_text_chars += len(text)
if has_img:
total_img_paras += 1
paragraphs.append({
'idx': i,
'text': text,
'text_len': len(text),
'has_img': has_img,
'img_rids': img_rids,
'is_appendix_heading': bool(re.match(r'^附件[一二三四五六七八九十\d]+[::、]', text)),
})
return {
'total_paras': len(paras),
'total_text_chars': total_text_chars,
'total_img_paras': total_img_paras,
'media_files': media_files,
'total_media_bytes': sum(media_files.values()),
'paragraphs': paragraphs,
}
def detect_cut_zone(analysis):
"""Detect if there's a tail image zone to cut."""
paras = analysis['paragraphs']
total = analysis['total_paras']
text_paras = sum(1 for p in paras if p['text_len'] > 0 and not p['has_img'])
img_paras = analysis['total_img_paras']
if text_paras == 0 and img_paras > 0:
return {'action': 'ocr', 'reason': '全文无文字段落,疑似扫描件合同'}
if img_paras == 0:
return None
img_ratio = img_paras / max(1, text_paras + img_paras)
if img_ratio > 0.5:
return {'action': 'ocr', 'reason': f'图片段落占比{img_ratio:.0%},疑似扫描件合同'}
# Find tail image zones
image_zones = []
i = 0
while i < total:
p = paras[i]
if p['is_appendix_heading']:
zone_start = i
zone_has_images = False
zone_has_text_content = False
j = i + 1
while j < total:
next_p = paras[j]
if next_p['is_appendix_heading']:
break
if next_p['has_img']:
zone_has_images = True
if next_p['text_len'] > 20 and not next_p['has_img']:
zone_has_text_content = True
j += 1
image_zones.append({
'start_idx': zone_start,
'end_idx': j - 1,
'heading': p['text'],
'has_images': zone_has_images,
'has_text': zone_has_text_content,
'is_image_only': zone_has_images and not zone_has_text_content,
})
i = j
else:
i += 1
# Find consecutive image-only appendices at the tail
tail_cut_zones = []
for zone in reversed(image_zones):
if zone['is_image_only']:
tail_cut_zones.insert(0, zone)
else:
break
if not tail_cut_zones:
return None
cut_start = tail_cut_zones[0]['start_idx']
cut_headings = [z['heading'] for z in tail_cut_zones]
return {
'action': 'cut',
'cut_start_idx': cut_start,
'cut_end_idx': total - 1,
'cut_headings': cut_headings,
'reason': f'末尾{len(tail_cut_zones)}个附件为纯图片:{", ".join(cut_headings)}',
}
def preprocess_contract(docx_path, output_dir=None):
"""Main entry: analyze and optionally strip tail images."""
if output_dir is None:
output_dir = os.path.dirname(docx_path) or '.'
basename = os.path.splitext(os.path.basename(docx_path))[0]
analysis = analyze_contract(docx_path)
cut_info = detect_cut_zone(analysis)
print(f"\n=== 合同预处理分析 ===")
print(f"文件: {os.path.basename(docx_path)}")
print(f"段落数: {analysis['total_paras']}")
print(f"文字字符: {analysis['total_text_chars']}")
print(f"图片段落: {analysis['total_img_paras']}")
print(f"媒体文件: {len(analysis['media_files'])} ({analysis['total_media_bytes']:,} bytes)")
if cut_info is None:
print(f"结论: 无需切割")
return {'action': 'none', 'analysis': analysis}
if cut_info['action'] == 'ocr':
print(f"结论: {cut_info['reason']},需OCR处理")
return {'action': 'ocr', 'reason': cut_info['reason'], 'analysis': analysis}
cut_start = cut_info['cut_start_idx']
print(f"结论: 需切割 — {cut_info['reason']}")
print(f"切割点: 段落 #{cut_start}")
with zipfile.ZipFile(docx_path) as z:
doc = etree.fromstring(z.read('word/document.xml'))
all_files = {}
for name in z.namelist():
all_files[name] = z.read(name)
body = doc.find(f'{{{W}}}body')
paras = body.findall(f'{{{W}}}p')
cut_paras_xml = []
for i in range(cut_start, len(paras)):
cut_paras_xml.append(etree.tostring(paras[i], encoding='unicode'))
for i in range(len(paras) - 1, cut_start - 1, -1):
body.remove(paras[i])
cut_rids = set()
for p_info in analysis['paragraphs'][cut_start:]:
cut_rids.update(p_info['img_rids'])
rels_xml = all_files.get('word/_rels/document.xml.rels', b'')
if isinstance(rels_xml, bytes):
rels_xml = rels_xml.decode()
rid_to_media = {}
for m in re.finditer(r'Id="(rId\d+)"[^/]*Target="(media/[^"]+)"', rels_xml):
rid_to_media[m.group(1)] = f'word/{m.group(2)}'
cut_media = {}
for rid in cut_rids:
media_path = rid_to_media.get(rid)
if media_path and media_path in all_files:
cut_media[media_path] = len(all_files[media_path])
stripped_path = os.path.join(output_dir, f'{basename}_stripped.docx')
all_files['word/document.xml'] = etree.tostring(doc, xml_declaration=True, encoding='UTF-8', standalone=True)
with zipfile.ZipFile(stripped_path, 'w', zipfile.ZIP_DEFLATED) as zout:
for name, data in all_files.items():
zout.writestr(name, data)
cutdata = {
'original_file': os.path.basename(docx_path),
'cut_start_idx': cut_start,
'total_paras_original': len(paras) + len(cut_paras_xml),
'cut_paragraphs_xml': cut_paras_xml,
'cut_headings': cut_info['cut_headings'],
'cut_media_files': list(cut_media.keys()),
'reason': cut_info['reason'],
}
cutdata_path = os.path.join(output_dir, f'{basename}_cutdata.json')
with open(cutdata_path, 'w', encoding='utf-8') as f:
json.dump(cutdata, f, ensure_ascii=False, indent=2)
stripped_size = os.path.getsize(stripped_path)
original_size = os.path.getsize(docx_path)
print(f"\n输出:")
print(f" stripped: {stripped_path} ({stripped_size:,} bytes)")
print(f" cutdata: {cutdata_path}")
print(f" 大小变化: {original_size:,}{stripped_size:,} bytes ({stripped_size/original_size:.0%})")
return {
'action': 'cut',
'stripped_path': stripped_path,
'cutdata_path': cutdata_path,
'cut_info': cut_info,
'analysis': analysis,
}
def restore_contract(reviewed_path, cutdata_path, output_path):
"""Restore cut content back into the reviewed file."""
with open(cutdata_path, 'r', encoding='utf-8') as f:
cutdata = json.load(f)
with zipfile.ZipFile(reviewed_path) as z:
doc = etree.fromstring(z.read('word/document.xml'))
all_files = {}
for name in z.namelist():
all_files[name] = z.read(name)
body = doc.find(f'{{{W}}}body')
sect_pr = body.find(f'{{{W}}}sectPr')
for para_xml in cutdata['cut_paragraphs_xml']:
para_elem = etree.fromstring(para_xml)
if sect_pr is not None:
sect_pr.addprevious(para_elem)
else:
body.append(para_elem)
original_dir = os.path.dirname(cutdata_path)
original_name = cutdata['original_file']
original_path = os.path.join(original_dir, original_name)
if os.path.exists(original_path):
with zipfile.ZipFile(original_path) as z_orig:
for media_file in cutdata.get('cut_media_files', []):
if media_file not in all_files and media_file in z_orig.namelist():
all_files[media_file] = z_orig.read(media_file)
print(f" 还原媒体文件: {media_file}")
all_files['word/document.xml'] = etree.tostring(doc, xml_declaration=True, encoding='UTF-8', standalone=True)
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zout:
for name, data in all_files.items():
zout.writestr(name, data)
restored_size = os.path.getsize(output_path)
print(f"\n=== 合同还原完成 ===")
print(f"还原文件: {output_path} ({restored_size:,} bytes)")
print(f"还原段落: {len(cutdata['cut_paragraphs_xml'])}")
print(f"还原附件: {', '.join(cutdata['cut_headings'])}")
return output_path
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Usage:")
print(" 预处理: python contract_preprocess.py preprocess <input.docx> [output_dir]")
print(" 还原: python contract_preprocess.py restore <reviewed.docx> <cutdata.json> <output.docx>")
sys.exit(1)
action = sys.argv[1]
if action == 'preprocess':
docx_path = sys.argv[2]
output_dir = sys.argv[3] if len(sys.argv) > 3 else None
result = preprocess_contract(docx_path, output_dir)
print(f"\nResult: {json.dumps({k: v for k, v in result.items() if k != 'analysis'}, ensure_ascii=False, indent=2)}")
elif action == 'restore':
reviewed_path = sys.argv[2]
cutdata_path = sys.argv[3]
output_path = sys.argv[4]
restore_contract(reviewed_path, cutdata_path, output_path)
else:
print(f"Unknown action: {action}")
sys.exit(1)
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""编号链诊断探针 — 一次性看清 docx 的自动编号/手动编号全貌。
用途:合同编号疑似错乱(重复/跳号/双号)时,动手改之前必跑此脚本。
它把三件事一次性摊开,让你判断「是我们WB改错的 / 他人修订重排的 / 还是源文件自带的潜伏自动编号」:
1. 每个段落:是否带 <w:numPr>(自动编号)、numId、ilvl、是否整段ins/del
2. numbering.xml 解析:numId→abstractNum→(numFmt, lvlText, start) ——
⚠️ start≠1 的 decimal 列表会渲染出「6、」之类的可见编号,但 run 里没有这个字!
这是最隐蔽的坑:源文件起草人给某段挂了 numId(start=6),OnlyOffice 自动显示「6、售后服务」,
而你在末尾新增条款时只数了手打的「1 2 3 4 5」,顺手编成「6」→ 与潜伏的自动6撞号。
3. 每段「接受所有修订后」的可见文本(去w:del、保w:ins),近似 OnlyOffice 接受后视图
用法: python numbering-diagnose.py <contract.docx>
.doc 先转换: soffice --headless --convert-to docx <file>.doc
"""
import sys, zipfile
from lxml import etree
W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
def text_mode(p, mode):
"""mode='final': 接受所有修订后(去del,保ins). mode='orig': 修订前(去ins,保del)."""
parts = []
for node in p.iter():
if node.tag == W + 't':
anc, skip = node, False
while anc is not None:
if mode == 'final' and anc.tag == W + 'del':
skip = True; break
if mode == 'orig' and anc.tag == W + 'ins':
skip = True; break
anc = anc.getparent()
if not skip:
parts.append(node.text or '')
elif node.tag == W + 'delText' and mode == 'orig':
parts.append(node.text or '')
return ''.join(parts).strip()
def parse_numbering(z):
"""返回 numId -> (numFmt, lvlText, start) 仅 lvl0(够用于条款标题层)."""
out = {}
if 'word/numbering.xml' not in z.namelist():
return out
num = etree.fromstring(z.read('word/numbering.xml'))
n2a = {}
for n in num.findall(W + 'num'):
ab = n.find(W + 'abstractNumId')
if ab is not None:
n2a[n.get(W + 'numId')] = ab.get(W + 'val')
a2fmt = {}
for ab in num.findall(W + 'abstractNum'):
l0 = ab.find(W + 'lvl')
if l0 is not None:
fmt = l0.find(W + 'numFmt')
txt = l0.find(W + 'lvlText')
st = l0.find(W + 'start')
a2fmt[ab.get(W + 'abstractNumId')] = (
fmt.get(W + 'val') if fmt is not None else '?',
txt.get(W + 'val') if txt is not None else '',
st.get(W + 'val') if st is not None else '1',
)
for nid, aid in n2a.items():
out[nid] = a2fmt.get(aid, ('?', '', '1'))
return out
def main(path):
z = zipfile.ZipFile(path)
root = etree.fromstring(z.read('word/document.xml'))
numinfo = parse_numbering(z)
print(f"### {path}\n")
print("=== numbering.xml: numId -> (numFmt, lvlText, start) ===")
if not numinfo:
print(" (无 numbering.xml — 全文应为手动文本编号)")
for nid, (fmt, txt, st) in sorted(numinfo.items()):
warn = ' ⚠️start≠1 会渲染潜伏编号!' if (fmt == 'decimal' and st != '1') else ''
print(f" numId={nid}: fmt={fmt}, lvlText='{txt}', start={st}{warn}")
print()
print("idx | numPr(自动) | rendered | ins/del | 文本(接受修订后)")
print("-" * 92)
for i, p in enumerate(root.findall('.//' + W + 'p')):
tf = text_mode(p, 'final')
if not tf:
continue
npr = p.find('.//' + W + 'numPr')
npinfo, rendered = '', ''
if npr is not None:
nid_el = npr.find(W + 'numId')
il_el = npr.find(W + 'ilvl')
nid = nid_el.get(W + 'val') if nid_el is not None else '?'
il = il_el.get(W + 'val') if il_el is not None else '0'
npinfo = f"numId={nid},lvl={il}"
fmt, txt, st = numinfo.get(nid, ('?', '', '1'))
if fmt == 'decimal':
rendered = (txt or '%1、').replace('%1', st) # 该项首个渲染值(近似)
elif fmt == 'bullet':
rendered = ''
elif fmt == 'none':
rendered = '(无)'
has_ins = p.find('.//' + W + 'ins') is not None
has_del = p.find('.//' + W + 'del') is not None
mk = ('INS' if has_ins else '') + ('/' if has_ins and has_del else '') + ('DEL' if has_del else '')
print(f"{i:3d} | {npinfo:18s} | {rendered:8s} | {mk:7s} | {tf[:46]}")
print()
print("判读要点:")
print(" - rendered 列非空 = OnlyOffice 会自动加这个编号(run里没有这串字)")
print(" - 手动编号: rendered='' 且文本以「N、」开头 = 编号是写死的文字")
print(" - 若末尾新增条款(INS)的手打编号 与 上方某段 rendered 自动编号 相同 → 撞号")
print(" 正确做法: 新增手打编号应接续【rendered 自动值】往下编, 不是接续最后一个手打数字")
if __name__ == '__main__':
if len(sys.argv) < 2:
print(__doc__); sys.exit(1)
main(sys.argv[1])
@@ -0,0 +1,34 @@
#!/bin/bash
# OnlyOffice x2t 渲染 docx → PDF
# 用途:用Maggie/Doro实际使用的渲染引擎(OnlyOffice)把合同docx渲染成PDF,
# 核对编号/格式的真实显示效果(与LibreOffice/python模拟可能不同,核对一律以此为准)。
# 用法: ./onlyoffice-render.sh /path/to/合同.docx [输出PDF路径]
# 不给输出路径时,默认输出到 同目录/同名.pdf
# 依赖: OnlyOffice容器 nextcloud-onlyoffice-1 在运行;x2t在容器内
# /var/www/onlyoffice/documentserver/server/FileConverter/bin/x2t
# 之后用: pdftotext -layout out.pdf - | grep -nE "^\s*[0-9]+、" 逐条数编号链
# pdftoppm -png -r 140 -f 1 -l 1 out.pdf prefix 转图发给Maggie确认
set -e
SRC="$1"
[ -z "$SRC" ] && { echo "用法: $0 <docx路径> [输出PDF]"; exit 1; }
OUT="${2:-${SRC%.docx}.pdf}"
CONTAINER=nextcloud-onlyoffice-1
TS=$(date +%s%N)
INNAME="/tmp/render_${TS}.docx"
OUTNAME="/tmp/render_${TS}.pdf"
CONVXML="/tmp/conv_${TS}.xml"
docker cp "$SRC" "${CONTAINER}:${INNAME}"
docker exec "$CONTAINER" bash -c "cat > ${CONVXML} << 'EOF'
<?xml version=\"1.0\" encoding=\"utf-8\"?>
<TaskQueueDataConvert xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">
<m_sFileFrom>${INNAME}</m_sFileFrom>
<m_sFileTo>${OUTNAME}</m_sFileTo>
<m_bIsNoBase64>true</m_bIsNoBase64>
</TaskQueueDataConvert>
EOF
cd /var/www/onlyoffice/documentserver/server/FileConverter/bin && ./x2t ${CONVXML} > /dev/null 2>&1 && echo x2t_done"
docker cp "${CONTAINER}:${OUTNAME}" "$OUT"
docker exec "$CONTAINER" rm -f "$INNAME" "$OUTNAME" "$CONVXML" 2>/dev/null || true
echo "渲染完成: $OUT"
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Post-save sweep: strip explicit attributes from WB INS runs when
the same-paragraph original runs rely on inheritance (ea=None, hint=None, sz=None).
Usage: python3 strip-inherited-ins-attrs.py <docx_path>
Modifies the file in place. Run AFTER ContractEditor.save() and BEFORE
wb-ins-font-verify.py to fix the known "ContractEditor默认sz=21与docDefaults继承冲突".
The pattern: for each paragraph containing WB INS, find the first plain w:r
(non-INS, non-DEL) as reference. If that reference run has no explicit
eastAsia/hint/sz, strip those from all WB INS runs in the same paragraph.
"""
import sys
import zipfile
import tempfile
import shutil
from lxml import etree
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
def strip_inherited_attrs(filepath):
with zipfile.ZipFile(filepath, 'r') as z:
doc_xml = z.read('word/document.xml')
all_files = {n: z.read(n) for n in z.namelist()}
tree = etree.fromstring(doc_xml)
body = tree.find(f'{WNS}body')
paras = body.findall(f'{WNS}p')
fixed = 0
for p in paras:
# Find first plain run as reference
orig_run = None
for child in p:
if child.tag == f'{WNS}r':
orig_run = child
break
if orig_run is None:
continue
orig_rpr = orig_run.find(f'{WNS}rPr')
orig_rf = orig_rpr.find(f'{WNS}rFonts') if orig_rpr is not None else None
orig_sz = orig_rpr.find(f'{WNS}sz') if orig_rpr is not None else None
orig_ea = orig_rf.get(f'{WNS}eastAsia') if orig_rf is not None else None
orig_hint = orig_rf.get(f'{WNS}hint') if orig_rf is not None else None
orig_sz_val = orig_sz.get(f'{WNS}val') if orig_sz is not None else None
for ins in p.findall(f'.//{WNS}ins'):
if ins.get(f'{WNS}author') != 'WB':
continue
for r in ins.findall(f'{WNS}r'):
rpr = r.find(f'{WNS}rPr')
if rpr is None:
continue
rf = rpr.find(f'{WNS}rFonts')
sz = rpr.find(f'{WNS}sz')
if orig_ea is None and rf is not None:
for attr in ['eastAsia', 'ascii', 'hAnsi']:
key = f'{WNS}{attr}'
if key in rf.attrib:
if orig_rf is None or orig_rf.get(key) is None:
del rf.attrib[key]
fixed += 1
if orig_hint is None and rf is not None and f'{WNS}hint' in rf.attrib:
del rf.attrib[f'{WNS}hint']
fixed += 1
if orig_sz_val is None and sz is not None:
rpr.remove(sz)
fixed += 1
# Save
tmp = tempfile.mktemp(suffix='.docx')
with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as zout:
for name in all_files:
if name == 'word/document.xml':
new_xml = etree.tostring(tree, xml_declaration=True, encoding='UTF-8', standalone=True)
new_str = new_xml.decode('utf-8')
new_str = new_str.replace(
"<?xml version='1.0' encoding='UTF-8' standalone='yes'?>",
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>')
new_str = new_str.replace('\n', '\r\n')
zout.writestr(name, new_str.encode('utf-8'))
else:
zout.writestr(name, all_files[name])
shutil.move(tmp, filepath)
return fixed
if __name__ == '__main__':
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <docx_path>")
sys.exit(1)
n = strip_inherited_attrs(sys.argv[1])
print(f"Fixed {n} inherited attribute issues in {sys.argv[1]}")
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Unify all tracked change authors in a docx to 'WB'.
Usage: python unify-author-wb.py <input.docx> [output.docx]
If output is omitted, overwrites input.
Covers: w:ins, w:del, rPrChange, pPrChange, sectPrChange,
tblPrChange, trPrChange, tcPrChange.
Also fixes XML declaration (single→double quotes) for OnlyOffice compatibility.
"""
import sys, os, zipfile, re
from lxml import etree
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
CHANGE_TAGS = ('ins', 'del', 'rPrChange', 'pPrChange',
'sectPrChange', 'tblPrChange', 'trPrChange', 'tcPrChange')
def unify_author(src_path, out_path=None):
if out_path is None:
out_path = src_path
tmp_path = out_path + '.tmp'
zin = zipfile.ZipFile(src_path, 'r')
doc_xml = zin.read('word/document.xml')
tree = etree.fromstring(doc_xml)
body = tree.find(f'{WNS}body')
changed = 0
for tag_suffix in CHANGE_TAGS:
for elem in body.iter(f'{WNS}{tag_suffix}'):
author = elem.get(f'{WNS}author')
if author and author != 'WB':
elem.set(f'{WNS}author', 'WB')
changed += 1
# Serialize + fix XML declaration
doc_bytes = etree.tostring(tree, xml_declaration=True, encoding='UTF-8', standalone=True)
doc_str = doc_bytes.decode('utf-8')
doc_str = doc_str.replace(
"<?xml version='1.0' encoding='UTF-8' standalone='yes'?>",
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>')
with zipfile.ZipFile(tmp_path, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.namelist():
if item == 'word/document.xml':
zout.writestr(item, doc_str.encode('utf-8'))
else:
zout.writestr(item, zin.read(item))
zin.close()
os.replace(tmp_path, out_path)
# Verify
z = zipfile.ZipFile(out_path)
vdoc = z.read('word/document.xml')
vtree = etree.fromstring(vdoc)
vbody = vtree.find(f'{WNS}body')
remaining = set()
for tag_suffix in CHANGE_TAGS:
for elem in vbody.iter(f'{WNS}{tag_suffix}'):
a = elem.get(f'{WNS}author', '')
if a != 'WB':
remaining.add(a)
z.close()
print(f"{changed} author attributes → WB")
if remaining:
print(f"⚠️ Remaining non-WB authors: {remaining}")
else:
print(f" All authors = WB")
print(f" Output: {out_path} ({os.path.getsize(out_path):,} bytes)")
if __name__ == '__main__':
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
src = sys.argv[1]
out = sys.argv[2] if len(sys.argv) > 2 else None
unify_author(src, out)
@@ -0,0 +1,698 @@
---
name: contract-pass-workflow
description: 合同审查pass后的标准操作——更新tracker、更新xlsx清单、上传Nextcloud、清缓存。Doro说pass后照此执行。
version: 1.0.0
tags: [合同审查, pass, tracker, xlsx]
---
# 合同审查 Pass 后标准操作
## 交付文件位置(铁律,2026-06-29 Doro纠正)
所有交付文件统一放在 **`Doro合同审查任务/任务交付/`(根目录)**,不放顾问单位子文件夹。
-`Doro合同审查任务/任务交付/【修】合同_朱家角.docx`
-`Doro合同审查任务/朱家角镇社区卫生服务中心/任务交付/【修】合同_朱家角.docx`
workflow YAML deliverer 步骤(第210行)明确写的上传路径是根目录 `任务交付/`。如果 deliverer 或 final_review 把文件放到了子文件夹,pass 步骤0核对时必须发现并移到根目录。
## 步骤0核对要点
pass 步骤0的核对流程:
1. 取原始文件名(去掉【修】/【审】前缀)
2.`Doro合同审查任务/待审查/` 确认源文件存在
3. **确认交付文件在根目录 `任务交付/`**(不是子文件夹)
4. 顾问单位核对(从合同正文读甲方名称)
5. tracker 查重
## 红线:验证指令 ≠ 回忆指令(2026-07-02 信任危机后确立)
### 根因排查铁律:抛弃旧判断,先查“发生了什么变化”,再谈原因(2026-07-14 Doro纠正)
当 Doro 明确说“去查原因”“不是让你猜测、推断”“完全抛弃此前的判断,重新核查情况”时,后续动作必须切换为**变化核查模式**,而不是继续打磨措辞、修补上一版归因。
**强制步骤:**
1. **旧判断全部作废**:停止沿用“状态漂移”“异常”“习惯变差”“元控制失效”等任何解释性语言,除非已经有直接证据支持。
2. **先限定时间窗**:如果用户已给出时间边界(如“发生在 7 月 10 日前”),所有核查必须围绕该边界展开;边界外的改动先排除,不得混入结论。
3. **优先查“可观察变化”而不是“解释”**
- 配置文件修改时间与具体改动;
- workflow/queue/watchdog/notify 脚本的修改时间与新增逻辑;
- skill 规则文本的修改时间;
- gateway / auto-notify / watchdog 的退出、重启、恢复日志;
- tracker / queue / done / manifest 等状态文件是否出现结构变化。
4. **汇报格式必须是“已查到的变化 / 没查到的变化”**,不能把“更可能”“像是”“说明了”写成原因结论。
5. **只有在“变化事实”查清后,才允许进入第二步根因分析**;若变化事实尚未闭环,明确说“目前只查到这些变化,原因尚未下结论”。
**禁止事项:**
- 不断修改上一版判断的措辞,假装自己在继续调查;
- 用“异常”“偶发”“状态不好”“坏习惯”去解释持续两天、批量失效的问题;
- 把证据和推断混写成同一层结论;
- 用户要求查原因时,实际只做语言收缩而不做新核查。
**本会话教训:** Doro连续纠正“不是让你猜测、推断”“不是让你不断修改措辞”“要你完全抛弃此前判断,重新核查 7 月 10 日前发生了什么变化”。以后凡是原因排查任务,第一步不是解释,而是建立时间窗并枚举已发生变化。
### 先查清时间窗,再查数据(2026-07-13 本会话再犯后补丁)
当 Doro/Maggie 说“上周五”“今天”“昨天”“本周一”这类**相对日期**时,**第一步不是直接查数据,而是先把自然语言时间词锚定成明确的北京时间起止窗口**。没先锚时间窗,就会出现:
- 把“上周五”理解成错误日期;
- UTC/BJT 边界算错;
- 用错窗口后得出“0 份”之类错误结论;
- 之后再补查 gateway.log 才发现用户是对的,严重伤信任。
**强制步骤:**
1. 先用北京时间确认当前日期和星期;
2. 把“上周五/今天”等词转换成**北京时间明确起止时间**;
3. 再换算成 UTC 时间戳/窗口;
4. 把这个时间窗写出来后,才开始查 `.meta` / gateway.log / tracker / xlsx。
**执行纪律:**
- 如果 `meta` 查不到,但 `gateway.log` 里已经出现该日文件消息证据,**不得继续说“0 份”或“没发”**;必须当场降级结论为“已证明确实发了,但文件清单仍在继续反查”。
- 对周五这类历史批次,**至少要交叉两源**:`gateway.log` 文件消息 + tracker / xlsx / queue 痕迹,不能只信一侧。
- 没闭环前,结论只能说“已查实部分”“尚未查实部分”,不能提前给“全部无遗漏”的总判断。
**本会话教训**:周五邱律师明明发了文件,但因先把时间窗算错、又只依赖 `.meta`,错误说成“0 份”;之后从 `gateway.log` 查到 `msg=''` 文件消息才纠正。以后凡是相对日期统计任务,**先定北京时间窗口,再查数据**。
## 红线:验证指令 ≠ 回忆指令(2026-07-02 信任危机后确立)
### 当前交付目录全量 pass 指令(2026-07-13 Doro明确授权)
当 Doro 明确下达类似指令:
- "任务交付文件夹里所有的合同及companion,都做pass"
- "现在这一刻,Nextcloud-任务交付里,所有的合同和companion,都做pass流程"
这属于**对当前任务交付目录的全量授权**,不再局限于单份合同或单个 companion。此时必须:
1. **先列出任务交付目录当前全部文件**,不要凭上一条对话里提到的那一份合同推断。
2. **合同与 companion 都要纳入核查范围**——先查清目录里到底有哪些主合同、有哪些 companion,不能只扫主合同关键词就下结论。
3. **主合同与 companion 的 pass 处理规则不同**
- **主合同**:做完整 pass——tracker 标记 `completed`,并登记到 excel。
- **companion**:属于主合同的附属交付物,**不单独登记到 excel,不单独占 seq**。只在核查结果中确认其存在与关联关系,必要时体现在主合同的 pass 备注/关联记录中。
4. **先查 tracker / xlsx 再写入**,但用户已经授权时,不要卡在"没有 tracker 记录所以我先不做"的保守口径上;对主合同应直接补建记录并完成 completed + excel 登记。
5. **汇报时先给出全量清单,再区分主合同与 companion 的处理结果**,不能把“全部做pass”误写成“所有文件都单独登记 excel”。
### 本次会话教训
- 错误做法:只围绕白鹤这一份合同回答"已pass",没有先把任务交付目录全量列出,遗漏了香花桥和朱家角积分项目的一整组合同及 companion。
- 第二层错误:把 companion 当成主合同一样单独写入 tracker/xlsx,导致 seq 冲突和错误登记。
- 正确做法:当 Doro 说"任务交付文件夹里所有的合同及companion,都做pass"时,必须把**当前任务交付目录的全部文件**作为审计范围,但**excel 只登记主合同,companion 不单独登记**。
**凡Doro说"查""核实""核对""是不是都""有没有遗漏"→ 回复中必须先有工具调用再有结论。context记忆/session summary ≠ 查证,不可直接输出。**
违反后果:Doro已明确说"到了无法信任你的程度"。这不是规则问题,是行为问题——规则早就写了(见已知坑),照样违反。
四个具体失败模式(2026-07-02 同一轮对话全犯):
1. **拿记忆当查证**:session context有信息→直接组织成答案→包装成"查实结果"→其实没跑工具
2. **渠道遗漏**:只查了QiuTing私信,漏了Doro私信、Doro直接Nextcloud上传、飞书等渠道
3. **不认识自己的输出**:之前汇报过的数据(seq 216-222含重固),被追问时说"找不到"——数据一直在,没看自己历史输出
4. **推责给用户**:找不到时直接问Doro"你记得叫什么名字吗"——把验证责任转嫁给用户。正确做法:穷尽搜索策略(换关键词、按时间批次找、按相邻seq推断、直接读xlsx逐行扫、session_search换多个query)。数据就在tracker里,是自己没认真遍历。
**第4条补充(2026-07-02追加)**:被追问"你真的查了吗"→ 如果上一条回复里没有tool call,直接承认"没查,现在查"。不辩解、不包装。
**唯一可接受的行为**
- 跑工具 → 看输出 → 写结论
- 不确定的说不确定
- 被追问时不防御不绕,直接承认没查到
- 对比自己之前输出过的表格/数据,确认是否已经有答案
## 恢复到workflow交付状态(2026-07-13 教训)
Doro可能要求"恢复到workflow完成的状态"——意思是撤销你的手动修改,让NC上的交付文件回到workflow原始交付版本。
**恢复方法(按优先级)**
1. **NC版本历史**`docker exec nextcloud-nextcloud-1 ls /var/www/html/data/doro/files_versions/Doro合同审查任务/任务交付/``.v<timestamp>` 文件,最早的版本 = workflow原始交付版
2. **`/tmp/pass_check_*` 副本**:pass流程核对时从NC拉取的副本,如果时间早于你的手动修改,就是workflow原版
3. **练塘镇子目录副本**:部分合同在 `练塘镇社区卫生服务中心/任务交付/` 也有一份
**操作**:docker cp 覆盖 → chown www-data → occ files:scan。
**场景**:你手动修复了workflow交付的合同(补编号、修字体等),但Doro认为应该重新走workflow而非手动修补。恢复后等Doro进一步指示。
## 前置审查(Doro要求"审查workflow修改"时触发)
Doro可能在pass之前要求逐份审查workflow交付物的质量。这不是pass流程,而是**前置质量审计**,只有审计通过+手动修复后Doro才会说pass。
### 审计方法论
1. **区分原文修订 vs workflow修订**:用zipfile读XML,按`w:ins/@author``w:del/@author`区分。`author=WB`是workflow的,其余(WB-1/86187/杨丽等)是原文自带的→保持不动。
2. **对照原文确认**:必须docker cp原文件(从NC待审查目录),转换后逐段对比,确认哪些修订是原文自带。
3. **逐项检查清单**
- INS rFonts:WB INS的rFonts属性必须与同段原文run一致(不多不少)。常见问题:多了hAnsi/cs/hint
- pStyle:新增条款标题的段落样式必须与原文条款标题一致(如Heading4),不能用Style15等其他样式
- 标题空格:原文"第六条违约责任"无空格→新增也不能有空格"第七条转包与分包"
- 子编号顺延:章节编号改了(七→八),内部子编号(7.1→8.1)也必须改
- 赔偿上限:双向条款的赔偿上限(限制甲方获赔)按规则"能删就删"
- 内容去重:新增保密存续等条款前,检查原文是否已有同义表述
- 脚注"法律顾问修订版":**必须用修订格式**(w:ins, author=WB),不能是普通文本
- 文件命名:【修】+原文件名一字不动(含扩展名变化.doc→.docx)
4. **通读全文**:接受修订后的全文必须通读,检查WB插入的内容是否与上下文语句通顺、有无重复编号(原文问题不改,但要识别)
5. **Doro的期望**
- "打开文件查清楚再回答我"→ 必须用工具完整检查后才能下结论,不能凭印象
- "看清楚前后文再回答我"→ 报告问题前必须理解修改点的上下文语境
- "有没有该加粗没加粗的"→ 格式检查要全面(bold/style/indent/spacing都要对比)
- "按照workflow的规则,手动修改"→ 发现问题后直接修复,不只是报告
### 修复操作要点
- 用zipfile+lxml直接操作XML(不用python-docx修改tracked changes)
- 修复后的INS rFonts只保留原文有的属性(通常eastAsia+ascii)
- 子编号顺延:原文数字可能分散在多个run中(如"7"+".1 "两个run),只需DEL+INS第一个数字run
- P49类大段文本需精确split run(保留前后文,只DEL中间要删的部分)
- 修复后必须python-docx打开验证
## 触发条件
Doro对已交付的合同**明确说"pass"**(可以是单份或批量)。
⚠️ **绝对前提:Doro必须亲口说"pass"才能启动此流程。** 合同交付后、workflow完成后、端午节合同end后——这些都**不是**pass。不要因为合同已交付就主动查xlsx/tracker或准备pass操作。Doro没说pass之前,对交付物的一切后续操作(更新tracker、更新xlsx、查重等)都不做。
⚠️ **"我满意了"≠Doro说pass(2026-07-13教训)**:即使你作为审查负责人完成了质检、修复了所有问题、对文件满意,也**绝不能自行执行pass流程**。必须等Doro明确说"pass"。2026-07-13实证:白鹤劳务派遣协议修复完毕后自行做了pass,被Doro纠正"我没说pass你做什么pass",紧急撤销(tracker回退delivered+xlsx删行)。自主质检和pass是两个完全独立的步骤——前者是你的职责,后者是Doro的权力。
2026-06-15教训:两次被Doro纠正("我都还没说pass呢"、"今天的合同我都没说pass呢")——agent在合同交付后主动查xlsx是否已更新、准备补写tracker,被视为越权操作。
2026-07-13教训:白鹤劳务派遣协议自行质检完后直接执行pass,被纠正"我没说pass你做什么pass",紧急撤销。
**手动审查交付物的完整检查清单**见 `references/manual-review-checklist-0713.md`——当Doro要求"审查workflow修改的情况"时按此执行。核心:自主质检→发现问题直接修→报告→等Doro说pass。不问"需要修复吗",不自行pass。
## 特殊判定:"终身不通过"
Doro可能对某些合同说"终身不通过"——这意味着合同审查质量太差,**永远不会pass**。
- **不是返工**:不是让你修了再交,而是直接否决
- **处理方式**:在tracker中标记`status: "permanently_rejected"`,不进入pass流程
- **反思**:必须分析为什么质量差到这个程度,是workflow哪个角色出了问题,把教训记入historical-failures
- **不要追问Doro**:已经定性了就不要再烦,自己复盘
## 操作步骤(严格按顺序)
### 0. 交付物核对(写入前必做)
⚠️ **PDF 批注交付件的独立核验** → 用 `scripts/verify_pdf_annotation_deliverable.py <原件> <NC交付件> [本地核验件]`:一键查 SHA256 字节一致、页数、原文未改动、批注数/author=WB、批注格式("建议"开头无【】)、高亮几何锚定。扫描件/PDF 合同走批注模式(非 docx 修订),docx 专项检查不适用,用此脚本替代。
Doro说pass后,在写tracker/xlsx之前,**必须先核对交付物与源文件的匹配关系**:
1. **取原始文件名**:从交付文件名去掉`【修】``【无修改意见】`前缀 → 得到原始文件名
2. **待审查目录核实**:去Nextcloud `Doro合同审查任务/待审查/` 确认该原始文件名存在。不存在则停下来排查,不继续写入
3. **顾问单位核对**:确认要写入xlsx的顾问单位名称正确。⚠️ 不能盲信classifier的`our_party_name`——已有多次classifier误判案例(如智慧医院云项目合同classifier设为"朱家角"实际甲方是"卫健事业发展中心")。**必须用python-docx打开合同原文,直接读取甲方名称**
- **⚠️ python-docx 的 `paragraph.text` 会吞掉 `w:ins`(修订插入)内容 → 读出的甲方可能是"补全前"的残缺名(2026-06-25 实证)**:当本次审查的改动之一就是"给甲方补全行政区前缀"(如 WB 修订插入「上海市青浦区」),交付件里甲方全称是「原稿可见文字 + w:ins 插入文字」拼起来的。`python-docx` 遍历 `doc.paragraphs[i].text` 时**未必包含 ins 的文字**,会让你误以为甲方还是缺前缀的简称。**核甲方全称必须把 `w:ins` 算进去**:用 zipfile 读 `word/document.xml`、对甲方那一段 `''.join(t.text for t in p.iter('{...}t'))``w:t` 不分 ins/非 ins,全收),或干脆遍历所有 `w:ins` 看 author=WB 插了什么。2026-06-25 项目终止协议书:`paragraph.text` 显示甲方「华新镇社区卫生服务中心」,实际交付件 w:ins 补了「上海市青浦区」,全称应是「上海市青浦区华新镇社区卫生服务中心」——只看 `.text` 就会把残缺简称写进 tracker/xlsx。**写 party 前先确认你读的是"接受修订后"的完整甲方名。**
- **顾问单位名称空白的特殊情况(按合同来源分两路,2026-06-25 补全)**:合同正文里顾问单位名称是空白下划线(待签时填)时,无法从正文确定。**先分清这份合同是谁的任务线**,再决定问谁——别默认是邱律师:
- **邱律师批量线**(health-centers):私信邱律师询问归属。私信用 `python3 ~/.hermes/scripts/wecom_dm.py --to qiuting --text "…"`(或 `_send_wecom(extra,'QiuTing',msg)`),**不用** `send_message(target='wecom:X')`(静默回退 home channel)。暂停 pass(不写 tracker/xlsx),邱律师回复后从步骤1继续;Doro 说过"邱律师回复了就直接补上 我不管了"——回复后自主补登记,不再找 Doro。
- **Doro 直接指派 / 非批量线**(如幼儿园保密协议、劳动合同等非卫生中心合同):**问发起 pass 的人本人**(通常就是正在对话的 Doro),不要问邱律师,也不要自己瞎填占位符。
- ⚠️ **绝不用泛指占位符当 party 写进 tracker/xlsx**(如"幼儿园(园方,名称空白待填)")——2026-06-25 教训:园名空白我写了这种占位符,Doro 直接纠正"顾问单位是平和学校"。空白就停下来问准确**法律主体全称**,确认后再写。
- **同名/近名主体消歧(2026-06-25 教训,写 party 前必做)**:拿到顾问单位名后,先 `openpyxl` 扫 xlsx 第 C 列看清单里**是否已有多个相似名**的主体——它们往往是**不同法律实体**,不能混用。本会话清单里同时有「上海青浦平和**幼儿园有限公司**」(seq13/15) 和「上海青浦平和**双语学校**」(seq44/210/211),一个园、一个校,是两个主体。判别靠**合同内容性质**:这份通篇"幼儿园/幼儿就读/保教费"→幼儿园主体;但既然清单里并存多个近名实体,**最终仍用 `clarify` 让发起人拍板一个准确全称**,并复用清单里既有的写法(保持前后一致,别造新写法)。
- **更正已写错的 party(两处同步)**:若 tracker/xlsx 已写入后才被纠正,两处都要改:tracker 用原子写回(tempfile+rename)改对应 seq 的 `party`;xlsx 改第 C 列后重新 `docker cp` 上传 + `occ files:scan` + 清 OnlyOffice 缓存重启;最后从线上重新拉 xlsx + 读 tracker **核对两处一致**才算完成。
4. **查重**:在tracker中检查是否已有相同 `original_filename` + `party` 的completed记录。有则为重复交付,不再写入
以上4步全部通过,才进入步骤1写tracker。任何一步不通过,停下来排查原因。
**注意**:Doro批量pass时可能列出审查意见文件(如"朱家角审查意见-xxx"、"采购协议-审查意见")。这些是主合同的附属交付物,**默认不单独写tracker/xlsx条目**。只需为主合同文件(【修】前缀的)写tracker和xlsx。审查意见文件如果不在交付目录中(可能已被清理或因classifier误判未生成),不影响主合同的pass流程——跳过即可,不需要报错或追问Doro。
### 例外:用户明确说“任务交付文件夹里所有的合同及companion,都做pass流程”时(2026-07-13 Doro明确)
当 Doro 用**当前任务交付目录全量授权**的口径下指示:
- “任务交付文件夹里所有的合同及companion,都做pass”
- “现在这一刻,Nextcloud-任务交付里,所有的合同和companion,都做pass流程”
此时必须把 companion **纳入 pass 核查范围**,但仍要遵守:
- **companion 不是独立合同,不单独登记 excel,不单独占 seq**;
- tracker/xlsx 的登记主体仍然是**主合同**;
- companion 的处理应当体现在“该主合同已连同 companion 一并核查/补做pass”的结果里,而不是把每个 companion 当主合同单独建台账。
**执行顺序**
1. 先把当前 `任务交付/` 目录全部文件列出来,不能只围绕当前对话那一份合同;
2. 再识别哪些是主合同、哪些是 companion;
3. 主合同逐份做 pass / tracker / xlsx;
4. companion 只做挂靠核查,不单独占 excel 行;
5. 回复时明确区分“主合同已登记几份、companion 已核查几份”,不要混成一类。
**2026-06-11教训**:安全测试合同因跳过核对,第一次把原始文件名写进xlsx文件名列(没有【修】前缀),发现后补写但未清理错误记录,导致tracker和xlsx各多一条重复数据。
**2026-06-12教训**:手动写xlsx时列顺序写反(日期和序号对调、文件名列写成原始文件名而非delivered_filename、顾问单位列写成"已完成"状态文字)。**写xlsx前必须先读上一行确认列顺序**,不要凭记忆。正确列顺序:A=序号, B=日期, C=顾问单位, D=合同名称, E=文件名(delivered_filename)。
### 1. 更新 Tracker JSON
路径:`~/.hermes/data/contract-tracker.json`
#### 状态流转(新增 `delivered` 中间态,2026-07-02 确立)
```
文件到达 → workflow审查 → deliverer交付成功 → queue-runner写入 status=delivered
Doro说pass → status=completed
24h后 → cleanup清理
```
**三道防线防重复:**
| 检查点 | 逻辑 |
|--------|------|
| queue-runner 启动前 | 查 tracker,`delivered``completed` → 直接 SKIP 并移入 done/ |
| watchdog resume 前 | 查 tracker,已交付的 thread 不恢复 |
| deliverer 写入时 | exists 检查,同文件不重复写入 |
#### Pass 时的写入逻辑
**如果 tracker 中已有该 `original_filename` 且 `status=delivered`** → 更新该记录为 `completed`,补全 `seq`/`party`/`contract_name`/`delivered_filename`/`converted_filename`/`xlsx_updated_at`/`cleaned=false`
**如果 tracker 中没有记录**(旧合同、手动审查等情况)→ 新建完整记录,直接 `status=completed`
完整记录示例:
```json
{
"original_filename": "消防设施检测服务合同(练塘卫生院).doc",
"delivered_filename": "【修】消防设施检测服务合同(练塘卫生院).docx",
"converted_filename": "消防设施检测服务合同(练塘卫生院).docx",
"party": "上海市青浦区练塘镇社区卫生服务中心",
"contract_name": "消防设施2026年度检测服务合同",
"seq": 175,
"status": "completed",
"delivered_at": "2026-06-09T18:30:00+08:00",
"xlsx_updated_at": "2026-06-09T20:09:00+08:00",
"cleaned": false
}
```
字段说明:
- `original_filename`:邱律师发来的原始文件名(可能是.doc)
- `delivered_filename`:交付到任务交付目录的文件名(【修】前缀)
- `converted_filename`:workflow转换后的.docx文件名(原始是.doc时有值,否则空字符串)。**来源**:workflow的`converted_filename`字段会从classifier一路传递到deliverer/final_review输出。pass流程必须从workflow输出中提取并写入tracker。如果workflow输出中没有此字段(旧workflow跑的合同),则根据original_filename判断:以`.doc`结尾的,converted_filename = 同名`.docx`;以`.docx`结尾的,converted_filename = 空字符串
- `seq`:合同审查清单中的序号
- `delivered_at`:workflow 交付完成时间(queue-runner 写入)
- `xlsx_updated_at`:pass 时写入,北京时间ISO格式,清理脚本据此计算24h
- `cleaned`:清理脚本执行后改为true
**写入方式**:先写临时文件再rename(原子操作),防止进程中断导致JSON损坏。
```python
import json, os, tempfile
from datetime import datetime, timezone, timedelta
BJT = timezone(timedelta(hours=8))
tracker_path = os.path.expanduser('~/.hermes/data/contract-tracker.json')
# 读取现有tracker
if os.path.exists(tracker_path):
with open(tracker_path, 'r') as f:
tracker = json.load(f)
else:
tracker = {"contracts": []}
now = datetime.now(BJT).isoformat()
# 查找是否已有 delivered 记录
existing = None
for c in tracker["contracts"]:
if c.get("original_filename") == original_filename:
existing = c
break
if existing and existing.get("status") == "delivered":
# 已有 delivered → 更新为 completed(补全字段)
existing["status"] = "completed"
existing["delivered_filename"] = delivered_filename
existing["converted_filename"] = converted_filename
existing["party"] = party
existing["contract_name"] = contract_name
existing["seq"] = seq
existing["xlsx_updated_at"] = now
existing["cleaned"] = False
elif existing and existing.get("status") == "completed":
# 已经 completed → 查重命中,不重复写入
pass
else:
# 无记录 → 新建(旧合同/手动审查走这条路)
tracker["contracts"].append({
"original_filename": original_filename,
"delivered_filename": delivered_filename,
"converted_filename": converted_filename,
"party": party,
"contract_name": contract_name,
"seq": seq,
"status": "completed",
"xlsx_updated_at": now,
"cleaned": False
})
# 原子写入
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(tracker_path), suffix='.json')
with os.fdopen(fd, 'w') as f:
json.dump(tracker, f, ensure_ascii=False, indent=2)
os.replace(tmp, tracker_path)
```
### 2. 更新合同审查清单 xlsx
路径(Nextcloud):`Doro合同审查任务/合同审查清单.xlsx`
⚠️ **铁律(2026-07-03覆盖事故后确立):写xlsx前必须从Nextcloud实时拉取最新版本,禁止使用/tmp或本地任何已有的xlsx文件。** 违反此规则会用旧版覆盖新版,导致其他session已登记的记录丢失(2026-07-03实证:用240行旧文件覆盖了252行最新版,丢失12条记录)。
**强制检查流程**
1. `docker cp` 从NC拉取 → 保存到 `/tmp/合同审查清单_LIVE.xlsx`(带LIVE后缀避免与残留文件混淆)
2. 打开后先读 `ws.max_row` 和最后一行的seq → 打印确认
3. 如果发现本地已有同名文件,**必须删除后重新拉取**,不得复用
4. 追加新行后上传
步骤:
1. **从Nextcloud下载最新xlsx(必须实时拉取,禁止复用本地文件)**
2. 用openpyxl追加行(序号、日期、顾问单位全称、合同名称、文件名)
3. 从上一行复制字体和对齐样式(用copy())
4. border用Side对象重建(不用ref_cell.border直接赋值,会报unhashable错误)
5. 保存后上传回Nextcloud
6. 执行files:scan + 清OnlyOffice缓存
```bash
# 下载(铁律:必须每次实时拉取,rm掉旧文件防止复用)
rm -f /tmp/合同审查清单_LIVE.xlsx
docker cp nextcloud-nextcloud-1:/var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx /tmp/合同审查清单_LIVE.xlsx
sudo chown maggie:maggie /tmp/合同审查清单_LIVE.xlsx
# 上传
docker cp /tmp/合同审查清单_LIVE.xlsx nextcloud-nextcloud-1:/var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx
# 上传
docker cp /tmp/合同审查清单_LIVE.xlsx nextcloud-nextcloud-1:/var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx
python3 -c "import openpyxl; ws=openpyxl.load_workbook('/tmp/合同审查清单_LIVE.xlsx').active; print(f'NC当前版本: {ws.max_row}行, 最后seq={ws.cell(ws.max_row,1).value}')"
# 上传
docker cp /tmp/合同审查清单_LIVE.xlsx nextcloud-nextcloud-1:/var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx
docker exec nextcloud-nextcloud-1 chown www-data:www-data /var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx
docker exec -u www-data nextcloud-nextcloud-1 php occ files:scan --path="doro/files/Doro合同审查任务/合同审查清单.xlsx"
# 上传后验证(write-read-verify,防止覆盖事故)
rm -f /tmp/合同审查清单_VERIFY.xlsx
docker cp nextcloud-nextcloud-1:/var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx /tmp/合同审查清单_VERIFY.xlsx
python3 -c "import openpyxl; ws=openpyxl.load_workbook('/tmp/合同审查清单_VERIFY.xlsx').active; print(f'上传后验证: {ws.max_row}行, 最后seq={ws.cell(ws.max_row,1).value}')"
# 清OnlyOffice缓存
docker exec nextcloud-onlyoffice-1 bash -c 'rm -rf /var/lib/onlyoffice/documentserver/App_Data/cache/files/data/*'
docker restart nextcloud-onlyoffice-1
```
### 3. 确认并汇报
向Doro确认已标记completed,告知清单已更新到第几条。
### 批量pass后的登记完整性审计(2026-06-15教训)
⚠️ **Doro说\"都pass了\"或\"看下是否都走了pass流程\"时,必须把每一份pass的合同逐一对照 tracker JSON 和 xlsx 两个存储,找出缺漏——不能假设\"workflow跑完了/交付了\"就等于\"登记完整\"。**
2026-06-15教训:6份合同队列全部跑完并交付,Doro说6份都pass。实际核对发现只有2份(端午节、医疗急救招聘)走了完整pass流程,另外4份(金泽蛋糕、赵巷消防、练塘健康积分、徐泾维保)tracker和xlsx**全都没登记**。Doro主动提醒\"excel登记是不够的\"。原因:交付由workflow的final_review完成,但pass流程(写tracker+xlsx)是独立的人工步骤,前面几份漏做了。
**审计脚本逻辑**(用openpyxl+json对照):
1. 列出本批次所有pass合同的 `delivered_filename`(从任务交付目录或Doro的pass消息提取)
2. 读 xlsx 第E列(文件名列)建一个 set
3. 读 tracker.json 的 `contracts[].delivered_filename` 建一个 set
4. 逐份合同检查:`in xlsx?` + `in tracker?`,打印缺失矩阵
5. 对缺失的合同,**完整补走步骤0(待审查核实+正文读甲方+查重)→ 步骤1(tracker)→ 步骤2(xlsx)**,不能只补一个存储
6. 补完后重新跑一遍审计脚本,确认6份全部 ✅ xlsx + ✅ tracker
**xlsx与tracker必须成对存在**:xlsx是给人看的登记,tracker是给cleanup cron用的。只有xlsx没tracker→文件永远不被自动清理;只有tracker没xlsx→Doro的清单缺条目。审计时两个都要查。
**openpyxl写xlsx的权限陷阱**:从Nextcloud用`sudo cp`下载的xlsx属主是root,openpyxl保存时报`PermissionError`。先`sudo chown maggie:maggie`改属主到当前用户的可写副本再操作。
## 触发模式
Doro会**回复引用**某条交付通知消息并说"pass"。可能是单份也可能批量("四份都pass")。
- 引用的消息中包含交付文件名,从中提取合同信息
- 批量pass时逐份处理,每份都写tracker+xlsx
### 审查意见文件的处理
Doro可能把审查意见文件(如"朱家角审查意见-XXX.docx"、"采购协议-审查意见.docx")也列入pass清单。这些是合同的伴随交付物,**不需要单独的tracker条目和xlsx行**——它们跟随主合同的tracker记录:
- 主合同"【修】XXX.docx" → 写tracker + 写xlsx
- 伴随审查意见文件 → 不写tracker,不写xlsx
- 清理时:审查意见文件跟随主合同一起清理
### Classifier甲方误判的pass处理(2026-06-12教训)
当workflow classifier误判了顾问单位(如把"卫健事业发展中心"误判为"朱家角"),pass流程写tracker/xlsx时必须用**合同正文中的真实甲方名称**,不能用classifier的`our_party_name`
- 步骤0核对时用python-docx读取合同正文前30段,找甲方全称
- tracker的`party`字段和xlsx的"顾问单位"列写真实甲方
## 清理cron配套信息
- Cron job name: `contract-cleanup`,job_id: `4636467b715d`
- 脚本路径: `~/.hermes/scripts/contract-cleanup.py`
- 调度: 每小时,no_agent静默模式
- 逻辑: 读tracker → 找completed + xlsx_updated_at超24h + cleaned=false → docker exec删Nextcloud待审查/和任务交付/中的文件 → 标记cleaned=true → files:scan
- 安全: 只删tracker中精确记录的文件名,不通配。xlsx在上一级目录碰不到。JSON损坏时静默不操作。原子写入(tempfile+rename)
- **.doc→.docx转换残留(2026-06-11发现+修复)**:原始文件为`.doc`时,workflow会用libreoffice转换为`.docx`副本放在待审查目录。cleanup脚本已有`converted_filename`字段的读取逻辑(第113-116行),但旧workflow跑的合同tracker中缺少此字段导致转换文件残留。**治本修复**(2026-06-11已实施):在review-contract.yaml的classifier procedure中添加`converted_filename`记录步骤,从classifier→reviewer→editor→deliverer→final_review全链路传递该字段。pass流程写tracker时从workflow输出中提取。cleanup脚本自动生效。
- **手动批量清理(Doro授权模式)**:Doro可能要求按日期批量清除旧文件(不走tracker逻辑),此时按修改时间(stat %Y)过滤,在Nextcloud容器内执行rm,完成后files:scan+清OO缓存。2026-06-11执行过一次:保留6月10日及之后的文件,删除之前的全部(待审查11个+任务交付179个)。
## 已知坑
- **时间窗不能先拍脑袋,必须先用工具确认当天与"上周五"的北京时间边界(2026-07-13 再犯后补强)**:用户要求"查看北京时间上周五及今天,邱律师(查sender id)发了多少合同"时,不能直接按自己理解硬算时间窗,更不能在没交叉验证前就下"周五=0份"结论。必须至少两步核对:①先用 `TZ='Asia/Shanghai' date` 确认当前北京时间与星期;②把北京时间窗口换算成 UTC 后,再同时查 `.meta``gateway.log`。**只要 gateway.log 已出现 QiuTing 的空消息(msg='')文件记录,就不能再说该时段 0 份。**
- **查"邱律师发了多少合同"必须双源交叉,不可只靠 cache meta(2026-07-13 教训)**:仅查 `~/.hermes/cache/documents/*.meta` 可能漏掉周五文件或误判为 0。标准做法:
1. `.meta`:按 `sender_id == QiuTing` + 北京时间窗口换算后的 UTC 区间统计
2. `gateway.log`:按同一时间窗 grep `platform=wecom user=QiuTing chat=QiuTing msg=''`,这是文件消息的一手证据
3. tracker + xlsx:核对这些文件是否都 workflow 完成、是否都 pass、是否都登记 excel
4. **没有把两源对齐前,不得下"都完成了/没有遗漏"结论**
- **被 Doro 说"胡说八道,重新查,查明情况再说"时,表示你刚才的结论缺乏证据链**:此时必须回退到原始数据源重查,不得只改措辞继续硬答。正确做法:先承认上一条没查明,再用不同数据源(如从 meta 转到 gateway.log)交叉验证。
- **审查workflow交付物时必须区分原文自带修订 vs WB修订(2026-07-13 Doro指示)**:Doro要求"对照原文件,WB-1如果是原文自带修订,保持不动。你只需要看workflow是不是准确遵守了workflow的规则,包括命名规则"。具体方法:①用zipfile读原文件(待审查目录)的revision authors,确认哪些author是原文已有的 ②读交付件,按author过滤只看WB的修订 ③逐条核对WB修订是否符合审查规则 ④原文自带的修订(其他author)不评价、不报告为问题。**常见误判**:把原文WB-1的修订当成workflow的WB修订来审查——必须先确认author再下结论。
- **核查汇报必须先用工具验证再说(2026-07-01+07-02 升级为顶部红线)**:详见本skill顶部「红线:验证指令 ≠ 回忆指令」章节 + `references/audit-methodology.md`。2026-07-02 同一轮对话中此规则被违反4次,导致Doro信任破裂("到了无法信任你的程度")。不再重复列举——执行时加载顶部红线即可。
- **"确定吗?再仔细查实"(2026-07-02 追加)**:Doro说"确定吗""再仔细认真查实"时,意思是**上一条回复的结论不够可信/不够深入**。正确做法:不要只是重复上一轮的输出,而是用不同角度/更深层的工具去交叉验证。具体:①查session_search看是否有其他session做过相同操作 ②查uwf step list确认每个thread走到了哪一步 ③确认通知确实被发出(session中有_send_wecom的tool call记录)而非假设。核心原则:**每一条"确认X发生了"的结论,都必须有对应的工具调用输出作为证据。**
- **文件"消失"的排查思路(2026-07-01 教训)**:Doro发现待审查/交付目录文件不在时,不要急着说"被误删"。先排查:①tracker 的 `cleaned` 字段是否已为true(cron清理了)→ 不可能不到24h;②auto_notify 是否正常工作(文件可能从来没上传到待审查目录,workflow直接从cache路径读文件完成审查,但Doro在Nextcloud看不到);③是否是手动操作中 `docker exec rm` 删了。根因很可能是"从来没上传到待审查"而不是"上传后被删了"。
- **交付件被 Doro 手动编辑后大小/内容会变 —— pass 时不覆盖、只登记(2026-06-23 实证)**:Doro 收到交付件后可能自己在 OnlyOffice 里编辑(删掉部分批注内容、改条款等),导致 Nextcloud 上的交付文件大小/字节/md5 与 workflow 原始交付件不同;且 OnlyOffice 后台处理期间文件**大小会持续变化**,docker cp 出来用 pymupdf/python-docx 读可能是 0 页/0 批注的中间态。**看到交付件与你核验时不一致,先确认是不是 Doro 自己改的,绝不要当成文件损坏去"修复"或用本地完整版覆盖。** Doro 说 pass 后:以 Doro 改后的版本为准,只写 tracker+xlsx 登记,**不触碰交付目录里的文件**。2026-06-23 教训:交付 PDF 从 20MB 变 3.5MB 且持续变化,我误判损坏、准备用本地版覆盖,Doro 说"是我删除了一些内容,你直接做 pass 就可以"。
- **PDF 扫描件合同走批注模式,不 OCR 转 docx(2026-06-23 Doro 明确)**:收到扫描件 PDF 合同(无文字层)时,**不要纠结"OCR 转 docx 才能修订"**——workflow 对 PDF 用**批注模式**审查(高亮 Highlight + 批注气泡 Text 配对,author=WB),原文零污染,这是正常流程(seq=201 朱家角.pdf、seq=207 阳澄湖团建.pdf 都是 PDF 批注交付)。Doro 原话"PDF 的修改使用批注,这是 workflow 的正常流程"。PDF 交付件**核验/pass 时**:核批注数/author/高亮锚定位置/原文页数不变,**不套用** INS/DEL/字号/numPr 那套 docx 专项检查(final_review 对 PDF 会自动判定这些不适用)。
- **交付文件位置:根目录 `任务交付/` 是正确的(2026-06-29 确认)**:workflow deliverer 明确写着上传到 `Doro合同审查任务/任务交付/`(根目录)。不要把文件移到顾问单位子文件夹——那里不是交付目的地。pass 流程步骤0核对时,交付文件应该在根目录 `任务交付/` 中。如果 deliverer 同时上传到了子文件夹(如 final_review 越权重复上传),子文件夹里的是多余的,应清理。
- **final_review 越权重复上传(2026-06-29 教训)**:workflow 中上传是 deliverer 的职责,final_review 只负责质量检查和通知 Doro。但 LLM 执行 final_review 时可能越权上传文件(到子文件夹而非根目录 `任务交付/`),且使用完全不同的命名格式。**判别**:`stat` 比较文件大小,同内容两份不同名=重复。**处理**:保留 deliverer 上传的(【修】/【审】+ 原始文件名),删除 final_review 额外上传的。**根因**:review-contract.yaml 的 final_review procedure 需修改,明确禁止上传动作(待 WeiWei 实施)。
- **审查意见标题空壳(2026-06-29 教训)**:workflow editor 生成的审查意见文档标题是"关于《合同》的审查意见"——"《合同》"是占位符,没有替换为实际合同名称。classifier 已正确提取了 `contract_title`,但 editor 生成审查意见时没有填入。**pass 流程必须检查审查意见标题**:如果标题是"关于《合同》的审查意见",需要手动修正为"关于《{合同实际名称}》的审查意见"。修正方法:用 zipfile + lxml 读 document.xml,找到标题段落的所有 `w:t` 节点,第一个设为完整标题,其余清空。
- **【审】审查意见文件缺失(2026-06-30 发现)**:deliverer 有时只上传【修】修订版而没有上传【审】审查意见文件。**排查**:`sudo find .../任务交付/ -name "【审】*"` 检查是否有对应的审查意见。**处理**:如果缺失,需要手动生成或报告Doro确认是否需要补做。审查意见是companion文件,不影响主合同的pass流程(不需要tracker/xlsx条目),但Doro可能期望看到。
- **Workflow常见格式缺陷清单(2026-07-13 审计总结,详见 `references/pre-pass-audit-checklist-20260713.md`)**:
1. WB INS rFonts多余属性(hAnsi/cs/hint)——每份都有,每次审计必查必清
2. 新增条款标题pStyle错误(用了Style15而非Heading4等)
3. 新增标题带空格("第七条 转包"应为"第七条转包")
4. 子编号未顺延(只改了章编号,没改内部X.Y编号)
5. 赔偿上限遗漏(双向条款的20%上限未删)
6. 保密存续重复插入(原文已有同义表述)
7. 脚注未用修订格式
- **同模板合同修订一致性(2026-07-03 Doro确认:手动调整)**:不同顾问单位提交审查的合同若为相同模板,修订需保持一致(规则9已有)。但不改workflow自动化逻辑——Doro指出不一致时手动调整即可,避免矫枉过正。不需要template_type标签或修订库。
- **非合同文件进入workflow审查(2026-07-01 香花桥招标需求实证)**:classifier识别出"招标需求文件"但仍走了完整review+edit流程并交付了修订版。Doro判定此类文件无需审查,直接通知邱律师。**已修auto_notify加L1文件名预筛**(关键词:招标需求/技术方案/报价单等)。但classifier兜底层未修——仍然会把非合同当合同审。根因:workflow的routing graph没有从classifier直接到$END的non-contract路径。**pass前排查**:如果交付物对应的原始文件明显不是合同(招标需求/投标文件/会议纪要等),直接删除交付物并通知邱律师,不做pass。
- **审查意见文件是错误交付物(2026-07-01 发现)**:workflow 有时会生成不该有的审查意见文件(如合同已被其他thread审查过、或classifier误判导致多生成)。**pass前排查**:检查任务交付目录中是否有不属于本次审查的【审】文件。如果是错误交付物(如旧版残留、重复审查产物),直接删除不pass。判断标准:审查意见的标题和内容是否对应本次审查的合同,标题是否是"关于《合同》的审查意见"(占位符)。
- **cleanup脚本不处理tracker顶层"ready"条目 + 不清理本地目录(2026-07-03 查明根因)**:`contract-cleanup.py` 只遍历 `tracker["contracts"]` 数组中 `status=completed` + `cleaned=False` 的条目。但以下两类文件不会被清理:
1. **Tracker顶层字典键**(status=ready的条目):这些是workflow跑完但尚未被Doro pass的合同,存储在tracker的顶层键而非contracts数组里。cleanup脚本看不到它们。Doro pass后,pass流程会把它们从顶层搬到contracts数组并标记completed——此时cleanup才能处理。**如果pass流程有bug没搬进contracts数组,文件永远不会被清理。**
2. **本地 `~/.hermes/shared/Doro合同审查任务/待审查/` 目录**:cleanup脚本只删Nextcloud容器内的文件(`docker exec rm`),本地shared目录的副本无人管理。这些是auto_notify上传NC后留下的本地副本——NC侧被cleanup清了,本地的永远残留。
- **诊断方法**:`ls ~/.hermes/shared/Doro合同审查任务/待审查/` 如果有文件,且对应的tracker条目已经completed/ready,就是此bug。
- **临时清理**:确认文件在tracker中已completed后,手动 `rm` 本地副本即可。
- **根治**:cleanup脚本需要增加两段逻辑:①遍历tracker顶层ready条目(Doro已pass但pass流程漏搬的)②清理本地shared/待审查/中已completed的文件。
- **沟通时间统一使用北京时间(2026-07-03 Doro多次纠正)**:所有对外沟通(包括向Doro汇报workflow状态、文件接收时间等)一律使用北京时间。服务器UTC时间仅在内部日志/脚本中使用,不对用户展示。
- **Doro说"查清楚"/"你去查原因"——必须用工具验证后再给结论**:Doro要求查证时,不能凭记忆或context summary回答。必须实际调用工具(session_search、terminal读文件/目录、tracker等)取得证据后再汇报。2026-07-13教训:被要求"查清楚我哪些说了pass"时,需要在本session对话记录中找到Doro的原话,不能凭印象列清单。
- **脚注"法律顾问修订版"必须用修订格式(2026-07-13 Doro纠正)**:添加页脚文字时,必须包裹在`w:ins`元素中(author=WB),不能作为普通文本直接写入。OnlyOffice/Word中显示为带修订标记的新增内容。实现方式:用python-docx添加footer文本后,再用zipfile+lxml找到footer XML中的对应run,包裹进`w:ins`元素。
- **交付件字体大小不一致(2026-07-01 Doro纠正,根因细化)**:有两种常见成因:
1. **段内混用**:同一段落内不同run使用不同字号(如sz=21和sz=24混用)。修复:找dominant size统一。
2. **INS-only新段落缺sz**(更隐蔽):`add_clause`插入的全新段落,原文docDefaults无sz定义或sz≠邻居段落的显式sz。新段落INS run不写sz→继承docDefaults→与显式sz=24的邻居段落不一致。**诊断**:找所有INS-only段落(整段只有w:ins),检查其run的rPr是否有sz,再对比前后段落的sz。缺sz且邻居有显式sz=补上。
3. **numPr叠加**:加了手动编号但没去掉原自动编号→显示"1. 第一条"。修法见contract-editor skill的"strip numPr"规则。
- **deliverer 重复上传(同内容两份不同名)(2026-06-29 教训)**:deliverer 可能对同一合同上传两次,文件大小完全一致说明内容相同。**判别**:`stat` 比较文件大小。**处理**:保留命名规范的那份(【修】/【审】+ 原始文件名),删除另一份。
- **原文件自带【修】前缀时命名错误(2026-07-13 璞石合同实证)**:邱律师发来的文件本身已有【修】前缀(如`【修】练塘-硬件购销合同-璞石医疗2026.7.13.wps`),workflow按规则应生成`【修】【修】练塘-...docx`(原文件名一字不动,前面再加【修】),但实际只生成了`【修】练塘-...docx`(吞掉了原文的【修】前缀)。**审查时判别**:原文件名含【修】时,交付文件名应出现两个【修】。只有一个=workflow命名错误。**根因**:workflow的命名逻辑strip了原文件名中已有的【修】前缀后再加自己的。
- **Cache hash前缀混入交付文件名(2026-06-30 实证)**:deliverer 有时把 cache 文件名直接当交付文件名上传,产生类似 `【修】doc_0ad4ee63bff5_印刷品制作合同2026.6(1).docx` 的文件名。**判别**:文件名含 `doc_[a-f0-9]{12}_` 模式。**处理**:删除带 hash 前缀的那份,保留正确命名的(`【修】印刷品制作合同2026.6(1).docx`)。**根因**:deliverer 从 cache 复制文件时没有去掉 cache hash 前缀重命名。
- **companion 文件(审查意见/流程单)不会被 cleanup 自动清理 → 永久残留孤儿(2026-06-21/29 实证)**:`contract-cleanup.py` 只删 tracker 里 `original_filename`/`converted_filename`/`delivered_filename` 三个字段精确匹配的文件。companion 从不进 tracker → 主合同被清理后 companion 永远残留。**排查**:运行 `references/cleanup-audit.py` 审计脚本,列出所有 not-in-tracker 的文件。**应急清理(Doro 授权后)**:
```bash
# 必须搜索所有任务交付路径(根目录+顾问单位子目录都可能有)
sudo docker exec nextcloud-nextcloud-1 find /var/www/html/data/doro/files/Doro合同审查任务/ -path "*任务交付*" -name "*审*意见*"
# 确认后逐个删除
sudo docker exec nextcloud-nextcloud-1 rm -f "<path1>" "<path2>" ...
# 扫描+清缓存
sudo docker exec -u www-data nextcloud-nextcloud-1 php occ files:scan doro --path="/doro/files/Doro合同审查任务/"
docker exec nextcloud-onlyoffice-1 bash -c 'rm -rf /var/lib/onlyoffice/documentserver/App_Data/cache/files/data/*'
docker restart nextcloud-onlyoffice-1
```
⚠️ Companion 可能同时存在于根目录 `任务交付/` 和顾问单位子目录(如 `朱家角镇社区卫生服务中心/任务交付/`)——find 命令用 `-path "*任务交付*"` 通配搜索确保不遗漏(2026-07-06 实证:肃言/恭兴审查意见各在两个位置共4份)。
**治本方案见 `references/companion-cleanup-proposal.md`**,需 WeiWei 决策后实施。
- **Companion 判定不能靠想当然命名(2026-07-13 白鹤劳务派遣协议教训)**:Doro说“交付文件夹里的合同及companion全部做pass”时,**先查清交付文件夹里到底有哪些相关文件,再决定有没有 companion**。不要因为很多合同通常会带审查意见,就默认本案也有;也不要只看主文件名一次就下结论。正确顺序:①先在 `任务交付/` 中做**宽匹配扫描**(合同名关键词、当事人名关键词、业务关键词都要扫)②再把整个 `Doro合同审查任务/` 目录补扫一遍,确认没有藏在别的子目录里的 companion ③最后再对 tracker+xlsx 核对是否已有 completed 记录。**如果实扫结果只有主合同一份,就要明确汇报“合同1份、companion 0份”,而不是笼统说“都pass了”。**
- **Companion 命名规则(2026-07-01 Doro确认)**:交付物=`【修】{原文件名}`,companion=`【审】{原文件名去扩展名} 审查意见.docx`。pass skill 扫描时按此规则拼确定性文件名+`nc_file_exists()`查询,查到就写入 tracker 的 `companion_files` 字段。cleanup 脚本读 `companion_files` 一并删除。两头(pass skill + cleanup script)必须同步改才有效果。workflow editor/deliverer 生成审查意见时也必须强制用此命名规则。
- **手动启动workflow前必须查实auto_notify是否已处理(2026-07-03 教训)**:发现cache中有新文件时,不要直接`uwf thread start`。必须先:①查`/tmp/auto_notify_new_file.log`确认auto_notify是否已检测到并启动了workflow ②`uwf thread list | grep running\|idle`看是否已有对应thread在跑。auto_notify正常工作时会自动上传NC+启动queue runner+排队执行,手动启动会导致重复审查。2026-07-03实证:邱律师发了文件,auto_notify 30秒内就启动了workflow(thread 06FJDADW),我没查就又手动启动了第三个重复thread,被Doro纠正。
- **不判断文件是否相同/重复(2026-07-03 Doro纠正)**:邱律师发了几次、发什么文件,不是我该判断的。不要说"同上""同名文件""是重复的"——即使md5一致也不做这个判断。auto_notify和workflow自己处理,我不干涉。待审查目录里有几份就是几份,workflow跑几个就是几个。
- **沟通必须使用北京时间(多次纠正)**:所有与Doro/Maggie的时间沟通一律用北京时间,包括表格、汇报、日志引用。服务器是UTC,必须+8转换后再输出。不写UTC时间。
- **"删掉批注"指令——先验证再操作(2026-07-08)**:Doro可能要求"删掉批注,提交后做pass"。操作前必须先用zipfile检查comments.xml是否存在+commentRangeStart数量。如果文件无批注(comments.xml不存在且无comment引用元素),直接汇报"文件无批注"然后继续后续操作(提交/pass),不需要强行"删除"不存在的东西。
- **邱律师同名文件不自行判断是否重复(2026-07-03+07-09 加强)**:邱律师发的多次同名文件,不要自己判断"重复发送"。同名文件可能:①不同顾问单位②修改版(字节不同=内容变了)③同一份发了两次。**只要字节大小不同,就必须视为不同合同独立审查**。2026-07-09教训:字节差407的两份合同有3处实质性条款差异(项目名称、支付方式、合同期限),不是"重复发送"。正确做法:检查字节是否相同→不同→独立审查或私信邱律师确认。
- **交付件大小/内容突变,先确认是不是用户手动编辑了,别当损坏(2026-06-22 实证)**:pass 前看到 Nextcloud 上的交付 docx/pdf 大小骤变(如 20MB→3.5MB 还在持续变、md5 对不上本地核验版),第一反应**别**判为"文件损坏/被进程写坏"。先确认是不是 Doro 自己在 OnlyOffice 里改了(删批注、调内容)。确认是用户编辑后:**不覆盖、不动交付目录的文件,以用户改后版本为准**,直接走 pass 登记(tracker+xlsx)。**铁律:pass 登记只动台账(tracker/xlsx),交付目录里的成品文件除非用户明确要求重做,否则只读不写。**
- **`.doc` 原始文件残留(2026-06-29 实证)**:tracker 的 `original_filename` 有时记录了 `.docx`(转换后文件名)而非 `.doc`(真正的原始文件),cleanup 按 `.docx` 去删找不到 `.doc` → 残留。**排查**:cleanup-audit.py 会标记为 NOT_IN_TRACKER。**预防**:pass 写 tracker 时确保 `original_filename` 是邱律师发来的真实文件名(含扩展名),不要写转换后的文件名。
- **编号"错误"误判——markup视图双编号是正常现象(2026-06-16教训)**:OnlyOffice/Word修订视图(markup)下,自动编号列表项被删除(含他人如屠佳青的删除)或新增条款插入后,后续项会显示"(3)(2)""(4)(3)"这类双编号——前一个是删除/插入前的旧编号,后一个是接受修订后的新编号。这是track changes的正常渲染,**不是编号错误**。Doro/Maggie说某份合同"编号修改错误"时,先别急着改:
1. 用execute_code生成"接受所有修订后"的版本:删除所有`w:del`元素 + 删除带段落标记删除(`pPr/rPr/del`)的整段 + 解包所有`w:ins`(把ins的子元素提到父级再删ins壳)
2. 用OnlyOffice x2t渲染accept版PDF,pdftotext看**最终编号**是否连续正确
3. 若accept后编号正确 → workflow没改错,markup双编号是正常的,**恢复workflow原版即可,不要擅改**
4. 若要改,必须先问清Doro/Maggie指的是accept后哪一处,他人(屠佳青等)的修订按铁律不能动
- 2026-06-16教训:把端午节合同(转包6误改成7)、医疗急救招聘合同的正常编号重排误判为错误并擅自改动,被Maggie两次纠正"workflow改得都没错,恢复成workflow修订版本"。x2t accept渲染命令见本skill的"用OnlyOffice渲染自查"或contract-editor skill。
- **但确有真编号重复时要修(2026-06-15端午节实测,与上条不冲突)**:自动编号列表项(numbering.xml中`numId`对应的`abstractNum`有`start=N`、`lvlText=%1、`)渲染出的编号,与后续**手动键入数字**的tracked插入条款(`w:ins`里`w:t`文本直接以"6、""7、"开头)会真冲突。本案"售后服务"是auto-number(numId=3, start=6 → 渲染为6),紧跟的WB插入条款手动写了"6、转包限制" → 出现两个6。这是**真错误**,不是markup双编号假象。判别要点:
1. 假象(不改):同一条款同时显示两个编号"(3)(2)",是track changes接受前后的新旧编号叠加 → accept后渲染连续就别动
2. 真错(要改):两个**不同条款**各自显示同一个数字(售后服务=6、转包限制=6)→ accept后仍重复 → 必须修
3. 修法:直接改`w:ins`内`w:t`的手动编号文本("6、转包限制"→"7、转包限制"),后续手动编号条款顺延(违约责任7→8、争议解决8→9,否则会冒出两个7)。用zipfile读document.xml→字符串replace(先`assert xml.count(old)==1`确认唯一)→zipfile写回,不破坏`w:ins`修订痕迹。改完用python-docx确认可打开+遍历`w:ins`确认三条仍在修订态(author=WB)
4. Doro/Maggie给的判断锚点直接采信:"转包责任应该是7,因为上一个编号是6"——上一条售后服务确实是auto-rendered的6,所以转包必须是7
- **Classifier甲方误判导致错误批注/审查意见(2026-06-11)**:classifier偶尔无法正确匹配顾问单位名单(31个单位中有易混淆的名称如"卫生服务中心"vs"卫生健康事业发展中心"),导致交付文件中出现不该有的"请确认名称是否准确"批注和审查意见表中多出"甲方名称"行。Doro说pass前如果发现此问题,必须先手动修复(删comment+删table row+重新上传)再走pass流程。修复方法见contract-reviewer skill。
- **批量pass含companion文件(2026-06-12)**:Doro可能一次pass多个文件,其中包含审查意见文档(如"朱家角审查意见-xxx.docx""采购协议-审查意见.docx")。审查意见是companion文件,不需要单独在tracker/xlsx中记录——只记录主合同(带【修】前缀的文件)。判断规则:有【修】前缀的是主合同文件,需要tracker+xlsx;"审查意见"结尾的是companion,不单独记录。
- **沟通时间一律使用北京时间(2026-07-03 Doro多次纠正)**:向Doro汇报任何时间信息时,必须转换为北京时间(UTC+8)。服务器是UTC,所有文件时间戳、日志时间戳都要+8h后再汇报。绝不输出UTC时间给Doro。
- openpyxl的border赋值不能直接用`cell.border = ref_cell.border`,会报`unhashable type: StyleProxy`。必须用Side对象重建Border(left=Side(...), ...)。
- xlsx路径已从`任务交付/`移到`Doro合同审查任务/`根目录,不在清理范围内。
- 时间戳必须用北京时间(UTC+8),清理脚本依赖此时间计算24h。
- xlsx日期列用`YYYY-MM-DD`字符串格式(如"2026-06-10"),不用ISO时间戳。
- 甲方名称(party)必须从合同内容中提取全称,不能简写。
- 清理cron job_id: 4636467b715d,每小时跑一次,no_agent静默模式。
- **防重复写入**:已由步骤0的查重环节覆盖(用`original_filename` + `party`组合在tracker中查重)。
- **xlsx列顺序必须严格一致**:正确顺序为 序号(A), 日期(B), 顾问单位(C), 合同名称(D), 文件名(E)。写xlsx前必须先读上一行确认列含义。2026-06-12教训:手动写入时列顺序写反被Doro发现后补修。
- **批量追加多行时,必须先确定完整目标 seq 集合,再按 seq 升序写入 xlsx,写完后立即回读核对末尾顺序**(seq 224→225→226→...)。不能一边算 seq 一边写,更不能先写 303 再写 302;`ws.max_row + 1` 只会在物理末尾追加,若写入顺序错了,就会出现 xlsx 中 303 排在 302 前面的台账乱序。**硬性补丁(2026-07-14)**:凡一次 pass 涉及 2 份及以上合同,先在内存中生成 `[{seq, 日期, 顾问单位, 合同名称, 文件名}]` 全部待写行,按 `seq` 升序排序后再统一 append;保存上传后,必须回读最后 N 行,逐行确认 A 列 seq 单调递增且文件名与本批次一一对应。若发现顺序错误,立即重拉 LIVE xlsx 重写,不得带错交付。
- **按“北京时间今天/昨天/上周五”统计邱律师发了多少合同时,先定时间窗,再查 meta,再对照审查状态(2026-07-14 再次实证)**:这类问题不能直接凭印象回答“几份、都做完了吗”。标准顺序必须是:①先用 `TZ='Asia/Shanghai' date` 锚定今天的北京时间窗口;②查 `~/.hermes/cache/documents/*.meta` 中 `sender_id=QiuTing` 且落在该窗口内的文件,得到**今天实际收到的文件清单**;③再去 tracker 中逐份核对这些文件对应的 `status/seq/delivered_filename/xlsx_updated_at`;④最后才汇总结论。**重点**:meta 统计的是“今天收到几份”,tracker 统计的是“这些文件审查到了哪一步”,两者不能互相替代。
- **同名不同来源/不同顾问单位的合同,统计和汇报时必须拆开,不得混成一句“劳务派遣协议已完成”(2026-07-14)**:如果今天收到的是 `劳务派遣协议.doc`,但 tracker/任务交付里同时还存在 `【修】白鹤--劳务派遣协议.docx` 等近名文件,汇报时必须明确区分“今天收到的这份”与“其他同类/同模板/同主题文件”。不能把别的合同的 completed 记录拿来替代今天这份的审查状态。标准动作:按 `original_filename` 精确对 tracker 命中,再补充说明是否另有近名已完成文件。
- **被用户要求“再核查一次”时,必须升级为四路交叉核查,不得只复述上一轮结论(2026-07-14)**:对“是不是同名合同但顾问单位不同”“今天收到的到底是哪几份”这类追问,重查时至少同时核:①Nextcloud 全目录文件扫描(不只根目录任务交付)②tracker 命中记录 ③xlsx 命中记录 ④cache meta/原件名称与正文中的甲方或项目编号。回复必须把四路结果并列写出,再下结论。不能只说“我刚才已经看过了,结论不变”。
- **自动 cleanup 依赖 tracker 文件名与 Nextcloud 实际文件名精确一致(2026-07-14)**:若发现“历史台账文件名”和“当前 NC 实际文件名”不一致,即使该条记录已经 completed,仍要同步修正 `tracker.original_filename` / `tracker.delivered_filename`,必要时同步修正 xlsx 第 E 列文件名,确保三者一致:①tracker ②xlsx ③NC 实际文件。否则 cleanup 按精确文件名匹配时会漏删或误判。修正后必须再次核对目标待审查文件与任务交付文件在 NC 中确实存在。
- **用户只要结果,不要过程解释时,汇报必须收敛成一句结论(2026-07-14 Doro纠正)**:当用户明确说“我就要一个结果”时,禁止继续附加过程说明、背景铺垫、风险解释或“补一句严谨的”。此类场景只回复最终结论,例如“能。”、“已完成。”、“不能,需要X条件。”;如确需补充,等用户追问后再展开。
- **统计“今天收到的合同”时,先答收到清单,再答审查状态,别把两件事揉成一句笼统结论(2026-07-14)**:推荐输出结构固定为:①今天收到几份;②逐份列出收到时间;③逐份列出审查状态(未启动 / 审查中 / delivered / completed);④如有同主题近名文件,单列“另有近名已完成文件,不等于今天这份”。这样可以避免把“收到数量”和“已完成数量”说串。
- **乙方空白时的处理**:合同乙方名称空白→不问Doro"该问谁",直接查文件来源(cache/documents时间戳+session_search找发送人),确定是邱律师发的就直接私信邱律师询问。Doro可能说"邱律师回复了就直接补上 我不管了"——意思是邱律师回复后自主完成tracker+xlsx更新,不再找Doro确认。
- **私信邱律师的send_message路由问题**(2026-06-12):`send_message(target='wecom:QiuTing')`会路由到home channel而非QiuTing私信。必须用`_send_wecom(extra, 'QiuTing', msg)`直接发送才能到私信。
- **私信任意企微用户的首选方法 = `wecom_dm.py` 脚本(2026-06-21 再犯后确立)**:`send_message(target='wecom:<user>')` 对企微用户名会**静默回退到 home channel**(JiaQian),既发不到目标、又可能违反信息隔离(把 Doro 团队内容发进 Maggie 渠道)。`_send_wecom(extra, ...)` 需要 gateway 的 `extra` 上下文,在 terminal/execute_code 里拿不到。**最稳的通用方法**是独立脚本:`python3 ~/.hermes/scripts/wecom_dm.py --to <别名> --text "内容"`——自己开 WebSocket + `aibot_send_msg` + `chat_type=1` 发主动私信,永不串号、目标唯一确定。白名单别名:doro / jiaqian(贾茜Maggie) / qiuting(邱律师) / weiwei(技术支持) / shasha(苌莎莎) / yangayi(颜伽艺) / xiaonan。先 `wecom_dm.py --list` 核对别名→userid 再发。**铁律:私信非 home 的企微用户,一律走 `wecom_dm.py`,绝不用 `send_message(target='wecom:X')`。** 2026-06-21 教训:给魏玮发技术讨论用了 `send_message(target='wecom:WeiWei')`,静默落到 JiaQian home channel,既没到魏玮又把 Doro 团队的脚本细节泄露进 Maggie 渠道,改用 `wecom_dm.py --to WeiWei` 才真正送达。
- **不干涉workflow铁律(2026-07-03 Doro纠正)**:发现邱律师发了新文件时,**禁止直接手动启动workflow**。必须先查 `/tmp/auto_notify_new_file.log` 和 `uwf thread list` 确认auto_notify是否已经处理。不判断文件是否重复("你也不要去判断是不是同一份文件")——让系统自己处理。2026-07-03教训:auto_notify已正常工作并启动了workflow,我没查就手动又启动了一个重复的。
- **手动启动workflow前必须查实auto_notify是否已处理(2026-07-03 Doro纠正)**:发现邱律师发了新合同时,**第一步不是手动启动workflow**,而是:①查`/tmp/auto_notify_new_file.log`确认auto_notify是否已检测并处理 ②查`uwf thread list | grep running\|idle`确认是否已有对应thread在跑。只有确认auto_notify没处理(日志无记录+无相关thread)才手动启动。2026-07-03教训:auto_notify已正常触发并启动了workflow,我没查就又手动启动了一个重复的。**"收到即启动"的前提是"确认没有被自动处理"。**
- **不判断邱律师发的文件是否重复/相同(2026-07-03 Doro纠正,2026-07-08 再次验证)**:邱律师多次发送同名文件时,不要自行判断"是同一份发重了"。存在同文件名但不同版本、不同条款内容的可能性。2026-07-08实证:同日发两份"2026年华新镇公立中小学生健康体检服务合同.docx"(11:05和16:04),文件大小不同(27889 vs 27482 bytes),实际条款差异显著(项目内容、付款方式、合同起始日均不同)——完全是两份实质不同的合同版本。queue-runner因同名文件已在done/直接SKIP导致第二份没审查。**核查方法**:用python比对两文件文本差异(zipfile提取全文+difflib对比),有实质差异则为不同合同/不同版本,须独立审查。**报告时绝不说"重复发送"**——Doro问"如何判断是重复发送"即提醒不该自行判断。
- **恢复文件到workflow原始交付状态(2026-07-13 实证)**:Doro要求撤销手动修改、恢复到workflow交付版本时,三个来源按优先级尝试:①NC版本历史(`files_versions/`目录下的`.vXXXXXXXXXX`文件,时间戳最早的=workflow首次上传版本)②`/tmp/pass_check_*`文件(之前做pass检查时从NC拉取的快照,如果当时还没手动修改则=workflow版本)③重新跑workflow(最后手段)。恢复后必须`docker cp`回NC + `chown www-data` + `occ files:scan`。
- **Tracker重复条目(同一合同不同文件名)**:workflow跑多轮或auto_notify重复触发时,tracker可能出现同一合同的多条delivered记录(文件名带版本后缀如`_v0113.doc`vs不带的)。批量pass时需注意:只pass实际在任务交付目录中的那份(【修】前缀的),旧的重复delivered条目如果文件名与NC上的交付物不匹配,不影响pass流程但会残留在tracker中(cleanup会因找不到文件而跳过)。
- **手动启动前必须先验证auto_notify是否已处理(2026-07-03铁律)**:发现邱律师新文件后,**禁止**直接手动启动workflow。必须先:①查`/tmp/auto_notify_new_file.log`确认auto_notify是否已检测并处理该文件 ②`uwf thread list | grep running`确认是否已有workflow在跑。只有确认auto_notify确实没工作(进程死亡或模式B静默失效)才手动介入。2026-07-03教训:auto_notify已正常触发workflow,但我没查就手动又启动了一个重复的,被Doro纠正"不要去干涉workflow"。**邱律师发多次同名文件不自行判断是否相同**——让系统处理,同时私信邱律师确认。
- **交付通知丢失的诊断与补发(2026-07-09 实证)**:Doro说"有几份合同我没有收到交付通知"时,按以下流程排查:①检查tracker中status=delivered的记录 ②查gateway.log在对应delivered_at时间前后是否有846609/Timeout/WS closed错误 ③确认是WS断连导致fire-and-forget通知丢失 ④用`wecom_dm.py --to doro`补发。**根因是企微WS凌晨不稳定+通知无重试机制,详见 `references/notification-ws-failure-pattern-20260709.md`。** 临时止血=手动补发;根治=watchdog增加notification_sent检查和补发逻辑(方案待实施)。
- **通知静默丢失——workflow完成但Doro没收到通知(2026-07-09 实证)**:workflow全流程跑完(thread status=end, tracker=delivered),但Doro说没收到交付通知。根因:`final_review`步骤通过`_send_wecom`发通知时,企微WebSocket已断连(errcode 846609: aibot websocket not subscribed),发送静默失败,无重试机制。**症状**:Doro说"没收到通知" + tracker有delivered记录 + `grep '846609' ~/.hermes/logs/gateway.log`在final_review执行时间段有报错。**诊断**:①查tracker找delivered_at时间 ②查gateway.log该时间±5分钟有无846609/WebSocket error ③确认final_review步骤确实跑完了(`uwf step list <thread>`有final_review且thread status=end)。**补救**:用`python3 ~/.hermes/scripts/wecom_dm.py --to doro --text "合同审查完成通知(补发):..."`手动补发。**预防**:目前无自动重试机制。如果发现当天有WebSocket中断记录,主动检查该时段内完成的所有workflow是否通知成功——以gateway.log中有对应的"Sending response...to doro"记录(不含后续846609 error)为准。
- **"delivered但Doro没收到通知"诊断(2026-07-09 实证)**:Doro说"有几份没收到交付通知"时,根因通常是final_review发通知时WebSocket已断(846609错误)。Workflow全程跑完(classifier→reviewer→editor→reviewer→deliverer→final_review),queue-runner标记delivered,但final_review内的`_send_wecom`因WS断连静默失败。**诊断步骤**:①tracker找status=delivered的合同 ②gateway.log搜846609和"WebSocket error"确认断连时段 ③`uwf step list <thread_id>`确认final_review确实执行过(有时长=执行了) ④确认交付文件在任务交付目录存在。**补救**:用`wecom_dm.py --to doro --text "合同审查完成通知(补发):..."`补发。补发内容含:合同名、顾问单位、修订数(ins/del计数)、主要修订摘要。注明"因XX时段企微WebSocket中断未实时送达,现补发"。
- **Watchdog "suspended + already delivered" 死循环(2026-07-08 实证)**:当 workflow 在 `final_review` 阶段因 HTTP 500 suspended,但 deliverer 已经成功交付(tracker=delivered)时,watchdog 会无限循环 BLOCK resume 且不归档文件,同时阻止 runner 重启。表现:Doro 没收到通知但文件已在任务交付目录。**诊断**:`tail /tmp/contract-queue/watchdog.log | grep "BLOCK resume"`。**止血三步**:①`uwf thread cancel <thread_id>` 取消所有suspended thread ②`mv /tmp/contract-queue/<files> /tmp/contract-queue/done/` 清空queue ③对已delivered的合同直接做pass(tracker delivered→completed + xlsx追加)。**注意**:可能影响一批合同(2026-07-08是5份同时卡住),需全部处理。详见 `references/watchdog-suspended-delivered-deadlock-20260708.md`。
- **同名文件判重铁律(2026-07-08 华新镇体检合同教训,Doro明确要求)**:判断两份合同是否为"同一份",**绝不能只看文件名**。邱律师经常对同一份合同发送修改版(文件名不变但内容已改),也可能不同顾问单位使用相同文件名。**判断标准——必须比对合同实质内容**:
1. 甲方(顾问单位)名称
2. 金额/费用条款(总价、单价、费用上限等关键数字)
3. 合同期限(起止日期)
4. 项目内容描述
5. 字节大小
**以上任何一项不同 → 不同合同/新版本,必须独立审查。全部相同 → 重复发送,可跳过。**
此规则适用于所有环节:auto_notify入队、queue-runner启动前、手动操作、以及任何需要判断"是否已审查过"的场景。auto_notify已通过 `~/.hermes/scripts/contract_content_compare.py` 自动执行内容比对。
**2026-07-08实证**:华新镇体检合同同日发送两版(11:05和16:04),文件名完全相同,但第二版增加了费用上限17万元、项目名称加了"华新镇"、起始日期从9月10日改为9月1日——是实质不同的合同版本。因系统只按文件名判重,第二版被跳过未审查。详见 `references/queue-runner-same-name-different-content-20260708.md`。
- **auto_notify脚本依赖与失败处理**:如果邱律师的合同没有自动启动workflow,先检查`auto_notify_new_file.sh`是否还活着(`ps aux | grep inotifywait`)。该脚本没有守护机制,会静默死亡。**两种失败模式**:
- **模式A:进程死亡** — inotifywait进程不存在。watchdog cron (`63bb31d4f050`) 会自动重启。
- **模式B:进程活着但事件静默丢失(更隐蔽)** — 进程在跑但inotifywait没有捕获到任何事件(日志完全为空)。原因可能是:inotifywait的文件描述符失效、文件系统事件被内核丢弃、脚本启动时文件已经到达(race condition)。watchdog无法修复此模式,必须人工介入。
- **诊断方法**:`cat ~/.hermes/logs/auto_notify.log | grep 日期` — 如果日志为空但meta文件有当天文件,就是模式B。
**fallback流程**:
1. 检查 `~/.hermes/cache/documents/` 中是否有未处理的文件(按meta时间戳筛选今天邱律师发的)
2. 手动复制到 `Doro合同审查任务/待审查/`(用 `sudo cp` + `sudo chown www-data:www-data`)
3. 逐个启动 workflow(`uwf thread start review-contract -p "..."`)
**⚠️ 主动发现铁律(2026-07-03教训)**:任何操作过程中(查私信状态、查文件、核对数据等),如果发现cache/documents/中有未处理的邱律师文件(meta显示sender_id=QiuTing但对应文件不在待审查目录),必须**立即中断当前任务**,先上传+启动workflow,再继续原任务。"收到即启动"不只是auto_notify的职责——手动发现的文件也必须立即处理,不能"等会再说"。2026-07-03实证:邱律师14:27发了新合同,我在14:30+检查私信时看到了meta记录但没有立即启动workflow,直到Doro追问才处理。
4. **多份合同时串行执行**:写 relay 脚本(等当前 thread `status=end` 后再启动下一个),因为所有合同共用 `/tmp/contract-review/` 工作目录,并行会互相覆盖
5. **后台执行+通知**:`uwf thread exec <thread_id> --count 20 --background`,配合 `notify_on_complete=true`
6. 可选:设 cron job 每15分钟检查 relay 进程是否存活,挂了自动重启
**2026-06-29 实证**:邱律师发了12个文件,auto_notify 没运行,只有4个自动进了 workflow。手动把剩余4个从 cache 移到待审查,写 relay 脚本串行执行,成功完成。
**2026-06-30 实证(模式B)**:邱律师发了4份合同,auto_notify进程在跑但日志完全为空(inotifywait静默失效)。手动处理全部4份。
- **Subagent/delegate_task 的清理禁令**:给 subagent 的 context 中必须明确写入"禁止删除 Nextcloud 待审查/和任务交付/目录中的任何文件。只做被要求的操作(写tracker/写xlsx/上传),不做任何清理"。2026-07-01教训:subagent 在执行 pass 操作时可能做了多余的清理动作导致文件丢失。
- **xlsx文件名列必须统一用delivered_filename**(带【修】或【无修改意见】前缀),不能写原始文件名。
## 自动清理架构(2026-06-11确认,2026-07-01补充)
活跃 cron 列表(截至 2026-07-02):
- **`contract-cleanup`**(job_id: `4636467b715d`):每小时,no_agent,deliver=local。清理 pass 超 24h 的文件。
- **`contract-queue-watchdog`**(job_id: `3174518affda`):每20分钟,no_agent,deliver=local。巡检 queue-runner 是否存活,必要时重启。⚠️ 此 cron 有已知 bug:当 queue/ 中有文件未移入 done/ 时会反复重启 runner 导致重复审查(详见 `references/queue-runner-duplicate-bug-20260702.md`)。**修复前需确保 runner 加了 tracker 查重逻辑。**
- **`auto-notify-watchdog`**(job_id: `63bb31d4f050`):每5分钟,no_agent。守护 auto_notify_new_file.sh 的 inotifywait 进程。
- 旧版`清理已交付合同`(agent模式/每6h/deliver到wecom:doro)和`合同审查调度`(每15分钟轮询)已于2026-06-11删除,与现有机制功能重复
- 新文件监控由 `auto_notify_new_file.sh`(inotifywait实时事件驱动)独立承担,不再有cron轮询
### ⚠️ 文件删除权限铁律(2026-07-01 Doro纠正)
**只有 cleanup cron 有权删除待审查/和任务交付/目录中的文件。** 手动操作(包括小Maggie和subagent)不得直接 `docker exec ... rm` 删除这两个目录的文件。
唯一例外:Doro 明确指令删除特定文件(如"删掉这个招标需求")。
2026-07-01教训:小Maggie在执行替换/清理操作时,`docker exec rm` 误删了4份已pass但未满24小时的合同原始文件(徐泾北大居、印刷品、健康积分华新、银发健康包)。文件不在trashbin(docker exec rm绕过trashbin),cleanup cron全天silent确认没动,是手动操作误删。
### Companion 文件清理方案(2026-07-01 确认)
**Companion 类型因顾问单位而异**(不统一为"审查意见"):
- 朱家角:审查意见文档(从模板生成)
- 爱卫中心:合同流程单(xlsx)
- 练塘:脚注(加在合同里,非独立文件,不需清理)
- 其他:无
**方案**:deliverer 上传后写 manifest → pass skill 读取 manifest 写入 tracker `companion_files` → cleanup 读取并一并删除。
**manifest 位置**:Nextcloud 任务交付目录下 `.delivery-manifest-{原文件名去扩展名}.json`(隐藏文件)。
**待落地**:cleanup 脚本需增加读取 `companion_files` 字段的逻辑;deliverer YAML 需增加写 manifest 步骤。
### 清理时机明确定义
- **触发条件**:Doro 说 pass → 写入 tracker(`xlsx_updated_at` 记录当前北京时间)
- **清理时机**:cleanup cron 每小时检查 tracker,找 `status=completed` + `xlsx_updated_at` 超过24小时 + `cleaned=false` 的记录
- **即:pass 后 24 小时清理**,不是交付后24小时、不是workflow结束后24小时
- **清理范围**:精确删除 tracker 中记录的 `original_filename`(待审查/)+ `delivered_filename`(任务交付/)+ `converted_filename`(待审查/,.doc转.docx时有值)
- **不清理的**:companion 文件(审查意见)不在 tracker 中,不会被自动清理;xlsx 在上一级目录不受影响
## 防重复审查(2026-07-01 朱家角标识牌 + 2026-07-02 夏阳/家庭医生签约)
**已pass合同被重复审查交付的根因**:
1. **relay-runner/auto_notify 不查 tracker**(2026-07-01):启动 workflow 前不检查 tracker 是否已有 completed 记录。manifest 文件残留已 pass 合同的文件名,被重新捡起来跑了一遍。
2. **queue-runner + watchdog 交互 bug**(2026-07-02,详见 `references/queue-runner-duplicate-bug-20260702.md`):watchdog 每20分钟重启 runner(因 done/ 不满 manifest 行数),runner 的 SKIP 逻辑只查文件是否在 queue/ 目录,**不查 tracker**。一天内 watchdog 重启 runner 41次,导致夏阳和家庭医生签约被重复审查并重复通知 Doro。
**铁律**:任何触发 workflow 的流程(auto_notify / relay-runner / queue-runner / 手动启动),**启动前必须检查 contract-tracker.json**:
**查错后否认(2026-07-01)**:Doro问朱家角标识牌怎么重复了,回答说"你没pass"——实际查 tracker 发现 seq=229 早在6/27就pass了。**被问任何合同状态时,先查 tracker/xlsx 用工具验证再回答,不凭"印象"。**
**Queue-runner + Watchdog 重复审查(2026-07-02 实证,详见 `references/queue-runner-duplicate-review-20260702.md`)**:runner等worker时异常退出→worker独立完成(含通知)→文件没移入done/→watchdog重启runner→重新审查→重复通知。**止血**:杀重复进程→清queue→移文件到done/。**Doro说收到重复通知时,按reference文件中的止血SOP执行。**
```bash
# 在 uwf thread start 之前(精确版,匹配 original_filename + status)
if python3 -c "
import json, sys
t = json.load(open('$HOME/.hermes/data/contract-tracker.json'))
completed = [c['original_filename'] for c in t['contracts'] if c['status']=='completed']
sys.exit(0 if '${FILENAME}' in completed else 1)
" 2>/dev/null; then
echo "SKIP: already completed in tracker"
# 移入 done/ 防止 watchdog 下次重启时再次尝试
mv "$QUEUE_DIR/$FILENAME" "$QUEUE_DIR/done/" 2>/dev/null
exit 0
fi
```
**手动启动时的检查**:用 `python3 -c "import json; ..."` 精确检查 original_filename + status=completed 组合。
**queue-runner 止血方法**(重复通知正在发生时):
1. `kill` runner 进程和 background-worker 进程
2. 将已完成的文件全部移入 `done/`:确保 `done/` 数量 ≥ manifest 行数
3. 验证后 watchdog 自然停止重启(进度检查通过)
## 铁律
- **待审查目录原文件:Doro说pass之前严禁删除(2026-07-01 Doro纠正)**:无论审查了多少轮、出了多少个修订版本,原文件必须留在待审查目录,直到Doro明确说pass。违反此规则等于丢失原始文件。2026-07-01教训:重新审查反委托代发工资和生育友好合同时,把原文件从待审查删了(以为已经审查完),Doro发现后要求恢复。恢复方法:从cache/documents/复制原文件回到待审查目录。
- **Nextcloud 待审查/和任务交付/目录文件:只有 cleanup cron 有权删除(2026-07-01确立)**:手动操作(包括小Maggie本人、subagent/delegate_task)不得使用 docker exec rm 删除这两个目录的文件。唯一例外:Doro 明确指令删除特定文件(如"删掉这个招标需求")。2026-07-01教训:今天pass的4份合同(徐泾北大居、印刷品、健康积分、银发健康包)原始文件和交付文件在pass后不到24小时即被删除,tracker显示cleaned=False,cleanup cron全天silent——是手动操作误删。根因无法追溯。
- **手动操作时 workflow 规则同样适用(2026-07-01确立)**:手动执行合同审查相关操作(修订、交付、清理)时,必须遵守 workflow YAML 中各角色的职责边界和规则约束。不能因为"我知道怎么做"就跳过规则。
- **不主动生成审查意见文档(2026-07-01 Doro纠正)**:除非Doro明确要求,否则pass流程只处理【修】修订版,不主动生成【审】审查意见文档。审查意见是额外交付物。
- **方案不等于授权执行**
- 新方案提出后,Doro会问"会有什么影响吗"——必须主动分析潜在影响(token消耗、误判风险、时区问题、单点故障、竞态条件等),不能只说好处。被要求"再想一想不要有漏洞"时,逐一列出漏洞清单+解法,不能遗漏。复杂度过高时Doro会直接砍掉——接受并简化。
@@ -0,0 +1,91 @@
# 合同审查完整性审计方法 (Audit Methodology)
## 触发条件
Doro说"查一查""核查""核实""是不是都审查了/pass了/登记了"→ 这是**验证指令**。
## 铁律
**回复中必须先有工具调用再有结论。context记忆≠查证,不可直接输出。**
## 时区转换(铁律)
服务器时区UTC,Maggie/Doro/邱律师北京时间(UTC+8)。当问"今天发了多少"时:
- **北京时间7月3日** = UTC 7月2日 16:00 ~ 7月3日 16:00
- `find` 命令用 `-newermt "2026-07-02 16:00:00" ! -newermt "2026-07-03 16:00:00"`
-`TZ='Asia/Shanghai' date` 确认当前北京时间
**典型错误**:用UTC当天(00:00-24:00)筛选→会把北京时间前一天下午的文件算进来、漏掉当天上午的文件。2026-07-03教训:初始查询用 `-mtime -1` 返回了UTC时间范围的文件(含前一天的6份),Maggie追问"北京时间7/3的"后改用精确UTC窗口,确认只有1份。
## 必须覆盖的数据源(缺一不可)
### 1. Tracker JSON
```bash
cat ~/.hermes/data/contract-tracker.json
```
- 按seq范围筛选
- 逐条列出original_filename, party, status, xlsx_updated_at
### 2. xlsx(与tracker交叉比对)
```bash
sudo docker cp nextcloud-nextcloud-1:/var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx /tmp/
```
- openpyxl读取,逐行比对tracker
### 3. Gateway log - 全部接收渠道
```bash
# QiuTing私信文件(空消息=文件附件)
grep 'user=QiuTing.*chat=QiuTing' gateway.log | grep "msg=''"
# Doro私信文件
grep 'user=doro.*chat=doro' gateway.log | grep "msg=''"
# Doro群文件
grep 'user=doro.*chat=wrbAFkXAAAiWC3styKqNj0bZyH6BbJ_Q' gateway.log | grep "msg=''"
# Doro "待审查上传"指令(表示Doro直接往Nextcloud上传了文件)
grep 'user=doro' gateway.log | grep -i '待审查.*上传\|上传.*新.*合同'
# 飞书渠道
grep 'feishu.*ou_757f053c9d7aff6c73b18aa60c337756' gateway.log | grep 'media='
```
### 4. Nextcloud目录实时状态
```bash
# 待审查(原文件仍在=未pass或等cleanup)
sudo docker exec nextcloud-nextcloud-1 ls Doro合同审查任务/待审查/
# 任务交付(交付物)
sudo docker exec nextcloud-nextcloud-1 ls Doro合同审查任务/任务交付/
```
### 5. 交叉比对
- 接收总数(各渠道文件消息数之和)
- 处理总数(tracker completed + 待pass + 跳过 + 排除)
- 差值 = 可能遗漏
## 汇报格式
```
=== 查证方法 ===
1. 读了什么(tracker/xlsx/gateway log哪些渠道/Nextcloud哪些目录)
2. 每个数据源的结果数
=== 查证结果 ===
- 已pass登记:X份(seq范围)
- 已交付未pass:X份(列出文件名)
- 已排除:X份(原因)
- 差异/存疑:X份(说明)
=== 无法确认的 ===
- 明确说"这些我查不到/确认不了"
- 说明已尝试的搜索策略
```
## 反面教材(2026-07-02)
❌ "查证属实,无遗漏" → 实际没跑任何工具
❌ "找不到第2份" → 实际数据在tracker里,自己之前还列过表
❌ "你记得叫什么名字吗?" → 把验证责任转嫁用户
✅ 正确做法:跑完全部5个数据源 → 列出原始数据 → 标注不确定项 → 再给结论
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Audit 待审查 and 任务交付 directories against tracker.
Usage:
python3 ~/.hermes/skills/legal/contract-pass-workflow/references/cleanup-audit.py
Prints a matrix showing which files are:
- In tracker (and their status/age/cleaned flag)
- NOT in tracker (orphans that will never be auto-cleaned)
- Should be cleaned (>24h + completed + cleaned=false)
Does NOT delete anything. Pure diagnostic.
"""
import json, os, subprocess
from datetime import datetime, timezone, timedelta
BJT = timezone(timedelta(hours=8))
now = datetime.now(BJT)
TRACKER = os.path.expanduser('~/.hermes/data/contract-tracker.json')
BASE = os.path.expanduser('~/nextcloud/data/data/doro/files/Doro合同审查任务')
待审查 = os.path.join(BASE, '待审查')
任务交付 = os.path.join(BASE, '任务交付')
def nc_ls(path):
"""List files in a Nextcloud-managed directory (needs sudo)."""
r = subprocess.run(['sudo', 'ls', path], capture_output=True, text=True)
return [f for f in r.stdout.strip().split('\n') if f] if r.stdout.strip() else []
def file_age_hours(path):
"""Get file age in hours from mtime."""
r = subprocess.run(['sudo', 'stat', '-c', '%Y', path], capture_output=True, text=True)
if r.stdout.strip():
mtime = int(r.stdout.strip())
return (now - datetime.fromtimestamp(mtime, tz=BJT)).total_seconds() / 3600
return -1
def main():
with open(TRACKER) as f:
tracker = json.load(f)
# Build lookup: filename -> list of tracker records
lookup = {}
for c in tracker['contracts']:
for key in ['delivered_filename', 'original_filename', 'converted_filename']:
fn = c.get(key, '')
if fn:
lookup.setdefault(fn, []).append(c)
orphans = []
for label, directory in [('待审查', 待审查), ('任务交付', 任务交付)]:
print(f"\n{'='*70}")
print(f" {label} ({directory})")
print(f"{'='*70}")
files = nc_ls(directory)
if not files:
print(" (empty)")
continue
for fn in sorted(files):
records = lookup.get(fn, [])
age = file_age_hours(os.path.join(directory, fn))
is_companion = any(k in fn for k in ['审查意见', '合同流程单'])
if records:
for r in records:
ts = r.get('xlsx_updated_at', '')
h = (now - datetime.fromisoformat(ts)).total_seconds() / 3600 if ts else -1
should = r['status'] == 'completed' and h > 24
flag = '🔴 SHOULD_CLEAN' if should else '⏳ waiting'
print(f" {fn}")
print(f" seq={r['seq']} | {h:.0f}h | cleaned={r.get('cleaned')} | {flag}")
else:
tag = '📋 COMPANION_ORPHAN' if is_companion else '⚠️ NOT_IN_TRACKER'
print(f" {fn}")
print(f" {tag} | age={age:.0f}h")
orphans.append((label, fn, age))
if orphans:
print(f"\n{'='*70}")
print(f" ORPHANS SUMMARY: {len(orphans)} files not tracked")
print(f"{'='*70}")
for label, fn, age in orphans:
print(f" [{label}] {fn} ({age:.0f}h old)")
if __name__ == '__main__':
main()
@@ -0,0 +1,23 @@
# Companion 文件清理修复方案(待 WeiWei 实施)
## 问题
cleanup cron (`contract-cleanup.py`) 只删 tracker 里有记录的文件。审查意见、合同流程单等 companion 文件按规则不单独写 tracker,主合同被清理后 companion 成为孤儿,永远留在 `任务交付/`
## 2026-06-29 实证
清理了 11 个 orphan companion 文件(6个合同流程单 xlsx、5个审查意见 docx),最老的残留 91 小时。
## 修复方案(方案 A,推荐)
### 1. pass workflow (skill) 改动
步骤1写 tracker 时,扫描 `任务交付/` 目录,找到与主合同同名的 companion 文件,写入 `companion_files` 字段。
### 2. cleanup 脚本改动
删主合同时,读 `companion_files` 字段,一并删除。
## .doc 原始文件残留修复
cleanup 删 `original_filename` 时,如果文件不存在,尝试同名但换扩展名(.doc 换 .docx 或反之)。
## 状态
- 2026-06-29:方案已提交给 WeiWei 讨论
- 待 WeiWei 确认后实施代码改动
@@ -0,0 +1,36 @@
# Companion 文件清理方案(待 WeiWei 决策)
## 问题
`contract-cleanup.py` 只删 tracker 里 `original_filename` / `converted_filename` / `delivered_filename` 三个字段精确匹配的文件。companion 文件(审查意见、合同流程单)从不进 tracker → 主合同被清理后 companion 成孤儿,永远残留。
## 方案 A:tracker 增加 companion_files 字段(推荐)
**改动1:pass workflow(skill contract-pass-workflow)**
步骤1写 tracker 时,扫描 `任务交付/` 目录,找到与主合同同目录且包含"审查意见"/"合同流程单"的文件,写入:
```json
"companion_files": ["【审】购销合同 审查意见.docx", "【审】合同流程单-xxx.xlsx"]
```
**改动2:cleanup 脚本(~/.hermes/scripts/contract-cleanup.py)**
删主合同时,读 `companion_files` 字段,逐个 `docker exec rm`
**优点**:精确匹配,不误删。
**缺点**:需改两处(skill + 脚本)。旧记录无此字段,但不影响——旧 companion 已手动清理或无价值。
## 方案 B:cleanup 按 stem 模糊匹配
**改动:仅 cleanup 脚本**
删主合同时,取 `delivered_filename` 的 stem(去扩展名),在 `任务交付/` 目录找 `*{stem}*审查意见*` / `*{stem}*合同流程单*` 一并删除。
**优点**:只改一处。
**缺点**:模糊匹配有误删风险(如两个合同名相近)。
## 附加修复:.doc 原始文件残留
**问题**:tracker 的 `original_filename` 有时记录了 `.docx`(转换后文件名)而非 `.doc`(真正的原始文件),cleanup 按 `.docx` 去删找不到 `.doc` → 残留。
**修复**:cleanup 脚本删 `original_filename` 时,如果文件不存在,尝试换扩展名(`.doc``.docx`)再找一次。兜底逻辑,不影响正常流程。
## 状态
- 2026-06-29:方案已提出,Doro 未授权执行
- 需 WeiWei 决策后实施
@@ -0,0 +1,36 @@
# Contract Processing Completeness Audit
When asked "have all contracts been reviewed/registered" for a date range, follow this exhaustive verification method. Do NOT answer from memory — every claim must be tool-verified.
## Input Channels to Check (ALL of these)
1. **QiuTing private messages** — gateway.log `user=QiuTing chat=QiuTing msg=''` (empty msg = file)
2. **Doro private messages** — gateway.log `user=doro chat=doro msg=''` (empty msg = file)
3. **Doro group messages** — gateway.log `user=doro chat=wrbAFkXAAAiWC3styKqNj0bZyH6BbJ_Q msg=''`
4. **Feishu messages** — gateway.log feishu platform entries with `media=` indicators
5. **Direct Nextcloud uploads** — Doro uploads directly to 待审查/ without going through gateway (indicated by Doro saying "待审查里上传了新合同" without a preceding file message)
## Cross-Reference Procedure
```
Step 1: Count ALL file-receive events per channel in date range (gateway.log grep)
Step 2: Read tracker JSON — list all entries in date range by seq
Step 3: Read xlsx — verify 1:1 match with tracker
Step 4: Check 待审查/ directory for unprocessed files
Step 5: Check 任务交付/ for delivered but un-tracked files
Step 6: Reconcile: total received (Step 1) vs total tracked (Step 2)
Account for: duplicates (Doro said "重复了 不用审了"), non-contracts (excluded),
multi-file batches, files awaiting pass
```
## Critical Rule
**Never say "查证属实无遗漏" unless ALL channels have been checked and reconciled.** If a channel cannot be fully verified (e.g., log doesn't record filenames for empty messages), state the uncertainty explicitly.
## Pitfalls (from 2026-06-29 incident)
- Doro sends files via private chat AND uploads directly to Nextcloud — both channels must be checked
- Gateway log records `msg=''` for file messages but does NOT record the filename — you cannot map file→contract from log alone
- When Doro says "2份 顾问单位是X", the number must be verified against tracker entries matching that party
- A contract may be tracked under a different party name than expected (e.g., "重固卫生服务中心" contract could be filed under the actual contract title without "重固" in it)
- Session memory is NOT verification — "I remember processing it" is not evidence
@@ -0,0 +1,51 @@
# Manual Review Checklist (2026-07-13 session)
When Doro asks to "审查workflow修改的情况" on delivered contracts, follow this checklist:
## Process
1. **Read the审查规则 first** — full text of review-rules-root.md
2. **Get both files**: original from 待审查/ + delivered from 任务交付/
3. **Identify ALL WB modifications** — list every WB INS/DEL with paragraph number
4. **Distinguish WB from original revisions** — other authors (WB-1, 86187, etc.) are original, don't touch
5. **Check each WB modification against rules** — one by one
6. **Read full accepted text** — check for语句不通顺, especially at INS boundaries
7. **Fix problems directly** — don't ask Doro if you should fix. Just fix.
8. **Report findings** — list what's correct and what's wrong
9. **Wait for Doro to say pass** — never self-initiate pass
## Common Workflow Issues Found (2026-07-13)
### 1. INS rFonts多余属性
Workflow consistently adds `hAnsi`, `cs`, `hint` to WB INS runs even when original runs only have `eastAsia` + `ascii`. Fix: strip these three attributes from all WB INS rPr/rFonts.
### 2. Sub-numbering not updated
When workflow inserts a new chapter (e.g. 第七条转包), it changes chapter headings (七→八, 八→九) but does NOT change sub-clause numbering (7.1→8.1, 8.1→9.1, 9.1→10.1). Fix: add DEL old number + INS new number for each sub-clause.
### 3. New clause heading format mismatch
- Wrong pStyle (e.g. Style15 instead of Heading4)
- Extra space in heading text (e.g. "第七条 转包" vs original "第六条违约责任" no space)
- Missing paragraph properties (spacing, ind) that originals have
### 4. Missing "法律顾问修订版" footer
Workflow sometimes doesn't add the footer. Fix: add via python-docx, then wrap the run in `w:ins author=WB` (must be tracked change format).
### 5. Duplicate content insertion
P32 example: original already had "保密义务不因合同解除...而免除", but WB added another "本条保密义务不因本合同的终止或解除而终止" — semantic duplicate. Fix: remove WB's duplicate.
### 6. Sentence flow at INS boundaries
WB appends保密/数据归属 text directly after original sentence without transition. If the original sentence's context doesn't naturally lead into the INS content (e.g. "遵守保密规范。保密义务不因..."), add a proper subject/definition sentence as transition.
### 7. 赔偿上限未删
Rule says "赔偿上限能删就删". Watch for bilateral clauses with caps (e.g. "违约金额为合同总金额的20%") — if it limits what our client can claim, delete it.
### 8. File naming with pre-existing【修】prefix
If the original file already has 【修】prefix (e.g. from previous editor), the delivered should technically be 【修】【修】... per strict rules. Record as known workflow defect.
## Self-check before reporting "满意"
- [ ] Every WB INS/DEL reviewed against rules
- [ ] Full accepted text read for fluency (especially INS boundaries)
- [ ] rFonts cleaned (no hAnsi/cs/hint extras)
- [ ] Sub-numbering顺延 complete (not just chapter headings)
- [ ] Footer "法律顾问修订版" present in tracked change format
- [ ] No duplicate semantic content
- [ ] Heading style/format matches originals
@@ -0,0 +1,72 @@
# 交付通知未送达诊断(2026-07-09 职业卫生+舜珙血压计)
## 现象
Doro说"有几份合同没收到交付通知"。任务交付目录有文件,tracker状态=delivered。
## 根因
今日凌晨01:27-02:15企微WebSocket连接中断(errcode 846609: aibot websocket not subscribed)。
Workflow的final_review步骤内部调用`_send_wecom(extra, 'doro', msg)`发送通知,但此时WS已断,通知静默失败。
## 时间线
- 01:27:23 — 首次846609错误
- 01:28:01 — 职业卫生合同thread end(final_review完成,通知发送失败)
- 01:30:41 — WebSocket closed (attempt 6)
- 02:12:29 — 舜珙血压计thread end(final_review完成,通知发送失败)
- 02:15:50 — WebSocket closed (attempt 7)
- 02:18:30 — Doro发"hi",WS恢复
## 诊断命令
```bash
# 1. 找tracker中delivered状态的合同
python3 -c "import json; t=json.load(open('~/.hermes/data/contract-tracker.json')); [print(c['delivered_filename']) for c in t['contracts'] if c.get('status')=='delivered']"
# 2. 查gateway.log确认WS断连时段
grep '846609\|WebSocket error\|WebSocket closed' ~/.hermes/logs/gateway.log | grep '2026-07-09'
# 3. 确认thread完成了final_review
uwf step list <thread_id> # 看是否有final_revi步骤且有时长
# 4. 确认文件在任务交付目录
sudo docker exec nextcloud-nextcloud-1 stat "/var/www/html/data/doro/files/Doro合同审查任务/任务交付/【修】XXX.docx"
```
## 补发方法
```bash
python3 ~/.hermes/scripts/wecom_dm.py --to doro --text "合同审查完成通知(补发):
1️⃣ 合同名称
顾问单位: XXX
修订: N处插入、M处删除
主要修订: ...
(此通知因XX时段企微WebSocket连接中断未能实时送达,现补发)"
```
## 修订内容提取方法(用于补发摘要)
```python
import zipfile
from lxml import etree
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
with zipfile.ZipFile(path) as z:
xml = z.read('word/document.xml')
root = etree.fromstring(xml)
# Count WB modifications
wb_ins = [ins for ins in root.findall(f'.//{{{W}}}ins') if ins.get(f'{{{W}}}author') == 'WB']
wb_del = [d for d in root.findall(f'.//{{{W}}}del') if d.get(f'{{{W}}}author') == 'WB']
print(f"WB修订: {len(wb_ins)} ins, {len(wb_del)} del")
# Get INS text summaries
for ins in wb_ins[:10]:
texts = [t.text for t in ins.iter(f'{{{W}}}t') if t.text]
text = ''.join(texts).strip()
if text and len(text) > 2:
print(f" + {text[:80]}")
```
## 预防措施
- auto_notify_watchdog cron每5分钟检查WS连接
- gateway WS重连机制(attempt 6-8自动重连)
- 但final_review内的`_send_wecom`调用没有重试机制——WS断了就直接失败
- **待改进**:final_review的通知步骤应增加重试逻辑或失败后写入pending_notifications队列
@@ -0,0 +1,64 @@
# Notification Silent Failure — WeChat 846609 WebSocket Disconnection
## Incident: 2026-07-09
### Timeline
- 01:27 UTC — Gateway errcode 846609 first appears (WebSocket not subscribed)
- 01:28 — 职业卫生监督 workflow final_review completes, notification fails silently
- 01:30 — WebSocket error attempt 6
- 02:12 — 舜珙血压计 workflow final_review completes, notification fails silently
- 02:15 — WebSocket error attempt 7
- 02:18 — Doro sends "hi", WebSocket recovers (inbound works before outbound stabilizes)
### Root Cause
`final_review` calls `_send_wecom(extra, 'doro', msg)` in a subprocess. When the WeCom WebSocket is disconnected (846609), the send fails but:
1. The uwf thread still ends successfully (status=end)
2. The queue-runner marks the contract as `delivered` in tracker
3. No retry mechanism exists for failed notifications
4. No alarm fires for silent notification failures
### Diagnostic Commands
```bash
# 1. Find delivered contracts that may have missed notifications
python3 -c "
import json
t = json.load(open('$HOME/.hermes/data/contract-tracker.json'))
for c in t['contracts']:
if c.get('status') == 'delivered':
print(f\" {c['original_filename']} delivered_at={c.get('delivered_at','?')}\")
"
# 2. Check for 846609 errors in the time window
grep '846609' ~/.hermes/logs/gateway.log | grep "$(date +%Y-%m-%d)"
# 3. Check WebSocket disconnection periods
grep 'WebSocket error\|websocket closed' ~/.hermes/logs/gateway.log | grep "$(date +%Y-%m-%d)"
# 4. Verify if notification was actually sent (look for successful send around delivered_at)
# A successful notification looks like:
# INFO gateway.platforms.base: [Wecom] Sending response (XXX chars) to doro
# WITHOUT a subsequent 846609 error in the same second
# 5. Check which threads completed during outage
grep 'DONE.*status=end' /tmp/contract-queue/queue.log | grep "TIME_RANGE"
```
### Remediation
```bash
# Manually re-send notification for affected contracts
python3 ~/.hermes/scripts/wecom_dm.py --to doro --text "合同审查完成通知(补发):
文件名: 【修】XXX.docx
顾问单位: XXX
修订摘要: X处插入、Y处删除
主要修订: ...
已上传至Nextcloud任务交付目录。
(因企微连接中断未能实时送达,现补发)"
```
### Prevention (not yet implemented)
- `final_review` should check send result and retry 3x with backoff
- Queue-runner should distinguish "delivered + notified" from "delivered + notification failed"
- Watchdog could audit: for each `delivered` record older than 30 min, verify gateway.log has a matching successful send
@@ -0,0 +1,70 @@
# 交付通知丢失:企微WS凌晨断连 + fire-and-forget架构
## 事件:2026-07-09 职业卫生+舜珙血压计两份合同交付无通知
### 时间线
- 01:23:06 — 最后一条成功发送(gateway → doro)
- 01:25~01:27 — WS断连(原因未知,WeCom服务端)
- 01:27:23 — 首次846609 "aibot websocket not subscribed"
- 01:28:01 — 职业卫生监督thread=end,final_review尝试通知→失败
- 01:30:44 — WS reconnected
- 02:04:16 — 成功发送(QiuTing),说明短暂恢复
- 02:12:29 — 舜珙thread=end,通知可能在02:05-02:12期间尝试
- 02:15:50 — WS再次断开
- 持续不稳定直到 06:36
### 根因链
1. WeCom WS凌晨不稳定(每30-60min断一次,服务端维护/长连接超时)
2. Gateway自动重连成功但846609持续("not subscribed"是服务端状态滞后)
3. workflow 24/7运行,final_review完成时间不可控
4. final_review通知是fire-and-forget:`_send_wecom(extra, 'doro', msg)` 调一次,失败即丢弃
### 通知机制分析
```
final_review procedure step 3:
cd ~/.hermes/hermes-agent && source venv/bin/activate && python -c "
from tools.send_message_tool import _send_wecom
...asyncio.run(_send_wecom(extra, 'doro', msg))..."
```
`_send_wecom`实现:
- 创建**新的** WeComAdapter实例
- connect() → send() → disconnect()
- 独立WS连接,不依赖gateway的WS
- 但用的是同一个WeCom API,846609是服务端状态,新连接一样受影响
- 失败返回 `{"error": "..."}` 给LLM,LLM可能仍标记notification_sent=true
### 7月8日也有相同模式
- 01:13 Timeout → 03:07 reconnect失败 → 06:04 gateway重启才恢复
- 约5小时不可用窗口
### 解决方案(待实施)
**推荐方案B:watchdog补发**
- watchdog cron(每20min)增加逻辑:
1. 扫描tracker中 `status=delivered` + `delivered_at > 30min前` + `notification_sent != true`
2.`wecom_dm.py --to doro` 补发通知(独立WS连接)
3. 成功后写 `notification_sent=true` + `notification_at=timestamp`
4. 失败则 `notification_attempts += 1`,下次tick继续重试
5. attempts > 6(即2小时)仍失败→日志告警不再重试
**tracker字段扩展**
```json
{
"notification_sent": false,
"notification_at": null,
"notification_attempts": 0
}
```
**wecom_dm.py优势**
- 独立WS连接,不受gateway状态影响
- 有明确的返回值(success/fail)
- 凌晨WS虽然不稳定但有恢复窗口(如02:04成功发送)
- 20min tick间隔 × 多次重试,大概率能命中一个可用窗口
### 临时止血
当发现合同delivered但Doro没收到通知时:
```bash
python3 ~/.hermes/scripts/wecom_dm.py --to doro --text "合同审查完成通知(补发): ..."
```
@@ -0,0 +1,56 @@
# Pre-Pass Audit Checklist (2026-07-13 练塘璞石+环保袋实战)
When Doro asks to "审查workflow修改" before pass, use this checklist.
## Step 1: Identify WB vs Original Revisions
```python
# In delivered docx:
for ins in root.iter(f'{{{W}}}ins'):
author = ins.get(f'{{{W}}}author')
# WB = workflow's modifications (audit these)
# Others (WB-1, 86187, 杨丽, etc.) = original file revisions (leave alone)
```
## Step 2: Format Audit (per WB INS run)
| Check | How | Common Fail |
|-------|-----|-------------|
| rFonts extra attrs | Compare WB INS rFonts with same-para orig run | hAnsi/cs/hint added by workflow |
| sz mismatch | Compare sz values | Usually OK if same as orig |
| pStyle on new headings | Compare with adjacent original headings | Style15 instead of Heading4 |
| Heading spacing/ind | Must match original heading paragraphs | Missing before/after=0, ind |
| Bold | If orig headings not bold, new ones shouldn't be | Usually OK |
| Title space | "第七条转包" vs "第七条 转包" | Workflow adds space |
## Step 3: Content/Numbering Audit
| Check | How | Common Fail |
|-------|-----|-------------|
| Sub-numbering顺延 | If 第七条→第八条, check 7.1→8.1 etc. | Workflow only changes chapter heading, forgets sub-numbers |
| 赔偿上限20% | Bilateral caps limit our client's recovery | Workflow misses bilateral cap deletion |
| Content dedup | Check if INS保密存续 duplicates existing text | Original may already have "义务不因...终止而免除" |
| 编号冲突 | Original may already have duplicate numbers | Don't fix original numbering bugs (per rules) |
## Step 4: Global Checks
- [ ] 脚注"法律顾问修订版" exists AND is in tracked change format (w:ins author=WB)
- [ ] File naming: 【修】+ original filename unchanged
- [ ] Read full accepted text for WB-introduced grammar issues
- [ ] Original revisions (other authors) untouched
## Fix Patterns
### Sub-numbering (split across runs: "7" + ".1 ")
Only need to DEL/INS the first digit run. Don't touch ".1 " run.
### Precise text deletion (P49 pattern)
When deleting middle of a single large run:
1. Split run into: before_text | DEL_text | after_text
2. Create 3 elements: normal_run(before) + del_elem(middle) + normal_run(after)
3. Insert at original position
### Footer tracked change
1. Add footer text via python-docx
2. Re-open with zipfile, find footer XML
3. Wrap the text run in `<w:ins id="..." author="WB" date="...">`
@@ -0,0 +1,91 @@
# Queue-Runner / Watchdog 重复审查 Bug(2026-07-02 确诊)
## 症状
Doro 不断收到同一份合同的重复"审查完毕"通知。同一天内夏阳合同被审查交付2次,家庭医生签约合同被审查交付后又有一个 thread 在跑。
## 根因
`contract-queue-watchdog`(cron `3174518affda`,每20分钟)与 `contract-queue-runner.sh` 的交互存在逻辑缺陷:
### 时间线
1. Runner 启动,从 manifest.txt 读取待处理文件列表
2. Runner 对文件A启动 `uwf thread start` + `uwf thread exec --background`
3. 背景 worker(node进程)开始跑 workflow,耗时 1-2 小时
4. Runner 自身在 `wait for worker PID` 循环中——但如果 runner 自己因某种原因退出(进程被杀、OOM、超时),只剩 worker 在跑
5. **关键 bug**:workflow 跑完后 deliverer 交付文件 + final_review 通知 Doro,但 runner 已经不在了——无法把文件从 queue/ 移入 done/
6. 20分钟后 watchdog tick:发现 `RUNNER_PID` 为空 + `DONE_CNT < TOTAL` + 无活跃 worker → **重启 runner**
7. 新 runner 读 manifest,发现文件还在 queue/(因为没被移到 done/)→ **再次启动 workflow** → 重复审查 → 重复通知
### 更隐蔽的变体(本次实证)
即使 runner 没死,也会出问题:
- Runner 在等 worker exit,worker 正常完成 → runner 移文件到 done/ → 进入下一份
- 但 runner 处理完 manifest 所有文件后正常退出
- 新文件在 runner 退出后被追加到 manifest(如 auto_notify 追加)
- Watchdog 重启 runner → runner 从头读 manifest → 前面的文件已在 done/ 会被 SKIP
- **但如果某份文件在 queue/ 中仍然存在**(不在 done/)→ 又跑一遍
### 为什么文件会在 queue/ 而不在 done/
1. Runner 异常退出,文件从未被移到 done/
2. 新追加的文件,上一轮 runner 没跑到就退出了
3. 文件被 auto_notify 或手动操作重新放回 queue/(不太可能但理论上存在)
## 缺失的防线
Runner 的 SKIP 逻辑只有一层:
```bash
[ -e "$FILE" ] || { log "SKIP (not found / already done): $BASENAME"; continue; }
```
只看文件是否还在 queue/ 目录。**完全不查 contract-tracker.json**。
## 修复方案
在 runner 的 `=== START:` 之前加 tracker 查重:
```bash
# === BEFORE START: check tracker for already-completed ===
if python3 -c "
import json, sys
t = json.load(open('$HOME/.hermes/data/contract-tracker.json'))
completed = [c['original_filename'] for c in t['contracts'] if c['status']=='completed']
sys.exit(0 if '$BASENAME' in completed else 1)
" 2>/dev/null; then
log "SKIP (already completed in tracker): $BASENAME"
mv "$FILE" "$QUEUE_DIR/done/"
continue
fi
```
## 止血操作(已执行)
1. ✅ kill 了正在重复跑的 reviewer thread (06FJ5NPHT9XX63HW0WDKXV3ZSR) 的 worker
2. ✅ kill 了 queue-runner 进程 (PID 1907973)
3. ✅ 将所有已处理文件移入 done/(家庭医生签约、朱家角标识标牌、计划生育协议)
4. ✅ 验证 done/ 数量 ≥ manifest 行数 → watchdog 不会再重启 runner
## 今天的重复统计
| 合同 | 正常审查 | 重复审查 | 影响 |
|------|----------|----------|------|
| 恭兴 | 05:00 (end) | 02:45被cancel了不算 | 无重复 |
| 肃言 | 06:25 (end) | — | 无重复 |
| 卫健委 | 07:28 (end) | — | 无重复 |
| 夏阳 | 08:42 (end) | 11:40 再跑一遍 (end) | ⚠️ 重复通知 |
| 家庭医生签约 | 12:38→19:21交付 | 19:40又重启→21:16重复交付 | ⚠️ 重复通知 |
## Watchdog 今天重启 runner 的次数
今天 watchdog tick 64次,其中触发 runner 重启 **41次**(00:00-10:40每20分钟都重启一次!)。大多数重启只是空跑(文件都在 done/ 了),但恭兴和夏阳那两次重启时文件还没进 done/,导致重复审查。
## 相关组件
- Runner 脚本:`~/.hermes/skills/devops/uwf/scripts/contract-queue-runner.sh`
- Watchdog 脚本:`~/.hermes/scripts/contract-queue-watchdog.sh`
- Watchdog cron:`contract-queue-watchdog` (job_id: `3174518affda`),每20分钟
- Queue 目录:`/tmp/contract-queue/`(manifest.txt + done/)
- Tracker:`~/.hermes/data/contract-tracker.json`
@@ -0,0 +1,85 @@
# Queue-Runner + Watchdog 重复审查Bug(2026-07-02 实证)
## 现象
Doro反复收到同一份合同的"审查完毕"通知。今天受影响的合同:
- 夏阳合同:被通知至少2次(可能3次)
- 家庭医生签约合同:被通知2次(第3次在final_review被手动杀掉)
## Bug链条(已验证)
```
1. Queue-runner 启动 → START 合同X → 创建 worker PID → "Waiting for worker..."
2. Runner 进程异常退出(OOM/信号/shell被杀),但 worker 子进程继续运行
3. Worker 独立完成整个 workflow(包括 final_review = 私信通知 Doro)
4. Runner 已死 → 没有执行 "mv $FILE done/" → 文件仍在 queue 目录
5. Watchdog(20min cron)检测到:runner不在 + done/ < manifest → 重启 runner
6. 新 runner 看到文件还在 queue → SKIP逻辑只检查 `[ -e "$FILE" ]` → 认为未处理
7. 新 runner 启动第二个 thread → 从头审查 → final_review 又通知 Doro
```
## 额外失败模式(watchdog恢复旧thread)
```
watchdog.log:
[2026-07-02 18:40:14] suspended 06FJ42KNSK1ZA767W8AZ6MXJ6C → exec 恢复
[2026-07-02 18:40:15] idle 06FJ3ZJXR5WHJNPDHF22YYMGCM → exec 续跑
```
Watchdog 恢复处于 idle/suspended 状态的旧 thread,这些 thread 的同名合同可能已被新 runner 完成。
旧 thread 被唤醒后接着跑完 final_review → 又一次通知。
## 今天的实际时间线
| 时间(BJT) | 事件 |
|-----------|------|
| 02:45 | 第一个runner启动,处理恭兴(cancelled) |
| 05:00 | Watchdog重启runner → 恭兴(成功) |
| 06:25 | 肃言完成 |
| 07:28 | 卫健委完成 |
| 08:42 | 夏阳 thread#1 启动 (06FJ3ZJXR) |
| ~11:00 | 夏阳#1 完成(含final_review通知);但runner已死,文件没进done/ |
| 11:40 | Watchdog重启runner → 夏阳 thread#2 启动 (06FJ58AD) |
| 12:38 | 夏阳#2 完成(第二次通知);家庭医生签约 thread 启动 (06FJ5NPHT) |
| 18:40 | Watchdog恢复旧idle thread 06FJ3ZJXR5WHJNPDHF22YYMGCM (夏阳#1) + 06FJ42KNSK (家庭医生#?) |
| 19:21 | 家庭医生签约交付到Nextcloud |
| ~21:00 | 06FJ42KNSK 完成 final_review(第2次家庭医生通知) |
| 21:16 | Watchdog再次重启runner → 家庭医生 thread#2 (06FJ5NPHT) 进入 final_review |
| 21:30 | 手动 kill -9 杀掉 06FJ5NPHT 的 final_review → 阻止第3次通知 |
## 止血SOP
当 Doro 报告收到重复通知时:
1. **找重复进程**`ps aux | grep -E "(background-worker|uwf-hermes)" | grep -v grep`
2. **杀掉重复进程**`kill -9 <worker-PID> <uwf-hermes-PID>`
3. **杀掉queue-runner**`kill <queue-runner-PID>`
4. **清理queue**:把所有tracker中已completed的文件移入done/
```python
import json, os, shutil
tracker = json.load(open(os.path.expanduser('~/.hermes/data/contract-tracker.json')))
completed = {c['original_filename'] for c in tracker['contracts'] if c['status'] == 'completed'}
queue_dir = '/tmp/contract-queue'
for f in os.listdir(queue_dir):
if f.endswith(('.doc', '.docx', '.pdf')) and f in completed:
shutil.move(f'{queue_dir}/{f}', f'{queue_dir}/done/{f}')
```
5. **验证**:`find /tmp/contract-queue/ -maxdepth 1 -name '*.doc*'` 应为空
6. **确认watchdog不会重启**:done/ 文件数 ≥ manifest.txt 行数
## 缺失防线(待修复)
| 位置 | 应加的检查 |
|------|-----------|
| queue-runner START 逻辑 | 启动workflow前查tracker:已completed直接mv到done/ |
| watchdog 恢复thread逻辑 | resume前查:同filename的tracker记录是否already completed |
| final_review | 发通知前查:是否24h内已有同合同的通知(防御性去重) |
## 关键文件路径
- Queue runner: `/home/maggie/.hermes/skills/devops/uwf/scripts/contract-queue-runner.sh`
- Watchdog: `~/.hermes/scripts/contract-queue-watchdog.sh`
- Queue dir: `/tmp/contract-queue/` (manifest.txt + done/)
- Queue log: `/tmp/contract-queue/queue.log`
- Watchdog log: `/tmp/contract-queue/watchdog.log`
- Tracker: `~/.hermes/data/contract-tracker.json`
- Cron jobs: `contract-queue-watchdog` (*/20, job_id:3174518affda), `auto-notify-watchdog` (5min, job_id:63bb31d4f050)
@@ -0,0 +1,110 @@
# Queue-Runner: Same-Name Different-Content File Silently Dropped (2026-07-08)
## Incident
邱律师 sent two versions of "2026年华新镇公立中小学生健康体检服务合同.docx" on the same day:
- 11:05 BJT (27889 bytes): generic version without fee cap
- 16:04 BJT (27482 bytes): specific version with 华新镇 in project name, ¥170,000 fee cap, different start date
Only the first was reviewed. The second was silently dropped.
## Root Cause Chain (3 components)
### 1. auto_notify manifest dedup (`grep -qFx`)
```bash
# In auto_notify_new_file.sh:
if ! grep -qFx "$orig_name" "$QUEUE_DIR/manifest.txt" 2>/dev/null; then
echo "$orig_name" >> "$QUEUE_DIR/manifest.txt"
fi
```
The filename was already in manifest.txt from the first file → second file NOT appended → manifest has only ONE entry for this filename.
### 2. Runner single-pass no-backtrack
The runner reads manifest top-to-bottom in one pass. By 08:00:09 UTC it had already passed the 华新镇 line (first version was in done/ → "SKIP not found"). When auto_notify wrote the second file to queue/ at 08:04, the runner was already past that line processing later files. It never goes back.
### 3. done/ presence satisfies watchdog progress check
`[ -e "$QUEUE_DIR/done/$f" ]` — the first version in done/ counts as "complete" for this manifest line.
## Key Principle (Doro 2026-07-08 铁律)
**判断是否为相同文件不能只看文件名。** 必须检查合同实质内容:
- 顾问单位(甲方)名称
- 金额/费用上限
- 合同期限(起止日期)
- 项目内容描述
- 字节大小
以上任何一项不同 → 视为新版本/不同合同,正常入队审查。
全部相同 → 视为重复发送,可跳过。
## Fix Implemented (2026-07-09)
### 1. Content comparison script: `~/.hermes/scripts/contract_content_compare.py`
```
Usage: python3 contract_content_compare.py <file_a> <file_b>
Exit 0 = same content (duplicate)
Exit 1 = different content (new version / different contract)
Exit 2 = cannot read (treat as different, err on safe side)
```
Compares:
- File size (bytes)
- 甲方 name (regex extraction from first 2000 chars)
- All amounts (阿拉伯数字 ≥4 digits + 元/万, 人民币XXX, percentages)
- All dates (YYYY年M月D日 format)
- Project summary (first 500 chars normalized)
### 2. auto_notify_new_file.sh modification
Replaced the simple `grep -qFx` manifest dedup with content-level comparison:
```bash
if grep -qFx "$orig_name" "$QUEUE_DIR/manifest.txt" 2>/dev/null; then
# Same filename exists in manifest — compare content
EXISTING="" # find in done/ or queue/
COMPARE_RESULT=$(python3 contract_content_compare.py "$EXISTING" "$filepath")
if [ $? -eq 0 ]; then
# Content identical → true duplicate, skip
log "SKIP duplicate (content identical): $orig_name"
return
else
# Content different → new version, rename with timestamp suffix and queue
RENAMED="${orig_name%.*}_v${TS}.${orig_name##*.}"
cp "$filepath" "$QUEUE_DIR/$RENAMED"
echo "$RENAMED" >> "$QUEUE_DIR/manifest.txt"
log "SAME NAME DIFFERENT CONTENT — queued as new: $RENAMED"
fi
else
# Brand new filename, normal flow
cp "$filepath" "$QUEUE_DIR/${orig_name}"
echo "$orig_name" >> "$QUEUE_DIR/manifest.txt"
fi
```
### 3. Verification
Tested with the actual 华新镇 two files:
```
$ python3 contract_content_compare.py file1.docx file2.docx
DIFFERENT: 两份文件内容不同(新版本/不同合同)
- 字节大小不同: 27889 vs 27482
- 金额不同: A多set(), B多{'170000'}
- 日期不同: A多{'2026年9月10日'}, B多{'2026年9月1日'}
- 内容不同(第212字起): '...年公立中小学生健康检查...' vs '...年华新镇公立中小学生健康体检...'
```
## Key Lesson
The initial diagnosis went through multiple wrong iterations:
1. First said "queue-runner only checks filename" — wrong (tracker guard also exists)
2. Then said "tracker guard is the one that skipped" — wrong (no tracker skip in logs)
3. Then said "整个链路没有内容比对" — wrong (Doro corrected: the system should have had it)
**Correct diagnosis process**: trace the actual log timestamps precisely, verify each claim with tool output, don't assume mechanisms exist or don't exist without reading the actual code.
**Doro's requirement**: "不能用文件名判断是否为相同文件,需要查看合同内容(包括顾问单位名称、金额、日期等)以及字节大小,要全面判断。" This is a capability requirement, not just a bug fix — the system must understand what makes two contracts "the same" at a business level.
@@ -0,0 +1,79 @@
# Watchdog "suspended + delivered" Deadlock (2026-07-08)
## Symptom
- Doro reports not receiving delivery notification for completed contracts
- `tail watchdog.log` shows repeated lines every 20 minutes:
```
BLOCK resume <thread_id>: tracker shows <filename> already delivered
runner 退出但有 worker/卡住thread在处理,暂不重启(避免撞车)
```
- Files ARE in 任务交付/ directory (deliverer succeeded), but notification was never sent
- Queue runner has exited, watchdog won't restart it
## Root Cause Chain
1. Workflow reaches `final_review` step (responsible for quality check + notification)
2. `final_review` encounters HTTP 500 / API failure → thread suspends
3. But `deliverer` already succeeded earlier → tracker shows `status=delivered`
4. Queue-runner sees `status=suspended` (not `end`) → marks as "needs inspection", does NOT archive to done/
5. Queue-runner continues to next file, eventually exits
6. Watchdog checks: finds suspended thread → tries to resume → checks tracker → sees "already delivered" → BLOCK
7. File still in queue/ (not in done/) → watchdog thinks work remains → won't restart runner for other files
8. **Infinite loop**: every 20 min watchdog ticks, hits same BLOCK, same "暂不重启"
## Impact Scope (2026-07-08 batch)
All 5 contracts from the 16:00 batch were affected (all hit HTTP 500 at final_review):
- 【修】华新慢病支持中心运维合同(1)_2.docx
- 赵巷合同1.doc
- 疾控中心(卫监所)实习人员宿舍改造工程.docx
- 2026年爱在党群·沟通有方家长沟通之道青春健康教育项目合作协议.docx
- 合同.docx
## Resolution (止血 SOP)
### Step 1: Cancel suspended threads
```bash
/home/maggie/.hermes/node/bin/uwf thread cancel <thread_id>
# Repeat for all suspended threads
```
### Step 2: Move stuck files to done/
```bash
cd /tmp/contract-queue
mv "filename1.docx" done/
mv "filename2.doc" done/
# Move ALL files that are in tracker as delivered/completed
```
### Step 3: Verify queue is clear
```bash
ls /tmp/contract-queue/*.doc* 2>/dev/null # Should be empty
```
### Step 4: Do pass for delivered files
Since files are already in 任务交付/ and tracker shows delivered, proceed with normal pass flow (update tracker to completed + write xlsx).
## Prevention
- The watchdog should detect "suspended + already delivered" as a terminal state and auto-archive to done/ (currently it only BLOCKs resume without archiving)
- The final_review step should have retry logic for transient HTTP 500 errors
- Queue-runner should archive files to done/ even when thread status=suspended IF tracker shows delivered (the work is done, only notification failed)
## Diagnosis Commands
```bash
# Check for deadlock pattern
tail -20 /tmp/contract-queue/watchdog.log | grep "BLOCK resume"
# Check queue-runner status
ps aux | grep contract-queue-runner | grep -v grep
# Check what's stuck in queue
ls /tmp/contract-queue/*.doc* 2>/dev/null
# Check tracker for delivered status
python3 -c "
import json
t = json.load(open('$HOME/.hermes/data/contract-tracker.json'))
for c in t['contracts']:
if c.get('status') == 'delivered':
print(f\" {c['original_filename']} → delivered\")
"
```
@@ -0,0 +1,26 @@
# Workflow Issues Report 2026-07-01 (Summary)
Full report at: Nextcloud 小Maggie协作区/workflow-issues-report-20260701.md
## Key Findings
### File Disappearance Root Cause
Files "missing" from 待审查 were likely **never uploaded there**. auto_notify failed silently (Mode B),
workflow read directly from cache path, audit completed, but Nextcloud 待审查 directory was never populated.
Cleanup cron confirmed NOT responsible (all cleaned records show >24h gap).
### Duplicate Review (朱家角标识牌)
Root cause: `/tmp/contract-queue/manifest` retained filename after pass+clean.
relay-runner reads manifest without checking contract-tracker.json for completed status.
Fix: relay-runner must `grep original_filename tracker.json` before `uwf thread start`.
### Companion Cleanup
Confirmed approach (Doro 2026-07-01):
- Naming rule: `【审】{原文件名去扩展名} 审查意见.docx`
- Pass skill writes `companion_files` field to tracker
- Cleanup script reads and deletes alongside main contract
- Both sides must be modified simultaneously
### Private Message Routing
`wecom_group_notify.py` (default=group) was used when `wecom_dm.py --to qiuting` (DM) was required.
Workflow YAML line 16-17 also incorrectly references group notify script.
@@ -0,0 +1,31 @@
# xlsx覆盖事故 2026-07-03
## 事故
pass流程中使用了/tmp残留的旧xlsx(240行,截止6月30日),覆盖了Nextcloud上的最新版(252行,截止7月3日),导致seq 241-251共11条记录丢失。
## 根因
没有按skill规定从Nextcloud实时拉取最新xlsx,直接用了本地旧文件。
## 恢复
/tmp下恰好有另一份正确副本(今早其他session拉取的),用它恢复+追加新行。
## 铁律
1. 写xlsx前必须 `rm -f → docker cp从NC拉取 → 读max_row打印seq确认 → 追加 → 上传`
2. 禁止使用/tmp中任何已存在的xlsx文件
3. 上传后重新拉取验证行数
## Doro原话
"你这个错误太可怕了"
"不仅要写入,避免下次发生,还得写入铁律"
## 连带错误
同一session中还犯了:
- 查到邱律师新文件后没查auto_notify日志就手动启动workflow → 重复
- 主观判断两份文件"相同" → 被Doro纠正"你也不要去判断是不是同一份文件"
- 时间用UTC而非北京时间 → 多次纠正
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""
独立终审/pass 前核验 PDF 批注交付件(2026-06-22 阳澄湖团建实证)。
用于扫描件/PDF 合同走"批注模式"交付后,小Maggie作为总负责人独立亲验——
不只信 workflow final_review 自报。docx 专项检查(编号/INS字体/numPr)对 PDF 不适用,
PDF 批注交付改查以下项:
1. Nextcloud 交付件 ↔ 本地核验件 SHA256 字节一致(防同步/编码改坏)
2. 页数:原件 == 交付件
3. 原文完整性:原件批注数应为 0(确认原文未被改动)
4. 批注数 + author:高亮(Highlight)与批注气泡(Text)应配对,全部 author=WB
5. 批注格式合规:内容全以"建议"开头,无【】标签、无理由/原因解释
6. 高亮几何锚定:落在正文区(非页边/空白),宽度横跨条款行
⚠️ 若交付件大小/页数异常(如读出 0 页、大小骤变),先排查是不是用户在 OnlyOffice
手动编辑——别急着判定损坏并用本地副本覆盖。见 SKILL.md "已知坑"第一条。
用法:
python3 verify_pdf_annotation_deliverable.py <原件.pdf> <Nextcloud交付件.pdf> [本地核验件.pdf]
# 第三个参数可选;给了就做 SHA256 字节一致性比对
"""
import sys, os, hashlib
try:
import pymupdf
except ImportError:
import fitz as pymupdf
FORBIDDEN = ['', '', '原因', '理由', '因为', '风险'] # 批注禁用 token(保密条款本体"因乙方原因"需人工甄别)
def sha256(p):
h = hashlib.sha256()
with open(p, 'rb') as f:
h.update(f.read())
return h.hexdigest()
def main():
if len(sys.argv) < 3:
print(__doc__); sys.exit(1)
orig, deliv = sys.argv[1], sys.argv[2]
local = sys.argv[3] if len(sys.argv) > 3 else None
ok = True
# 1. SHA256 字节一致
if local:
s_d, s_l = sha256(deliv), sha256(local)
same = s_d == s_l
print(f"[1] SHA256 NC↔本地 {'✅一致' if same else '❌不一致'} NC={s_d[:16]} 本地={s_l[:16]}")
ok &= same
do, dd = pymupdf.open(orig), pymupdf.open(deliv)
# 2. 页数
p_ok = len(do) == len(dd)
print(f"[2] 页数 原件{len(do)}==交付{len(dd)} {'' if p_ok else ''}")
ok &= p_ok
if len(dd) == 0:
print(" ⚠️ 交付件读出 0 页!先查是不是用户在 OnlyOffice 改动/后台编码,别判定损坏就覆盖。")
ok = False
# 3. 原文完整性
orig_annots = sum(len(list(p.annots() or [])) for p in do)
print(f"[3] 原件批注数={orig_annots} {'✅原文未改动' if orig_annots == 0 else '⚠️原件本身带批注'}")
# 4 & 5. 批注数/author/格式
total = 0; authors = set(); types = {}; bad_fmt = []
for pi, page in enumerate(dd):
for a in page.annots() or []:
total += 1
info = a.info
authors.add(info.get('title', ''))
t = a.type[1]; types[t] = types.get(t, 0) + 1
c = (info.get('content', '') or '').strip()
if c:
if not c.startswith('建议'):
bad_fmt.append(f"P{pi+1}: 非'建议'开头: {c[:30]}")
for tok in FORBIDDEN:
if tok in c:
bad_fmt.append(f"P{pi+1}: 含禁用token[{tok}]: {c[:30]}")
a_ok = authors <= {'WB'} and total > 0
print(f"[4] 批注总数={total} 类型={types} author={authors} {'' if a_ok else ''}")
ok &= a_ok
if bad_fmt:
print(f"[5] ❌批注格式问题{len(bad_fmt)}处:")
for b in bad_fmt[:10]:
print(f" {b}")
ok = False
else:
print(f"[5] 批注格式 ✅全以'建议'开头、无【】/理由")
# 6. 高亮几何锚定(落正文非页边)
print(f"[6] 高亮锚定位置:")
for pi, page in enumerate(dd):
ph = page.rect.height
for a in page.annots() or []:
if a.type[1] == 'Highlight':
r = a.rect
yp = r.y0 / ph * 100
flag = '' if (5 < yp < 95 and r.width > 200) else '⚠️疑似页边/过窄'
print(f" P{pi+1} y={yp:.0f}%高 宽{r.width:.0f}px {flag}")
print(f"\n{'='*40}\n总核验: {'✅ 全部通过,可交付/登记' if ok else '❌ 有问题,先排查再说'}")
sys.exit(0 if ok else 2)
if __name__ == '__main__':
main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,996 @@
---
name: contract-portfolio-analysis
description: 合同组合分析——批量OCR、独立法律审查、模版对比、三角色校对、Excel汇总表制作与台账维护。适用于多校区/多合同的批量梳理与持续维护项目。
version: 1.12.0
tags: [合同, 批量分析, 模版对比, Excel, OCR, 租赁, 法律审查, 三角色校对, 台账维护]
triggers:
- 批量合同梳理/分析
- 多校区合同汇总
- 合同与标准模版对比
- 合同组合风险分析
- 租赁合同汇总表制作
- 汇总表更新/维护
- 租赁台账维护
- 新增租赁合同
- 合同到期更新
- 提前解除/退租更新
---
# 合同组合分析(Contract Portfolio Analysis)
## 适用场景
客户有大量已签署合同需要梳理(如多个校区的租赁+物业合同),要求:
- 逐份提取关键信息
- 与标准模版对比差异
- 提取变更/解除条款
- 汇总为结构化Excel表格
---
## ⚠️ 元规则:workflow 是必经清单,必须逐项严格执行;不得擅自改动(Maggie 2026-06-22 确立)
**这条管的是「怎么对待 workflow 本身」,优先级排在所有内容铁律之前。**
- **每个校区都必须按本 skill 完整逐项执行**——Step 0→7(单校区闭环 Step 0→6 + 全局收尾 Step 7)+ 三角色校对 + H列三件套 + **末尾整体风险分析与建议段** + 需核实标红,**一步都不能跳、不能简化、不能凭「上个校区做过的印象」代替**。Maggie 原话:「以后每个校区都需要按照 workflow 来做。」
- **不得擅自改动 workflow**:流程的任何增删改(跳过校对、省掉整体分析段、改列结构、改 Step 顺序、改交付方式等)都必须**经 Maggie 明确授权**;授权的调整**固化进本 skill 后才算数**,未固化的一律按原 workflow。我不能自行决定「这步这次不用做」。Maggie 原话:「不能擅自改动 workflow。」
- **悦拾光教训(2026-06-22)**:被要求「做好汇总表」后,凭「世茂做过」的印象裸做,擅自跳过了三角色校对、末尾整体风险分析段、H列付款安排标红、需客户核实标红——被 Maggie **连续四次**追问「你有按 workflow 操作么 / 在做了么」。根因:把 workflow 当参考而非必经清单,把「做表」误判成机械画格子活、绕过 skill 直接 openpyxl 裸写。(详见 Pitfall 16)
- **落地**:每个校区开工前,**先把 workflow 步骤列成 todo 逐项打勾**;交付前对照清单确认每一步都做了,**缺一项不交付**。Maggie 验收必查两件事:(a) 标准板块齐全(尤其整体风险分析与建议段);(b) 是否真走了 workflow——两者缺一即返工。
---
## 🔴🔴 开工铁律:每个校区跑「单校区开工闸门」脚本,从头独立做(Maggie 2026-06-23 立,最高优先级之一)
**这条专治「开新校区时凭上个校区的印象乱跑/跳步」。是物理闸门,不是靠记性。**
### ① 开工第一个动作 = 跑闸门脚本(不跑不准动手)
开始任何一个校区(新建/续做/重做)的**第一件事**,先在终端跑:
```bash
python3 ~/.hermes/skills/legal/contract-portfolio-analysis/scripts/campus-workflow-gate.py <校区名>
```
它会打印:单校区独立闭环纪律 + 完整 Step 0→7 workflow + 该校区源文件夹定位 + 汇总表存放路径 + 待打勾 todo。**把它吐出的 todo 贴进 todo 工具逐项打勾**。没跑这个脚本、没建这份 todo,就不算开工,不准开始填表。
### ② 单校区独立闭环纪律(核心)
**每个校区都是「第一次」,从 Step 0 从头到 Step 6 独立完整跑一遍(Step 7 总览是全部校区定稿后的全局收尾),不受任何其他校区影响:**
- **不拿别校区的印象代替本校区的实做**——「世茂/悦拾光是这样做的」「上个校区这么定级」这类印象,**一律不假设适用于本校区**。本校区的逐字通读、八维审查、回 07 原件比对,每一项都要在本校区原文上从头做。
- **别校区的结论/定级/措辞,统统不迁移**。一切回本校区合同原文重新判断。这是「全新合同全面审」,不是「套上一份的模子」。
- **为什么立这条**:批量做时,注意力被格式/效率分散,最容易「这份大概和上一份一样」地跳读跳审——人不会觉得自己跳了,但判断地基已经空了(与「第一铁律·逐字通读」的批量失效模式同源)。开工闸门脚本就是强制把「每个校区从头来」顶在最前面。
### ③ 汇总表存放纪律(Maggie 2026-06-23 立)
**每个校区做完,汇总表存到该校区自己的文件夹下**,与该校区合同放一起,便于客户对照查阅:
- 存放路径:`小Maggie协作区/南通新东方/履约期内非集采合同-综办/房租物业合同/<校区名>/`
- 命名:`<校区名/项目名>-梳理-MJ-YYYYMMDD.xlsx`(当事人/项目名+文件名+修改人+日期)
- **不放公共目录、不放别的校区文件夹、不只留本地 /tmp**。17 个校区各自的汇总表归各自文件夹。
- 总览 sheet 是所有校区定稿后最后整合的产物(见 Step 7),与「各校区表存各校区文件夹」不冲突——单校区表归位在前,总览整合在后。
### ④ 与既有规则的关系
本节是「元规则·workflow 必经清单」的开工落地抓手,与「Pitfall 18·禁止 openpyxl 裸做」「第一铁律·逐字通读」三位一体:元规则定「必须走流程」,本节定「每校区从头独立走 + 开工先跑闸门 + 表归各自文件夹」,Pitfall 18 定「别绕过 skill 裸写」。三条一起堵死「开新校区自己乱跑」。
---
## ⚠️ 第一铁律:每份合同每份文件,亲自逐字逐句通读理解后再判断(Maggie 2026-06-22 确立,刻进骨子的律师基本严谨)
**这是本 skill 所有规则的地基,排在最前面。** Maggie 原话:「你要把每一份合同每一份文件都逐字逐句自己阅读理解并审查,刻在骨子里,这是做一个律师工作最基本的严谨。」
- **铁律本身**:法律审查/填表/下任何结论前,必须**亲自 `read_file` 把该合同整篇 OCR 原文(含全部附件)从头读到尾、读懂每条的语境与语义**。`禁止`用以下任何一种代替通读:① subagent 的提取报告 ② `grep`/`search_files` 命中单行 ③「这套合同我见过、大概是这样」的印象式推断。源头(读原文)必须在自己手里——这与「三角色分工·法律审查动作A不外包」「第0步·审查第一性原则」是同一条骨架,本条把它提到全局第一位。
- **🔴 批量场景是这条铁律最容易失守的地方(必须正视的失效模式)**:一次做 4+ 份合同时,注意力被「填表格式、行高、防 subagent 超时、跨表联动」分散,逐字通读这一步会**悄悄退化**成「提取报告说啥我核个大概」——人不会觉得自己跳读了,但判断的地基已经空了。**越是批量、越要顶住,每一份都亲自从头读完,不因为是第 3 份第 4 份就打折。**
- **自检信号(出现即说明我没真读)**:用户就某条款问一个具体问题(如「这里能不能推算」「这个填空是什么意思」「依据是什么」),我**一回原文、30 秒就核出答案**——这恰好证明答案一直在原文里明摆着,**之前没去逐字读**。凡是「用户一问、回原文秒答」的,根因都是当初没通读,不是题目难。
- **没真读的两类典型产物(2026-06-22 世茂高中物业实证,均在 ⑤b 详述)**:① 因果方向写反(9.1/10.1「物业违约连带触发租赁解除」——原文是单向「租赁终止则物业终止」);② 选填留空 `/` 当真实备选项论证(「填空额取高」)。两个都是「没读懂原文就下笔」的直接产物,逐字读过绝不会发生。
- **逐字通读会主动捞出选择性审查漏掉的真问题(同日世茂物业实证)**:亲自通读两份物业全文后,发现 K列漏审了 ① 高中物业 8.3/8.4/8.5 甲方违约责任(双倍保证金赔偿等,**对乙方有利**)② 6.2/6.3 甲方可转让+「乙方15日内不配合签转让协议视为同意+甲方可解约」(沉默视同意,**对乙方不利**)③ 3.1.3 乙方违约全部保证金作违约金——挑审/靠提取报告时全漏了。**逐字读不是慢,是把本该发现的风险真的发现。**
- **落地**:续做/校对/交付前,凡涉及对某合同下法律结论,先确认「这份我本人逐字读过整篇原文了吗」;没有就先读,OCR 没有就先 OCR(扫描件无文字层走 tesseract,见 Pitfall 4)。读完再走「整合质询三对撞」「⑤b 法律结论核证」收口。
---
## ⚠️ 铁律:模版差异 ≠ 法律风险(Maggie 2026-06-16 确立)
**法律审查 和 模版比对 是两件性质完全不同的事,绝不能混为一谈:**
| | 法律审查(动作A) | 模版比对(动作B) |
|---|------------------|------------------|
| 回答的问题 | 合同**本身**有没有法律风险 | 现实情况 vs **内部合规要求**差多少 |
| 参照系 | 法律 + 司法实践 | 客户的标准范本 |
| 做法 | 当作**一份新合同全面审** | 中性陈述差异 |
| 性质 | 法律风险判断 | 合规差距说明 |
- **不能用"和模版有无差距"代替"有无法律风险"**。模版比对的目的是让客户了解现状与内控标准的差距,**不能作为合同本身法律风险的判断依据**。
- 一份合同可能**完全符合模版却仍有法律风险**(条款歧义、引用失效法规、约定履行不能的义务——模版覆盖不到);也可能**大幅偏离模版却无实质法律风险**(纯商业安排或措辞不同)。
- ❌ 旧做法里隐含的等号"模版有、本合同没有 = 风险"是**错的**,已废止。
- 汇总表中"法律风险"信息(动作A产出)与"模版差异"信息(动作B产出)**分列**,并注明两者性质区别,避免下游把合规差距误读为法律风险。
→ 完整审查框架见 `references/independent-legal-review-framework.md`(八维框架),模版比对方法论见本文「模版对比方法论」节。
---
## ⚠️ 核心工序:填表前的「整合质询」(Maggie 2026-06-17 世茂提成教训确立,对治"信息在手却没整合")
**这是我主审的必经工序,不是可选项。** 病灶诊断(必须正视):提成漏判、违约金摘单句,根因都**不是信息没拿到**——提取报告早标了"提成比例%空缺"、合同里违约金兜底句白纸黑字写着。错在**读到了分散的信息,却没把它们对撞成完整判断**就直接填表了。光靠"记得仔细点"防不住,状态一松就漏。所以把"整合"从脑内一闪念,固化成填表前的强制动作。
### 做法:每个关键字段进表前,先过「三对撞」
对**金额、面积、期限、违约金、解除权、优先权、续租、保证金**等每个要进表的关键字段,填之前必须主动问三句、并在脑中(或主审清单里)确认一遍才能落笔:
1. **空缺对撞**——这个数额/比例/期限,原文对应的**填空位真的填了数额吗**?还是 `/`、空白、"待定"、"另行约定"?
- 空缺 → 该机制实践中不适用,按"实际如何"写,不照搬字面(提成栽点:比例空缺=提成不适用=实际按保底)。
- ⚠️ **反向陷阱:下"留白/未约定"结论前,必须穷尽 正文条款 + 全部附件 + 补充协议 三处出处,别拿"我提取的那一处没写"当"全合同没约定"(2026-06-18 世茂高中租赁2028租金教训)**。世茂栽点:高中租赁(3023双签版)附件三只约定了 2026/1–2027/12 保底租金,提取只读了附件三 → 表里记"2028年度第3年留白";实际**正文第3.1条**已明确约定 2028 年度:不含税 **6,689.17 元/月**、含税 **7,291.2 元/月**(税率9%)、或营业额2%提成两者取高。是 Maggie 截图正文第3.1条才捞回来的。错因:商业Mall合同金额信息**正文与附件双向分布**——附件三按年列租金却漏了第3年,正文3.1条反而把全程(含第3年)写全了。这与本 skill "金额数字常在附件,正文条款只定规则"的经验**互为补充、不可偏废**:附件可能有**时间/范围缺口**由正文补全,正文也可能只定规则把数额甩给附件——两个方向都要查。
- 做法:任何"留白/空白/未约定/第X年缺"落笔前,回 OCR 原文把**正文对应条款 + 每个附件 + 补充协议**都 `grep` 一遍(搜该费用/金额关键词与年度),三处都确认没有,才能记"留白";一处有就照实补,并按 4d 把数字来源标到**真实出处条款号**(如"正文3.1"而非"附件三")。补回的数额仍走金额数学交叉验证锁真值(不含税×(1+税率)=含税、单价×面积=不含税、税金/不含税=税率、对比相邻年度递增率是否合常理)。
2. **跨条款对撞**——这个字段在**别的条款**有没有被限定、修改、加例外、设前提、做衔接?
- 违约金:有没有"守约方/违约方"对等表述?有没有"不足赔偿的赔全部损失"兜底?(万达栽点:摘"2个月"漏了兜底全赔+对等适用)
- 期限:物业期限 vs 租赁期限对不对得上?(世茂青少物业2024/3 vs 新租赁2025/12)
- 解除权/优先权:正文说有,附件/补充协议有没有改掉、放弃掉?
3. **字面 vs 实际对撞**——合同这么"写",**实践中实际怎么执行**?字面机制会不会因某个空缺/前提不成立而落空?
- "两者取高"但提成比例空白→取高落空,实际只有保底。
- "可续租"但通知期已过/条件未成就→续租权实际已丧失。
4a. **跨副本对撞**——同一项目里若有**多份同一套标准格式合同**(如世茂青少+高中都是世茂52+格式、万达各校区同范本),**同一条款号的文本必须逐字相同**。提取后若发现**同一条款在两份副本里数值/表述不一致**,这**几乎必然是 OCR 错误**,不是真实差异——**立即回原图核,绝不把伪差异写进表**。(2026-06-18 世茂6.4装修违约金教训:青少 OCR 读"十倍"、高中 OCR 读"1倍",我把"青少10倍/高中1倍"当真实差异写进 K列还标"青少畸高"——其实两份同款合同6.4逐字相同,青少那行 OCR 是整行乱码,"十"是"1"的误识。同一范本同条款不一致=红灯,不是发现。)"十↔1""〇↔0""日↔目"等也是 OCR 高混淆对,和 ‰↔% 同等警惕。处置同费率符号:双跑交叉→不一致即裁图放大、`MEDIA:` 发 Maggie 肉眼终判,不在两个机器结果里挑一个。
- **🟢 反向建设性用法:同范本逐字相同特性可「补回」某份 OCR 丢失的字段,不止「揪错」(2026-06-22 悦拾光实证)**:当一份合同某关键字段被**页间断裂/污渍/整行乱码**吃掉、双跑裁图都拿不到时,回它的同范本兄弟合同核同一条款号——既逐字相同,兄弟份的值即这份的真值。实证:悦拾光一期租赁30.3逾期付款违约金率正好卡在第12页末「每逾期一日甲方有权按拖」断行处、费率数字消失在页间,各 psm 裁图都补不到;扩租合同(同星展模板)30.3 OCR 完整「按拖欠金额【3】%…逾期超【7】日停水电」——同范本同条款号,一期那缺口即【3】%。**比裁图发 Maggie 更省一步**(自动闭环,不必劳烦用户看像素)。前提同 4a:必须是**确认的同一套范本**(条款号体系、措辞结构逐条对应),补回数额仍走金额数学交叉验证/兄弟份二次确认。「跨副本对撞」完整双向用法:**不一致→揪 OCR 错;一份缺→拿兄弟份补**。
4b. **倍数/比例先换算绝对值再定级(别被大数字唬住)**——违约金"X倍日租金""X%"等,**必须乘出每天/每月的绝对金额,放进合同语境判断高低**,不凭倍数大小拍脑袋。世茂6.4:装修期是免租期,按营业期日租金折算——1倍≈1,100元/天=督促按期开业的常规违约金(**不构成风险点**);若真10倍≈11,000元/天=一天顶三分之一月租金,才叫畸高。**定级看绝对值与语境,不看倍数数字本身大不大。**
4c. **租赁违约金/保证金一律换算成"几个月月租金"进表(Maggie 2026-06-18 世茂确立)**——租赁合同的**保证金、违约金**等金额,进 H列/K列时**必须同时给出"=X个月月租金"**,让违约金是否过高一目了然、便于横向比对。物业合同同理换算成"X个月管理费"。
- 换算基数:租赁用**月(保底)租金**(14.2"平均月租金"则按租期加权平均月租算);物业用**月管理费**。
- 🔴 **分母陷阱:月租金 = 年租÷12(或半年租÷6),别误把半年租/全年租当分母(2026-06-22 人民中路实证,格式校对揪出)**。栽点:押金39,730元,月租金=119,190.75÷6=19,865元,正确换算≈**2个月月租**;我误用半年租金当分母算成"0.33个月月租"(39,730÷119,190.75=0.33,实为"0.33个半年期租金")。做除法前先把分母统一成**月**租金,再除。
- 实例(世茂):青少租赁保证金66,230元≈**1.95个月**月租;14.2违约金(平均月租3倍或等额取高)=101,685元=**3个月**月租。高中租赁保证金13,888元=**2个月**月租;14.2违约金=20,832元=**3个月**月租。青少物业履约保证金12,432.72元≈**3个月**管理费;高中物业6,944元=**2个月**管理费。
- 写法:金额后加括号注换算,如"租赁保证金:66,230元(≈1.95个月平均月租)""根本违约金(14.2):平均月租3倍或等额保证金取高=101,685元(3个月月租)"。这是金额提取的**标准动作**,不是可选。
4d. **金额条款号标"数字真实出处",不标正文"请见附件X"的指引条(Maggie 2026-06-18 世茂物业逐条纠正确立)**——给金额加条款号方便核对时,必须标**数字实际写在合同哪一条**,而不是正文里那句"具体金额请见附件一"的指引条。
- 世茂栽点(被 Maggie 逐条纠正4次):履约保证金我标"3.1.1"(正文指引条)实际数字在**附件一1.1**;管理费标"3.3"实际在**附件一第2条**;装修押金标"3.4.1"实际在**附件一3.1**;水电费标"3.6"实际在**附件一4.1**;租赁保证金标"5.1"实际在**附件三2.1**。世茂这类商业合同的金额数字几乎全在附件(附件一费用表/附件三租金表),正文条款只写"详见附件X"。
- 做法:标条款号前,回原文确认**数字落地在哪一条**——搜到正文"请见附件X"就继续往附件里翻,找到真正写着数字的那一条(如"附件一第2条""附件三2.1")再标。一翻就到,才是方便核对。
- 条款号格式忠于原文编号体系:世茂用阿拉伯数字(附件一1.1、附件三2.1),万达用中文章节(四、五、第四条一款)——不把万达的"四"硬改成"4.1",各合同标各自的原生编号。
4e. **分档条款先判"本租户属哪一档",都不属=该条不适用,不照搬档位数字(Maggie 2026-06-18 世茂质量保证金确立)**——合同按业态/类型分档约定金额时(如质量保证金分"充值类≥8万/零售1万/餐饮留空"),**必须先判断本租户(新东方=教育培训)属于哪一档**。
- 世茂栽点:质量保证金附件一1.2分三档(充值业务为主≥8万、零售商品`/`留空、美容美发餐饮`/`留空),新东方是**教育培训**机构——三档**都不属于**。我却把"≥8万(充值类)/1万(零售)"照搬进 H列,既误导(像是本合同要交8万/1万),其中"1万"还是 OCR 把零售档留空`/`误识成"1"。
- 正解:本租户不属任何档→**该条对本租户不适用,整条不列**(与"提成比例空白→不适用""装修违约金属常规→不列"同理)。绝不照搬不对应的档位数字。
- ⚠️ **业态推理优先于 OCR 字符核对**:当"本租户不属任何档"已能凭业态推理判定时,不必纠结某档的留空符号到底是`/`还是"1万"——那是"零售档"的填值,新东方本就不在零售档,符号是几都不影响"不适用"的结论。先用语境(业态归类)判适用性,再决定要不要核字符。这是"字面 vs 实际对撞"在分档条款上的落地。
4. **单位/量级对撞**——费率、金额、面积的**单位和量级**是否合理?OCR 文本的符号高度不可信,必须做常识量级核验。
- **‰ vs % 是 OCR 重灾区**(2026-06-17 世茂租赁14.1教训):扫描件 OCR 常把千分号 `‰` 误识为百分号 `%`。世茂4份合同 OCR 全部把"千分之2"识别成"2%",被 Maggie 当场抓出。
- **量级常识闸门**:日费率写进表前先口算年化——每日 X% × 365。**年化超过约 100% 就该警觉**(每日2%=年化730%,荒谬;每日千分之2=年化73%,合理)。逾期违约金/滞纳金日费率,正常落在 万分之几~千分之几(年化 18%~73%),**见到"每日1%、每日2%"先疑 OCR 误识,回 PDF 原件核符号**。
- 同理核:折年化标注别算错(0.5%/日=年化182.5%,不是18.25%;万分之5/日才是年化18.25%)。
- **OCR 符号判不准时的处理**:扫描件低质量 OCR 对 ‰/% 这种小符号经常判不清,机器反复试无解→**标注"OCR数值,单位以PDF原件为准",不武断定值**;能看清原件就看(局部裁剪放大),看不清就请 Maggie 核(她看过原件)。绝不拿可疑的 OCR 符号当确定结论填进交付物。
- **主动双跑核符号,不止"怀疑"(2026-06-18 世茂物业9.2实证)**:对存疑费率符号别停在"先疑 OCR"——主动跑**两种方法**交叉验证:①整页 OCR;②把该费率行**裁出来放大 3–4 倍单独重 OCR**(tesseract chi_sim+eng 多 psm)。**两次结果不一致 = 已证明机器判不准,立即升级人工**,绝不在两个机器结果里挑一个填表。世茂实证:青少物业9.2 整页读 `0.5%`、裁图重读 `0.5‰`;高中物业9.2 两次分别读 `1%` 和 `1‰`——三跑三种组合,铁证 OCR 不可信。常见误识:`%` 被读成"吃",`‰` 与 `%` 在不同 psm 间反复横跳。
- **升级人工要带"放大裁图",不甩空问题(2026-06-18 确立)**:机器判不准时,把那一行费率裁出、放大、存 PNG,用 `MEDIA:` 发 Maggie 做肉眼终判(符号就在数字后那一个字符),而不是空口问"是%还是‰"。这是"不把校验责任推给用户"在符号核对上的落地——能做的双跑交叉先做尽,剩下唯一机器解不了的一个像素符号才交人。✅ **vision_analyze 已配好可用(魏玮 2026-06-22 配置,实测能准确读出渲染图的红色标记/文字截断/版面)**:现在符号判不准时**先自己 `vision_analyze` 看裁图终判**,能自核就不必裁图发 Maggie;自核仍拿不准的像素级符号才交人。这是从前"vision provider 未配、只能裁图发人"的升级——主路径变为自核,发人是兜底。命令级配方见 `references/ocr-rate-symbol-verification.md`。
- **金额用数学交叉验证,构成自洽 = 不必看图、不必问人(2026-06-18 世茂高中物业管理费实证)**:当 OCR 的**合计/总额不稳**(同一"合计"两跑读成 1347、8472)但**构成项稳定**(不含税 3275.42 + 税金 196.53、单价 9.43×面积 347.2、税率 6%),用**算术关系反推**就是最硬的裁判——比看像素更可靠,且**全自动无需人工**。三条恒等式当探针:①`不含税 + 税金 = 含税合计`(3275.42+196.53=3471.95,自证"合计1347"是误读)②`单价 × 面积 = 不含税`(9.43×347.2≈3274,与 OCR 不含税吻合)③`税金 / 不含税 = 税率`(196.53/3275.42=6.00%,精确命中)。三式互相咬合且与多数稳定 OCR 值一致 → 锁定真值,OCR 那个不稳的总额直接弃用。**适用面**:凡"分项 + 合计"结构(管理费、租金保底=不含税+税金、押金=N月租金)都先做构成自洽核验,再决定信不信 OCR 的总额。比量级闸门更进一步:量级闸门排除荒谬值,数学交叉验证直接算出真值。
### 铁律
- **三对撞过不了,不填表**。任一对撞发现问题,回原文核实清楚再落笔。
- **优先用提取报告里 subagent 已标的"空缺/待核"信号**——它标了"提成%空缺"我却没用,是整合失职,不是它没干活。subagent 标的每个"待核/空缺/乱码"都必须在三对撞里被显式处理掉,不能晾着。
- **判断过程不写进交付物**(Maggie 2026-06-17):三对撞是我审查时走的内部工序,汇总表/结论里**只写整合后的最终结论**(如"营业期保底租金"),不写"因提成空缺所以不适用"这类推理过程。过程留给主审清单,交付物只留结论。
- **核实痕迹不写进交付物**(Maggie 2026-06-18 世茂确立):我**自己的核实过程**——"经PDF原件核实""经数学交叉核实""关键数字均经PDF原件+数学交叉核实""(目录XX系扫描漏识L)"等——一律**不进交付物**,删除。客户只需看结论,不需要知道我怎么核的。核实是我的内部责任,留痕在工作记录/主审清单即可。这与"判断过程不进交付物"同源。世茂栽点:青少/高中物业K列写"(9.2,经PDF原件核实)"、高中物业末尾整段"〔关键数字均经PDF原件+数学交叉核实…〕"注脚,被 Maggie 要求全删。
- ⚠️ **清理核实痕迹(及任何统一性清理/修正)必须全表扫描,配对合同往往漏一处(2026-06-22 世茂确立)**:青少↔高中、租赁↔物业的同款条款常含同一句痕迹,删一处极易漏掉配对那份。实例:Maggie 手删青少物业 I8 的"经原件核实",却漏了高中物业 I14 的同款痕迹("逾期缴费违约金1‰/日(9.2,经原件核实)"),由小Maggie 补扫全表清掉。续做接手/交付前**用脚本全表 grep 痕迹关键词**(经原件核实/经原件/经PDF/经数学/均经…核实/核实〕)确认 0 残留再发——别只清用户点名的那一处。这是「同口径修正必须扫全表对齐」在清理动作上的同一抓手。
- **需客户核实的内容整条标红**(Maggie 2026-06-18 世茂确立):风险点/备注中**需要客户去核实或确认某个事实**的内容,**整条标红**(红色 FFFF0000),方便客户一眼识别待办。
- **判据(标红 vs 不标红)**:①只标"**需客户去核实/确认事实**"的——如"服务期衔接需核实""2028年度计租标准建议签约时补明或确认";②**提示类不标红**——"提示按时缴费""提示知悉""提示自行投保"等是提醒乙方履约注意,不是要客户核实事实;③**我已核实的不标红**(且核实痕迹要删,见上条)。
- **范围:整条标红**(从该条编号到句末整条,不是只标"需核实"那半句)。Maggie 先要"只标需核实那句"、后改为"整条标红"——以**整条**为准。实例:青少物业第1条(服务期衔接需核实)、高中租赁第9条(2028租金留白需确认)、整体分析第6点(衔接需核实)三条整条红色。
- ✅ **标准做法(2026-06-18 世茂最终验证成功):openpyxl 写富文本红色 → WPS 打开另存为 xlsx → Excel 不报错且红色保留**。这是"单元格内某条标红、其余黑、且 Excel 兼容"目前**唯一跑通**的路径,Maggie 已亲验 Excel 正常打开+红色在。两步缺一不可。
- **技术实现**:openpyxl `CellRichText` + `TextBlock(InlineFont(rFont="微软雅黑", sz=10, color="FFFF0000"), 整条文本)`,黑色段 `color="FF000000"`,按 `\n` 切分逐行判断、整行染色保留换行。务必带 rFont/sz 与全表一致(微软雅黑10),否则富文本丢字体。
- ⚠️ **为什么必须 WPS 另存这一步**:openpyxl 把单元格写成 `inlineStr`+`CellRichText`(`<is><r><rPr>…`),不合 Excel 严格 OOXML 校验 → Excel 报"部分内容有问题/需要修复",点"修复"会**丢弃富文本→红色一并丢失**。试过手改 XML(修 rPr 子元素顺序 rFont→charset→family→sz→color、补 `charset=134`/`family=2`)**都没用**。WPS 容错宽松能正常打开,**且 WPS 另存会把整个文件重写成规范格式(inlineStr→sharedStrings),消除所有不合规处**——这才是 Excel 不再报错的真正原因。验证规范化成功的标志:另存版 xlsx 里 `xl/sharedStrings.xml` 存在(openpyxl 原版没有)。
- 🔴🔴 **富文本红必须是 openpyxl 写入的「最后一步」,中间任何 load_workbook→save 都会把红打回纯文本(2026-06-22 悦拾光实证,多次返工教训)**:openpyxl 的 `CellRichText` 在「`load_workbook` 重新加载→`save`」一个来回后**退化成纯字符串**——哪怕这一轮只改了别的格、甚至只改了行高没碰富文本格,红色照样全丢。悦拾光栽点:H5/H6 写好富文本红(9处)后,又去 `load_workbook` 改 K列定性错误、改行高,每改一轮存一次,红色就被打回纯文本一次(红run 9→0),反复三次。**正解:把所有文本编辑(改错别字、补条款、改措辞、改行高 row_dimensions)全部做完定稿后,在同一个脚本的最后一段一次性写富文本红 + `save`,之后绝不再 `load_workbook`**。写完立即用 `zipfile` 读 `xl/worksheets/sheet1.xml` 数 `FFFF0000` 验证(不要用 openpyxl 读回验证——读回这个动作本身无害,但养成「写完只用 zipfile 验、不 load」的肌肉记忆更稳)。顺序铁律:①所有纯文本编辑→②设行高→③最后写富文本红→④save→⑤zipfile验红run→⑥WPS另存/x2t渲染。
- 🔴🔴 **二次编辑已标红(WPS规范化)的 xlsx:绝不用 openpyxl 重存——它会把红色全毁掉(2026-06-18 世茂实证)**。WPS 另存后的好文件存储用 `sharedStrings.xml` + 富文本红 `<r>` run;一旦 `openpyxl.load_workbook → 改 → save`,openpyxl 把整表打回 `inlineStr`、**3 处红色 run 直接归零、`sharedStrings.xml` 消失**,Excel 又报"需要修复"——等于把 WPS 救回的成果一键作废。**改一个字都不能用 openpyxl 存。**(本 session 实测:openpyxl 改完 H11 后红色 3→0,结构 sharedStrings→inlineStr,幸亏有 WPS 好基线备份才回得来。所以**改前先把 WPS 好版本另存 `_bak_` 备份**。)
- 🔴🔴 **建造阶段(WPS 规范化之前)同样会丢红:每一次 `load_workbook → save` 循环都把 CellRichText 悄悄打回纯字符串,哪怕这次只改了别的格 / 只设了行高、根本没碰富文本格(2026-06-22 悦拾光实证,连丢两次才定位)**。机制:openpyxl 一旦重新加载含富文本的工作簿再 save,所有 CellRichText 一律退化成 str、红 run 归零。所以**富文本红必须是整个建表流程的最后一步**:① 先在同一个脚本里把所有行高设好、所有黑字内容写完;② **最后**才写 CellRichText 红 run;③ `save`;④ 此后**绝不再 `load_workbook`**——要渲染 / 上传直接拿这个文件,要改内容就回到①把整段脚本重跑(含最后写红),不在已写红的文件上二次 load。**验证红色只用「只读 zip 数 `FFFF0000`」**(`zipfile` 读 `xl/worksheets/sheet1.xml` 或 `sharedStrings.xml`),**绝不用 `load_workbook` 复核**——一 load 一 save 红就又归零。典型错序(先写红 → 再 load 设行高 → save)= 红照样没,本 session 正是这样连丢两次。
- ✅ **正解:在 `sharedStrings.xml` 的 XML 层做外科手术,红 run 一个字不碰**。流程:①`zipfile` 解压 WPS 好文件到临时目录;②`sheet1.xml` 里每个格子 `<c r="H11" t="s"><v>45</v></c>` 的 `<v>` 就是 **sharedString 索引**——据此把"要改哪个格"翻成"要改第几条 `<si>`";③lxml 打开 `xl/sharedStrings.xml`,**只替换目标 `<si>` 里的 `<t>` 文字节点**(纯文本格直接重写单个 `<t xml:space="preserve">`;含红 run 的格**只改黑色 `<r>` 的 `<t>`**,红色 `<r>`(带 `<rPr>…<color rgb="FFFF0000"/>`)原样保留);④重新打包。
- ⚠️ **重新打包必须把 `[Content_Types].xml` 放 zip 第一项**(其次 `_rels/`),否则 LibreOffice 等严格解析器报 `source file could not be loaded`(Excel/WPS 宽容,但别赌)。用 `zipfile.ZIP_DEFLATED`,逐文件 `zf.write(full, arc)`。
- **删一条红色风险项 + 顺移编号**(如已核实的"留白"风险撤销 → 整条删):lxml 里定位目标 `<r>`(按 `<t>` 文字开头如 `"9. "` 且含关键词)→ `si.remove(目标<r>)` → 遍历后续 `<r>` 的 `<t>` 把 `"10. "→"9. " "11. "→"10. "` 前缀顺移,保持 1–N 连续。删红 run 时红色计数会随之 −1(删的就是那条红)。
- **改完五查**(本地验不了 Excel,这五项是能自动做的最强保证,过了再发 Maggie):①`xl/sharedStrings.xml` 仍在;②红色 run 数 = 改前预期(删 1 条红就 3→2,没删就不变)——用 `re.findall(r'rgb="FFFF0000"', ss)` 数;③`zipfile.testzip()` 通过 + 所有 `.xml/.rels` 部件 lxml 能 `fromstring` 解析;④目标格文字已更新、编号 1–N 连续、旧表述("留白/未约定"等)全表 `grep` 0 残留;⑤与 WPS 好基线**部件清单同构**(`set(namelist)` 差异仅空目录条目可接受)。一键跑:`scripts/edit-redmarked-xlsx.py --verify <文件> --baseline <WPS好基线>`。
- **最终仍交 Maggie 用 Excel 肉眼终判**(同"本地验不了 Excel"铁律),但上述五查全过再发,不裸交。完整可复用实现(解压→按 si 索引改 `<t>`→删 run 顺移→规范重打包→五查)见 `scripts/edit-redmarked-xlsx.py`。
- 🔴 **「红色run数」五查②不是「标红条数」,别拿它当颜色没动坏的护身符(2026-06-22 世茂确立)**:`red_run_count()` 数的是 `rgb=FFFF0000` 串的出现次数,**等于带红 rPr 的 `<r>` 个数**,不等于「有几条风险被标红」。隐藏陷阱:某些长单元格(世茂 K8 青少物业、K11 高中租赁、A16 整体评价)在 WPS 规范化时被整格染红——**该格每个 `<r>`(标题/每条/空行)都带红 rPr**,K8=8 红run、K11=15、A16=20,全表实际红run≈47,而非交付物语义上的「6 处标红提示」。我连续几次编辑都看「红色保持6 ✅」就放心,那个 6 只是凑巧(H5/H8/H11/H14 四个单纯提示格各1 + 别处)——它**根本不反映三个长红格的真实状态**。教训:① 改前先 `--show-si <idx>` 看目标格的 run 结构,确认它是「纯文本格 / 单红run / 整格全红」哪一种,再决定怎么动;② 含红 run 的格做编辑/插入新条时,新增 `<r>` 会**继承所在格的染色基调**(整格全红的格里插的新条也会是红的),不想红就显式给新 run 的 rPr 设黑 `<color rgb=FF000000/>`;③「整格泛红」是否要修属格式返工、范围大,**先渲染发 Maggie 确认她 Excel/OnlyOffice 里看到的是黑字还是红字再决定**,不自作主张大改已验收过的格。④ 自己看不了图时用**像素分析绕过 vision**:`PIL`+`numpy` 读 x2t 渲染的 PNG,按 `(R>120)&(G<90)&(B<90)` 数红字像素、`(R<90)&(G<90)&(B<90)` 数黑字像素,逐水平带判主色,能量化「整格红 vs 黑字为主」——比纯靠 XML 推断更接近用户实际所见(本次实证 K8 区域红字带6 vs 黑字带13,红黑混杂而非纯红)。
- ⚠️ **本地无法自验 Excel 行为,别反复甩"修好了"让用户当测试员**:x2t/LibreOffice 在本环境都不能可靠复现 Excel 的严格校验(LibreOffice 连干净版都报 "source file could not be loaded",是环境问题非文件问题,不可用它验证)。本 session 连续 4+ 次"还是不行"就是反面典型。**没有能复现失败的工具时,先说清"我这边验不了 Excel",把带富文本红色的版本发给用户、请其 WPS 另存,不要断言成功。**
- **给用户的话术**:标好红后——"文件红色已标,但 openpyxl 生成的格式 Excel 会报'需要修复'。请你把这个文件用 WPS 打开 → 另存为 xlsx,Excel 就正常了、红色也在。另存后发我,我替换到 Nextcloud。" ⚠️ 被另存的必须是**带富文本红色那一版**(别拿中途"去富文本"的版本去另存,那样没红色)。
- **更省事的退路(嫌 WPS 那步麻烦/纯自动化场景)**:纯文本前缀 `【需客户核实】`/`❗待核实:`,整条黑字零格式,Excel 绝不报错——但没有红色高亮。Maggie 要红色就走 WPS 另存法。
- 完整排查全过程见 `references/openpyxl-excel-richtext-pitfall.md`。
- 这道工序对治的是"上下文整合",与「整款通读不摘单句」「合同间整体审查」是同一方法的三个抓手:整款通读=条款内不漏要件,整体审查=条款间不漏关联,整合质询=填表前强制对撞收口。
### ⚠️ 法律风险须标注对应合同条款(Maggie 2026-06-17 万达打样确立)
每一条法律风险**尽量标注其对应的合同条款号**,方便 Maggie 或客户在需要时回原文核对,使风险结论可溯源。
- **明确条款型**(合同里确有该条款)→ 直接标号,嵌在描述里:如「装修期满须恢复原状(第五条3款)」「违约金仅2个月租金(第九条1款)」「续租通知期 第八条3款"三个月" vs 第十条1款"一个月"」。
- **缺失型风险**(合同里压根没有某约定,如无办证退出通道、无任意解除权、无查封拍卖衔接条款)→ **不硬凑条款号**,写清"第X条仅有…,无…"或"合同无…条款",点出"在哪个条款本该有却没有"。如「第八条仅有协商解除/期满终止,无任意解除权」。
- 标注方式:**在风险描述里嵌入条款号(括号或行文)**,不另起统一后缀(Maggie 已确认此方式)。提前解除路径等结论也同样补出处(如「协商解除(第八条1款)」「押金不退(第五条2款)」)。
- **铁律:条款号必须核对合同原文逐条确认,绝不臆造**。标注前先读 OCR 原文 `.md`,把每条风险 → 原文条款匹配一遍;写入后用脚本验证关键条款号是否到位。
- 法条(民法典X条)仍按〔待核实〕处理,与合同条款号是两回事:合同条款号是本合同内部定位,法条编号是外部法律引用。
### ⚠️ 金额/费用栏(H列)也标注对应条款号(Maggie 2026-06-18 世茂+万达确立)
K列法律风险标条款号的同理,**H列每个金额/费用项也标注其在合同中的对应条款号**,方便 Maggie/客户回原文核对。这是 H列金额提取的**标准动作**,与「4c 换算月租金」一起做。
- **格式按世茂来,简单明了**:金额后加括号注条款号,如「租赁保证金:66,230元(5.1,附件三;≈1.95个月平均月租)」「管理费:含税4,144.24/月(3.3,附件一)」「根本违约金(14.2):…」。
- **⚠️ 条款号忠于各合同原文的编号体系,绝不统一臆造**:
- 世茂租赁/物业用**阿拉伯数字**:保证金5.1、保底租金5.2、结算周期5.2.2、违约金14.2;物业履约保证金3.1.1、质量保证金3.1.2。
- 万达租赁用**中文章节**:租金「四」、押金「五」——原文就是「四、租金及支付方式」「五、租赁押金」,**不能硬改成「4.1」造成与原文不符**。
- 万达物业用「**第四条**」:物业费/能耗第四条一款、垃圾清运/装修保证金第四条二款。
- 「格式按世茂来」指的是**括号注法简洁**,不是把所有合同的编号都改成阿拉伯数字——编号本身必须能在该合同原文里查得到。
- **金额数字常在附件,正文条款只定规则 → 双层定位**:世茂保底租金正文5.2只写「标准见附件三」,具体数额在附件三 → 标「(5.2,附件三)」。同理保证金「(5.1,附件三)」、管理费「(3.3,附件一)」。
- **同范本不同份,条款号可能不同,必须各自核**:青少物业管理费在 **3.3**、高中物业管理费在 **3.2**;青少物业装修押金 **3.4.1**、高中物业 **3.3.1**——别因为是同一套世茂物业格式就套用同一条款号。逐份回原文 `grep` 核条款标题。
- **铁律:条款号必须回 OCR 原文逐项核实,绝不臆造**(同 K列法律风险条款号铁律)。
### ⚠️ 金额/费用栏(H列)推算每期支付截止日;推不出则总结+红色提示(Maggie 2026-06-18 世茂确立,确认为**通用规则**)
> 🔵 **通用规则(Maggie 2026-06-18 明确)**:「付款安排 + 无法推算时红色提示」是**所有租赁/物业类合同梳理的标准动作**,不是世茂个案——以后**每一份**租赁/物业合同进 H列都要做。与「H列标条款号」「4c 换算月租金」一并,构成 H列三件套。
H列不只列金额,还要处理合同约定的**付款时间**,让 Maggie/客户一眼知道下一笔哪天该付。**分两条路径,按"起算日能否确定"分流**:
- **路径A(起算日确定)→ 推算具体支付截止日**:把抽象付款规则("预付制""结算周期六个月""上个结算周期最后一个月15日前支付")逐期算成**每一期的具体支付截止日**。**尚未到支付时点的(付款截止日 ≥ 审查当日)整条标红提示**(红色 FFFF0000,标红技术走前述「openpyxl 写富文本红 → WPS 另存规范化」/「sharedStrings XML 层加红 run」法)。
- **路径B(起算日不明/推不出)→ 总结合同写了什么 + 红色提示约定不清**:见下方"🔴🔴 推算不出来就别硬推"条。**绝不硬凑确定日期。**
- **推算前先定起算日**:营业期/计租起算日决定全部结算周期的划分。**装修期是否计租、营业期从哪天起算,必须回原文+向 Maggie 确认,不擅自假设**(世茂高中:Maggie 确认起租日=2026/1/1,含装修期也计租)。起算日错→整串付款日全错→误导付款。
- ⚠️ **"租赁期限X年,自A至B"——A到B的日历跨度可能 > X年,免租装修期常不计入约定的X年租期(2026-06-22 人民中路 Maggie 确立)**。人民中路合同写"租赁期限5年,自2025/3/1至2030/4/30",但该区间日历跨度实为5年2个月:前2个月(2025/3/1–4/30)是免租装修期、**不计入5年**,真正5年计租期是2025/5/1–2030/4/30。识别三个别混的概念:①日历区间(A至B) ②约定租期年限(X年) ③计租期(起租日起算)。G列分行写清三者,免租期单列并注"不计入X年租期、物业费由乙方承担"。Maggie 原话:"免租期没有算到租期里"。注:商业租赁的"租赁期限"措辞常与免租期叠加导致 OCR/字面易误读,是否计入须回原文+问 Maggie。
- **日期是硬事实,用代码算不手算**:按结算周期逐期划区间,套合同付款条款算每期截止日(如"上个结算周期最后一个月的15日前")。算完**先把每期推算结果列给 Maggie 核对合同解释**(起算日含义、各期付款节点解读),确认后再写进表。
- **标红范围**:只标"付款截止日 ≥ 审查当日"的**未到期期次**;已付/已到期的黑字。基准日取审查当日(如 2026/6/18)。这与「需客户核实内容标红」用同一套红色技术,但语义不同——这里红的是"未来待付款提示"。
- 🔴🔴 **推算不出来就别硬推——退到"总结+提示"路线(Maggie 2026-06-18 世茂反转确立)**:起算日合同没写死、各期具体付款日**确实推算不出来**时,**绝不硬凑一个确定日期**(错的确定日期比"不确定"更危险,会误导付款)。Maggie 原话:"算了,世茂的我也推算不出来。这种推算不出来的,就总结下合同里写的结算周期,以及支付时间。无法推算的,就提示合同约定不清楚,请注意实际支付时间之类的。" 改走两段式:
- **第一段(黑字·照实总结合同写了什么)**:`【付款安排】结算周期X个自然月/一个自然年,预付制(条款号):首期于营业期起租日/交付日支付;其后每期于上一结算周期最后一个月15日前提前支付(遇法定节假日顺延)。`
- ⚠️ **已过期的确定节点不列(Maggie 2026-06-18 世茂青少确立)**:合同明确写了的确定付款节点,**只有付款截止日 ≥ 审查当日(未来/待付)才列**;**截止日 < 审查当日(已过期)的一律不列**——付款安排聚焦"尚未发生、需提醒注意"的,过去的节点占篇幅无意义。世茂青少租赁"第二期保底租金余额335,133.92元应于2026/4/15前支付"——2026/4/15 早于审查日 2026/6/18,**已过期 → 删除不列**(Maggie 原话"这个时间点已经过了,没必要列出")。红色提示里同步去掉对该过期节点的引用(如"及第二期付款日(2026/4/15)"删掉)。判据与"标红范围只标未到期期次"同源:**已发生的不提,待发生的才提(未到期还标红)**。
- **第二段(整句标红·提示约定不清)**:`🔴合同未明确约定营业期起租日/交付日,各期具体付款日无法推算,请按实际营业期起算日/交付日及甲方账单核对支付时间。` 整句红色 FFFF0000,走「openpyxl 写富文本红 → WPS 另存规范化」/「sharedStrings XML 层加红 run」标红法。
- **判据:能确定起算日→推算具体日期(前几条);起算日不明/推不出→总结+红色提示(本条)。** 律师式严谨:宁可标"约定不清、请核实实际支付时间",不给一个算错的确定日期。这与"事实问题不靠文本提取下结论""OCR 缺失数字标〔待核〕不编造"同源——**不确定的事项如实提示,不假装确定**。
- **🔑 客户问"能否推算具体付款日"时,回 OCR 原文核条款原话,不靠汇总表摘要判断(2026-06-22 世茂青少物业确立)**:表里的付款节点是压缩摘要,本身可能语义不全,不足以支撑"能否推算"的判断。Maggie 问青少物业"上一个自然年15日前,是不是12月15日前"——回 OCR 原文核 3.3.3/3.3.4,发现原文**真的只写"上一个自然年15日前"、没有月份限定词**(两处独立一致出现 → 非 OCR 丢字,是合同本身缺月份)。结论:即使起算日确定,光凭"15日前"也定不出哪天 → 约定不清。**calculability 是事实问题,回原文条款原话核,不在表摘要上推。**
- **同范本不同份,付款条款表述可能真实不同(不止条款号不同)**:青少物业(3.3.x,结算周期一个自然年,"上一个自然年15日前"**缺月份**) vs 高中物业(3.2.x,结算周期6个自然月,"上一个结算周期**最后一个月**的15日前"**月份完整**)——同套世茂物业格式,付款节点表述却**真实不同**,青少那份更彻底地约定不清。已比对 OCR 原文确认非 OCR 错。"跨副本对撞"既要防 OCR 伪差异、也要识别真差异,**不能因"同范本"就假设两份逐字相同**。校准一份时回兄弟份核原文:相同才一并改,不同则只改该份(本次只改青少 H8,高中 H14 月份完整、归因正确,原样不动)。
- **"约定不清"的红色提示精准归因到具体条款缺陷,不泛泛说"起算日未定"**:旧提示"合同未明确约定交付日/起算日…"归因不全;青少物业真正的双重缺陷是 ①交付日未写死 ②"15日前"连哪个月都没写。改为点明"合同3.3.3/3.3.4仅约定'上一个自然年15日前',未明确具体月份(如是否为12月15日),且交付日/起算日未写定…"——客户拿表即可对照条款知道是合同本身漏洞。**精准归因 = 客户可操作。**
- 🔴 **"推不出"的第二类成因:付款条款文本本身缺关键限定词(月份/日期锚点),不止起算日缺失(2026-06-22 世茂青少物业确立)**:除"起算日未写死"外,**条款字面缺月份/日期锚点**同样导致推不出确定日,且更隐蔽——表里摘要常把它"脑补补全"掩盖掉。世茂实证:青少物业 3.3.3/3.3.4 原文是"乙方应于**上一个自然年 15 日前**支付下个自然年的管理费"——**"自然年"前缺了月份**(不像租赁合同写全的"上一**结算周期最后一个月**15日前")。字面"上一自然年15日前"语义不完整,存在两解:①脱漏"最后一个月"→应为12月15日;②约定不清。**Maggie 直觉问"是不是12月15日"方向多半对,但合同文本本身没写"12月"这三个字,按律师严谨不能替合同补全**。
- 必查动作:用户问"能不能推算出具体日期"时,**绝不拿汇总表里被压缩过的摘要回答**(摘要可能已把缺失的限定词脑补掉)——必须回 OCR/PDF 原文核该付款条款的**逐字表述**,确认月份/日、起算日是否都写全。表摘要"上一个自然年15日前支付下一自然年"看似完整,原文实则缺月份。
- 处置:缺锚点→仍走"总结+红色提示",且**红提示里如实点明是合同文本缺了什么**(如"合同3.3.4仅约定'上一自然年15日前'未明确月份,请按甲方账单核对"),让客户知道是合同本身没写清、不是我们漏算。是否按解读①直接认定确定日期,属法律判断取舍,**提请 Maggie 定,不自作主张补全**。
- 标红语义:这里红的是"**合同约定不清的待核提示**",落在"需客户核实/确认事实"那一类(见「需客户核实内容标红」判据①),整句标红,与"未来待付款提示"标红并行不悖。
- **整批同套格式合同付款条款逐字相同时,四份文案可成套复用**:世茂青少+高中、租赁+物业四份均"首期于起租日/交付日付、其后每期上个结算周期最后一月15日前预付",只结算周期不同(高中租赁6月、高中物业6月、青少租赁12月、青少物业一自然年)——文案套同一模板改结算周期即可,不必每份从头写。
- **世茂四份付款条款实例**(同套世茂52+格式,付款节点逐字相同):租赁/物业均"首期于营业期起租日/交付日支付,之后每个结算周期的保底租金/管理费于**上个结算周期最后一个月的15日前**提前支付(遇法定节假日顺延)"。结算周期:**高中租赁6个月、高中物业6个月、青少租赁12个自然月、青少物业一个自然年**。
- 这与 4c(换算月租金)、H列标条款号同属 H列标准动作——都是"交付物信息完整、可核对、待办凸显"的 Maggie 偏好。
#### 零散情形 → 提炼专业法律概念(Maggie 2026-06-17 万达打样确立)
- 审查中遇到**零散列举的具体情形**,识别其背后的**专业法律概念**,用概念统领、具体情形作括注,比堆砌情形更专业简洁。
- 租赁场景三类常见(背后是不同制度,**别张冠李戴挂法条**):
- 出租方变更/过户 → **所有权变动**(买卖不破租赁,民法典725条)
- 查封、拍卖(执行/第三人处置)→ **第三人主张权利**(民法典729条)—— 注意:查封≠所有权变动,不能挂725
- 征收、拆迁 → **征收征用**(民法典243条)
- 格式:`专业概念(具体情形举例)`。实例:原写"合同无查封拍卖、出租方变更、征收拆迁的衔接条款(援引725条买卖不破租赁)"(情形零散+725挂错),改为"所有权变动(出租方变更)、第三人主张权利(查封、拍卖)、征收征用等情形未作安排,实操中求偿困难"。
- **法条号默认不写进表格正文**(Maggie偏简洁)——概念用准即可,法条留作内部依据;正文落点回到"实操求偿困难"等实际后果。
### ⚠️ 风险定级纪律:克制,按"实际影响"分级(Maggie 2026-06-17 万达打样确立)
站乙方立场审查不等于把每处瑕疵都往高风险写。Maggie 反复纠正"评高了"。三条定级铁律:
**① 形式瑕疵按"是否实际影响成立/生效/履行"定级,不夸大**
- 形式瑕疵 = 签署日期空白、印章不全、填空未填、落款不完整等。
- 判据:若**关键履行要素已明确约定**(租期起止、金额、付款时间,标条款号)**且合同已实际履行**(《民法典》第490/502条:一方履行主要义务对方接受即成立生效),则纯形式瑕疵评 🟢**低风险**,不评高风险、不写"合同效力存疑"。
- 建议落点:**不渲染风险,落到"建议补正以规范合同管理"**。
- 表述模板:"X空白/未填。鉴于〔关键要素已明确(第X条)+合同已实际履行〕,X缺失不影响合同成立与生效,风险较低,但仍建议明确X,以规范合同管理。"
- 实例:万达"签署日期空白"原评🔴"合同是否有效成立存疑"→ 降🟢低风险。
**② 尽调/核验类事项用操作性"请确认…"提示,不写成"未核验→风险"**
- 适用:权属核验、资质核验、证照查验等"应做、通常已做、只是需确认"的事项。
- 写法:操作性核验提示,**假定通常已做**,措辞平和。如"核验出租权:请确认已核验过甲方的不动产权证明原件,并有复印件存档"。
- 等级:归 🟢 低风险(建议规范类),**不放高风险/须核实区**,不写"无从核验→效力风险"。
- 实例:万达"出租权属未核验(风险)"→ 改"核验出租权:请确认已核验…并存档",归🟢低风险。
**③ 一条风险只讲一件事**——别把一个真问题和一个伪问题捆在一条(会一起被拔高)。万达原把"签署日期空白"+"出租权属未核验"捆成一条评🔴;拆开后各自降级。
**⑤ 责任/违约条款必须整款通读,不摘单句(确认偏误警示,2026-06-17 万达违约救济教训)**
> ⚠️ **前置主规则(合同级第一性原则,非仅违约条款)**:整款通读的前提是**完整通读全文 + 准确把握每个条款的上下文语境与语义**。这不是违约条款的特殊要求,而是审查地基——**整个合同、每一条款**都要先通读、读懂语境再下结论。法律审查第一步必须 `read_file` 把整篇合同 OCR 文本(.md,通常仅20–60KB)一次性读完,**禁止用 grep 命中单行代替阅读**。详见 `references/independent-legal-review-framework.md` 第0步·审查第一性原则(2026-06-18 世茂14.2教训:错误归因"PDF太大不能通读",实测 OCR 文本仅58KB完全可整篇读;根因是"命中即停"+只扫字没读懂语义)。
- 违约/责任条款通常含多要件:**结算方式 + 违约金 + 兜底赔偿 + 适用主体 + 情形列举**。全部识别再下结论,看到"违约金X个月"就停 = 断章取义。
- 实例:万达第九条1款,只摘"2个月违约金"判"救济偏薄、对乙方不利",漏看了 ①"甲乙双方…守约方有权解除"=**双方对等适用** ②"按实际使用天数结算租金"=年付未用部分照退 ③"违约金不足以赔偿的赔全部损失"=**兜底全赔**。整款实为均衡条款,结论被推翻、该条移除。
- **先判"对谁适用"再判利弊**:中国合同违约责任多为"守约方/违约方"对等表述。下"对X方不利"前先确认是单方还是双方对等条款;对等条款不存在偏向谁。
- **"偏薄/不足"类结论必须先排除兜底**:全条款搜"不足以赔偿的赔全部损失"类兜底句,有兜底就不能说"无法覆盖损失"。
- **警惕标签预设 = 确认偏误**:自然人房东、格式不规范等标签会诱导预判风险,带预设找证据只看见印证预设的部分。正解:用条款本身说话,问这条实际给了X方什么、拿走了什么。
- **违约金/赔偿/费率必须连「计算基数」一起提取,只写比例=没说清(2026-06-18 世茂青少租赁14.1教训)**:写「千分之二」「3倍」「30%」而不写**乘什么**,等于没提取——读者不知道基数。基数(如「逾期**应付费用总额**的千分之二」「**平均月租金**的3倍」「**合同总价**的30%」)必须与比例同时进 I列/K列。世茂栽点:14.1 原文「按前述**逾期应付费用总额**的千分之二」,我只写「逾期付款违约金千分之2/日」漏了基数,被 Maggie 纠正。提取费率/违约金时强制自问:这个百分比/倍数**乘的是哪个金额**?基数没提到=回原文补。
- **⚠️ 同一合同里多处「同数值」违约金,基数可能完全不同,必须逐条认基数防张冠李戴(2026-06-22 悦拾光实证)**:一份合同常有数个违约金/赔偿率,数字碰巧相同极易混填。悦拾光实证:6.1 **开业违约金** = 首期租金的 **3%**(基数=首期租金,督促按期开业的一次性违约金)vs 30.3 **逾期付款违约金** = **拖欠金额** 的 **3%**/日(基数=拖欠金额,逐日累计)——两个都是「3%」但**基数、性质、计息方式三不同**,旧版 K列把两者混为一谈、基数张冠李戴。铁律:见到同合同多处违约金,**一律按条款号逐条独立认「率×基数×计息单位(一次性/按日/按月)」**,绝不因数字相同就假设是同一个机制。年化/绝对值核高低时(4b)也要带对基数:拖欠金额3%/日=年化1095%畸高(但若是合同真实条款则照实记,疑 OCR 先核——本例双份核过3%属真值非误识),与开业违约金3%(一次性、基数=首期租金)完全两码事。
- ⚠️ **只标你核过的区分维度,别为「解释清楚」凭空添一个没核的维度——会自造新错(2026-06-22 悦拾光 6.1 二次教训,由独立法律校对 subagent 揪出)**:区分两个同数值违约金时,我为了「讲清楚」给 6.1 加注「属一次性」与 30.3「按日累计」对比——**但 6.1 原文是「每逾期一日按首期租金3%」,本身就是按日累计、不是一次性**。真正且唯一核过的区分维度只有**基数不同**(30.3=拖欠金额浮动 / 6.1=首期租金固定),计息单位我没核就脑补了个「一次性」,既错又对乙方低估风险(开业晚60天≈首期租金180%)。教训:写区分注**只写已回原文坐实的那个维度**(这里=基数),没核的维度(计息单位、绝对额、适用主体)一律不写——「为了表述完整而补一个想当然的对比项」是确认偏误的变体,宁可只点准一个维度,不画蛇添足凑三要素。这与「⑤b rule 2 标了条款号≠读懂条款」同源:别拿模糊印象填充你没真读的部分。
- **「除……外,……还应……」句式 = 责任叠加,必须逐层拆全(2026-06-18 世茂14.2教训,整款通读的句法级抓手)**:中文违约后果常是「**除[A]外**,[乙方]**还应**[B];若不足赔偿的**还应[C]补足**」的多层叠加。看到「除…外」就警觉——**「除…外」前面那一层(A)极易被整段漏掉**。世茂栽点:14.2 原文「**除乙方交纳的租赁保证金不予退还用以冲抵违约金赔偿给甲方外**,乙方**还应**按平均月租金3倍或等额保证金(两者取高)支付违约金;不足赔偿的**还应负责补足**」——我只摘了中间「3倍/等额取高」,把「①保证金没收冲抵」整层漏掉、「③不足补足」也丢了,三层只取一层。正确提取必须三层齐全:①保证金没收冲抵 + ②另付主违约金 + ③不足补足。这是「整款通读不摘单句」落到**句法层面**——「除…外」「另…」「还应…」「并…」都是叠加信号词,逐个信号词对应一层后果,一个都不能丢。
- **同口径修正必须扫全表对齐(规则一致性)**:同一套格式合同(如世茂青少+高中租赁,14.1/14.2逐字相同)改了一处条款表述,必须回各校区/各份原文核对是否同款,同款的一并改,否则同表内「青少改了高中没改」自相矛盾。改完用脚本全表扫旧表述残留(`bad=[旧串...]` 遍历所有单元格)确认0残留。
**⑤b 法律结论核证三铁律(2026-06-22 世茂高中物业 9.1/10.1 教训)**
教训:高中物业 K列第2条出现两个错误——①把「2个月 或 _/_元(两者取高)」里的选填留空 `/` 当成「填空额」备选项写进交付物论证("或填空额,取高;填空为空故按2个月计");②写「物业违约可能连带触发租赁解除」,因果方向与原文相反——10.1 实为「租赁终止则物业终止」的**单向**联动,9.1 前提是「乙方过错致出租方解除租赁」在先、物业方才追责,合同**无**「物业违约→租赁解除」链条;且该句与同格第5条「10.1 租赁终止则物业终止」自相矛盾未自检。三条对治:
1. **选填留空 `/` = 不适用,是跨条款通用判别,不只用于提成/分档**:任何「或___元 / 或__%(两者取高)」类条款,填空为 `/` / 空白 / 未填 → 该选项不存在,**直接写确定项的结论**(如「2个月平均月管理费」),**不在交付物里论证那个空选项**(「填空为空故按2个月计」这类推理过程不进交付物)。这是 ⑨(提成空白=不适用)、4e(分档留空=不适用)的**泛化**——「选填留空=不适用」适用于违约金、保证金、费率等一切选填条款,不限于提成/分档。栽点根因:已有规则只绑定在它首次出现的场景,未泛化到新条款。
2. **法律因果链必须核方向,标了条款号 ≠ 读懂条款**:写「A导致B」「A连带触发B」「A可能触发C」这类因果结论前,回原文确认**箭头方向**(是 A→B 还是 B→A),尤其「联动 / 连带 / 触发 / 导致」这类词。合同联动多为**单向**(「租赁终止则物业终止」≠「物业终止则租赁终止」)。标条款号是定位、不是免检牌——标了 (9.1) 仍须真读懂 9.1 的**前提与后果**分别是什么,不能凭「两者有联动」的模糊印象脑补反向因果。栽点根因:印象式推断代替条款核对。
3. **同一单元格内多条结论写完互相对撞一遍**:同一格里引同一条款(如 10.1)的多处表述,方向 / 口径必须一致。填表「整合质询」应含**单元格内一致性自查**——高中物业 K14 第2条与第5条都涉 10.1 却方向写反、自相矛盾而未自检,正因写时凭印象一气呵成、没回头与同格其他条对撞。
4. **引某条款作依据前,先确认该条「正文非空」——空标题条款不能当依据,回原文找真正承载该规则的条款(2026-06-22 悦拾光 23条空壳教训)**:合同里**有标题、正文却是空白**的条款是真实存在的坑(OCR 与原件都如此,非 OCR 丢字)。悦拾光实证:「第二十三条 租赁房屋的转租」**只有这行标题、正文整条空**(OCR 里直接从该标题跳到第二十四条续租)——我一度在 I列写「禁止转租(23/28.9)」,把空壳的23条当依据引了。真正承载「禁止转租」的是 **28.9**(乙方擅自转租=根本违约)。处置铁律:① 任何条款号写进交付物前,回 OCR 原文确认**该条款号下确有承载目标规则的正文**,不是只看到标题就引;② 发现标题在、正文空 → **绝不引这个空号**,全文 `grep` 该规则的关键词(如「转租」)定位到真正写着它的条款(可能在「根本违约」列举、违约责任等别处)再引;③ 判断「正文是否真空」要看相邻条款是否紧接——若「第二十三条」标题下一行就是「第二十四条」,即空壳。这是 rule 2「标了条款号≠读懂条款」的同族延伸:rule 2 防因果方向错,本条防「引了个根本没内容的条款号」——都是「条款号是定位、不是免检牌」。④ **🔴 同范本两份副本,同一条款可能「一份正文空、另一份有正文」——这是真实差异,不是 OCR 错,「镜像印象」是漏读元凶(2026-06-22 悦拾光二次教训,由独立法律校对 subagent 揪出)**:悦拾光一期 23条「租赁房屋的转租」确为空壳(标题直接跳 24条),但**扩租 23条有禁止性正文**「未经甲方书面同意,乙方不得将该房屋转租,亦不得将该房屋进行其他非法或违约处置」。我逐字通读时太想确认「两份是同模板镜像」,反而把扩租这行有正文的 23条整行跳过、一口咬定「两份都空、都用 28.9」——连带写出「两份逐条对应、条款高度一致」的夸大表述。后果:①扩租禁止转租其实有**双重依据**(23条直接禁止 + 28.9根本违约),漏了直接依据;②「高度一致」失实。**这与 4a 的双向用法同源**:4a 既要「同条款数值不一致→疑 OCR」、也要「同范本不假设逐字相同、识别真差异」;本条把它落到「空 vs 有正文」这种最隐蔽的差异上——**越是认定「同模板镜像」,越要逐字核每一条,镜像印象会让你跳过真实差异行**。处置:任何「两份高度一致 / 逐条对应 / 逐字相同」的整体判断,落笔前必须**对两份的每一条标题+正文做一次存在性核对**(尤其转租、优先权、特殊约定这类易被一方删/改的条款),有一处不同就不能写「高度一致」,要点明差异(如「扩租另含 X 条正文,一期仅标题」)。
**⑥ 有约定的事项不拿任意性/兜底性法定标准质疑(意思自治优先,2026-06-17 万达催告期教训)**
- 合同已明确约定的事项,**不得再拿"没有约定时才适用"的法定标准质疑其效力**。《民法典》合同编大量条款是"没有约定或约定不明时"才适用。
- 典型误用:约定了违约金/催告期/解除条件后,又写"是否符合法定'合理'标准存疑"——错。除非约定违反**强制性规定**或构成**显失公平/格式条款无效**等可推翻情形。
- 实例:万达第四条2款已约定"催告10日可解除",不能再套民法典722条"合理期限"质疑这10天够不够。对偏严苛但合法有效的约定,**只做商业风险提示**("代价较重,提示注意按时履约/协商更优条款"),不做法律效力质疑。
**④ 等级调整后的结构维护**:风险在🔴/🟡/🟢板块间移动后——(a) 受影响板块若清空(如🔴须核实区只剩的项都降级走了)→**移除空板块**;(b) 全列风险**编号重排 1–N 连续无跳号**;(c) openpyxl 局部改完跑保真核对(见 Pitfall 1b)。
**⑦ 审查已履行合同,剔除面向"履行前时点"的过时前瞻提示(2026-06-17 万达物业合同教训)**
- 这批合同**都是已实际履行多时的合同**(万达2024/9签、2026/6已运营近两年)。审查时剔除面向**已过去且不可逆时点**(装修前、进场前、签约时)的**前瞻性操作提示**——这类提示对早过了那个时点的合同毫无意义,是马后炮。
- 实例:物业合同低风险区"装修一次性费用(建议装修前纳入预算)""用电容量限制(建议核实现有容量是否充足)"两条——装修2024年底已完成、能正常运营近两年即证明用电够用,两条都删。
- **保留**面向**当前/未来持续**的提示:如"能耗费每年发生→保留对账凭证"(合同仍在履行、费用持续发生)、退出机制/续租/违约等面向未来履行与退出的风险。续签建议(补任意解除权/不可抗力/办学许可条款)也保留——面向"未来续约",不是过时前瞻。
- 判断标准一句话:问"这条提示指向的时点,是否已经过去且不可逆?"——是→删;指向当前或未来→留。
- **跨合同一致性检查**:删某类提示后,复查同表其他合同(主合同/其他校区)有无同类前瞻提示,统一处理。
**⑦b 履行中合同 + 属常规商业安排的条款 → 直接删除,不写"虽然有X但属常规"提示(Maggie 2026-06-18 世茂装修违约金确立)**
- 合同**已在履行**、某条款经核实**属正常商业安排、不构成实际风险**的,**直接不列、不提示**——不要为了"显得审过了"而写"装修违约金1倍属常规督促性、提示按期完成装修开业"之类的安抚性提示。
- 实例:世茂6.4装修延误违约金,核实为日租金**1倍**(≈1,100元/天,常规督促性违约金,合同已在履行)→ Maggie 原话"装修延误违约金不奇怪的情况下,不用提示,删除就好",整条🟡中风险删除,不降级保留、不写提示。
- 与⑦同理(⑦删过时前瞻提示,⑦b删常规无风险条款),都是"交付物只留真正要客户注意的风险,不堆无意义提示"。判断:这条款**当前是否真给乙方带来风险**?否(属常规商业安排)→ 删,不提示。
**⑧ 整体风险分析段(第三部分)须与已确立的定级尺度全程对齐(2026-06-17 万达第三部分重写教训)**
- 校区 sheet 末尾的「整体风险分析与建议」段是早期产出、措辞最容易残留**已被推翻的旧判断**。每次定级尺度有更新,**必须回头重写这一段**,不能只改前面的逐条风险列。
- 万达第三部分重写时清理掉的旧判断(全部违反本节①-⑥与「模版差异≠法律风险」铁律):✗"出租方为自然人→偏向甲方"(标签预设/确认偏误)✗"违约金仅2个月→保护不足"(均衡条款,且法律意见书明确2个月违约金不适用无故退租)✗"提前退出风险高"(协商解除可互不担责、继续履行风险极低)✗ 拿"与模版差距大"当风险论证。
- 重写后结构(站乙方立场、客观中性、不用预设标签):整体评价(已履行可正常运行)→主要法律关注点(标条款)→提前解约成本与路径(**直接引同校区法律意见书的权威数字,可溯源**)→操作建议(意见书五步法)→续签建议(面向未来)→低风险事项(核验出租权、模板套用)。
- **⚠️ 总结段精简尺度(2026-06-17 Maggie 定稿万达表确立)**:第三部分作为给客户看的「总结」,**只保留客户最需要注意的内容**——整体评价、主要法律关注点、提前解约成本、续签建议。**删掉**:①提前解除的具体操作步骤建议(五步法等程序性操作)②低风险事项提醒(核验出租权、模板套用等)。理由:总结要简明扼要、突出重点,操作细节和低风险事项放在前面逐条风险列里即可,不必在总结里重复,以免淹没真正要客户关注的重点。(注:上一条「重写后结构」是完整版结构;本条是 Maggie 进一步精简后的**最终交付尺度**,以本条为准——总结段不含操作建议段与低风险事项段。)
- **⚠️ 汇总表意见简明、删法条号(2026-06-17 Maggie 定稿万达表确立,与第65行呼应)**:汇总表(含第三部分总结)的风险意见**尽量简单明了**,**删掉民法典法条号**(如584/580条等不写进表格正文)。但两类编号**保留**:①合同条款号(第八条、第五条2款等——便于回原文核对,见第50-56行)②引自正式法律意见书的法条(第三部分若直接援引意见书结论,其法条作为依据可留)。一句话:汇总表删「外部法条引用」求简洁,留「合同内部条款定位」便核对。
**⑨ "保底或提成两者取高"——必须核提成比例是否填了数额,空白即不适用(2026-06-17 世茂青少租赁 Maggie 纠正)**
- 商业Mall租赁常见"月租金=每月保底租金 或 每月营业额×提成比例%,两者取高"的约定。**绝不能照搬"两者取高"的字面就写进表**——必须回原文核**提成比例栏是否真的填了数额**。
- 世茂青少/高中租赁实例:附件三租金段写"合计33280.59元 **或按当月总营业额(税前)之/%提成;两者取高**"——提成比例是 `/`(空白占位)、**没有数额**。提成租金无从计算,**实践中即不适用,实际只按保底租金计租**。
- 正确写法(Maggie 2026-06-17 再纠正:判断过程不进交付物):金额栏标题写"营业期**保底租金**"、只列保底数额即可——结论栏只放结论,**判断过程不写进汇总表**。既不写"保底与提成取高"(误导以为有提成机制),也不写"提成比例空白→无法计算→不适用"这类推理过程(推导留在审查阶段,不进交付物)。
- 推广检查:凡遇"两者取高/就高"类租金约定,逐份核提成比例(%处)填没填数额;空白/`/`/未填→按不适用处理。这是"事实核实、不照搬字面"原则在金额栏的具体落地。
### ⚠️ 铁律:每份合同逐条审查,不挑不跳,禁标"简化审查"(Maggie 2026-06-17 万达物业合同确立)
**每一份合同**——无论主合同/附属合同、标准模板/格式文本——都必须**按审查标准逐条审查,从①主体信息 → ②各实体条款 → ③违约/争议/生效 → ④签名落款,全过一遍,不挑重点、不跳条款。**
- **严禁标注"简化审查""重点审查"之类减档说明**。Maggie 原话:"任何一份合同都应该按照审查标准逐条审查,从主体信息到签名落款,不能跳着或者挑着来。" 这种标注既是给客户的负面信号(像在说"没认真审、打了折扣"),也是给偷工减料找说法。审查深度的主次是内部判断,**不写进交付物**。
- **合同间的主次(如物业依附租赁)只影响风险权重表述,不影响审查覆盖面**——附属合同照样逐条审。
- **逐条审查反而捞出挑审漏掉的真问题(万达物业合同实证)**:从"挑审4条"改为"逐条审"后新发现——①协议性质/主体框架错位(套用"前期物业服务协议"住宅业主模板,乙方实为承租人非业主)②第十七条生效条款"业主办理入住手续签字生效"与承租场景不符③第八条广告牌设置与租赁合同"乙方可免费设广告牌"衔接冲突。挑审时全漏了。
- **交付物结构(逐条审查后)**:风险按🔴🟡🟢分级列出(标条款)。物业等附属合同标题与主合同对齐用"站乙方立场独立审查",不用"简化审查"。
- ⚠️ **不加"✅已审查无异常"展示段(Maggie 2026-06-18 反转此前万达打样规则)**:此前为展示"审查覆盖全部条款",会在 K列末尾加一段"✅已审查无异常"罗列审过且无问题的条款。**Maggie 明确不要这个提示——删除。** 逐条审查是**内部要求**(覆盖面靠内部保证),但交付物**只列真正的风险点,不写"已审查无异常"这类覆盖面展示段**。理由同"判断过程不进交付物""总结段只留客户最需注意的内容":客户要看的是风险,不是"我审了哪些没问题"的清单。**已有的旧表若带此段,更新时一并删除。**
**⚠️ 事实问题不靠文本提取下结论(连带自我教训)**:签字/盖章是否空白、印章有无属**事实问题**,PDF 手写签名/印章在 OCR/文本提取里**看不到**。不能凭 `.md` 提取版断言"甲方签字处空白"——必须**看原件 PNG 图片或问 Maggie 确认**。同"自已/自己"教训:提取层看到的"空白/错字"可能只是提取丢失,不是原件真相。涉及签署状态的风险,先核图再定级。
### ⚠️ 交付物呈现规范(Maggie 2026-06-18 世茂确立)
两条关于「交付物里放什么、怎么标」的硬规则,与「判断过程不进交付物」「总结段只留客户最需注意的内容」同源——**交付物只为客户服务,不留我的工作痕迹,待客户做的事用颜色凸显。**
**① 核实痕迹直接删除,客户不需要知道我怎么核的**
- "经PDF原件核实""经原件核实""经PDF原件+数学交叉核实""〔关键数字均经…核实〕"等**我自己的核验过程标注**,一律**不写进交付物**——客户只需要看结论,不需要看我怎么验证的。
- 实例:世茂物业 K列"逾期缴费滞纳金:每日0.5‰(9.2,**经PDF原件核实**)"→删成"(9.2)";高中物业末尾整段"〔关键数字均经PDF原件+数学交叉核实:①…②…③…〕"注脚→整段删除。
- 核验是我的**内部责任**,留痕放工作记录/主审清单即可,不进客户看的表。这与「判断过程不进交付物」「不加✅已审查无异常展示段」是同一条线:交付物 = 客户视角的结论,不是我的工作日志。
**② 需客户核实/确认的内容怎么凸显——⚠️ openpyxl 富文本标红会让 Excel 报错,但 WPS 另存可救(2026-06-18 世茂,最终标红成功保留)**
- 需求:风险点中**需要客户去核实/确认某个事实**的内容(如"两份合同衔接需核实""建议向客户核实""建议签约时补明或确认2028年度计租标准")希望**凸显**,方便客户一眼识别待办。
- **边界(不管用什么方式凸显都适用)**:①只标"需客户核实/确认事实"的——提示类(提示按时缴费、提示知悉、提示确认衔接)不凸显;②我已核实的不凸显(且核实痕迹要删,见①);③范围 Maggie 要的是**整条**(从编号到句末),不是只标半句。
- 🔴🔴 **铁律:openpyxl 的 `CellRichText` 局部着色会让 Microsoft Excel 报"发现部分内容有问题/需要修复"——但 WPS 打开另存可救回(2026-06-18 世茂,最终标红成功)。** 完整经过:
- openpyxl 把带局部色的单元格写成 `inlineStr`+`<is><r><rPr>…`。试过 ①调 `<rPr>` 子元素顺序(rFont→sz→color)②补 `charset=134`/`family=2` ③去掉富文本只留纯文本——**Excel 仍报错**。说明根子不只是富文本,openpyxl 生成的这个表底层某处就不合 Excel 严格 OOXML 校验。
- **WPS、OnlyOffice x2t、openpyxl readback、LibreOffice 全都不报错或无法复现**——唯独 Microsoft Excel 报。所以"WPS 打开正常""openpyxl 读回正常"**完全不能当交付合格证据**。Maggie 的客户/她本人用 Excel,Excel 报错就是不合格。
- **本地没有能复现 Excel 严格校验的工具**(LibreOffice 在本环境连干净文件都报 "source file could not be loaded",x2t 太宽松)。→ **改完无法自验 Excel 行为时,必须如实跟用户说"我这边验不了 Excel,你帮我打开看下",不要断言"修好了"。** 本 session 连续 4+ 次"还是不行"、把用户当测试员,是明确的反面教训,用户失去耐心。
- ✅ **要凸显就用不碰富文本的方式(Excel 绝不报错)**:
1. **纯文本标记(首选)**:在需核实条前加醒目前缀,如 `【需客户核实】…` / `❗待核实:…`,整条普通黑字、零特殊格式。最稳,Excel/WPS 都不报错。
2. **整格统一格式**(`cell.font=Font(...)`、整格背景 `PatternFill`):安全,但会把整格所有条目一起染——仅当"整格就这一条"时可用。
3. **必须某条带色/加粗 → WPS 另存法(2026-06-18 世茂实证:此法成功,Excel 不再报错且红色保留)**:openpyxl 写好带 `CellRichText` 局部标红的 xlsx 后,让用户/我把文件**用 WPS 打开 → 另存为 xlsx**。WPS 会把 openpyxl 那套不合 Excel 严格校验的 XML **重写成规范格式**,结果:①Excel 打开**不再报"需要修复"** ②**红色局部着色保留**。Maggie 亲自验证 Excel 正常打开+红色在。这是\"既要某条标红、又要 Excel 兼容\"目前**唯一跑通**的路径。\n - 代价:需\"WPS 打开另存\"这一步人工(或我若有 WPS CLI 可自动化;本环境暂靠用户手动)。给用户的话术:\"文件我已标红,但 openpyxl 生成的格式 Excel 会报错——你用 WPS 打开后另存一次 xlsx,Excel 就正常了、红色也在。\"\n - ⚠️ 前提:被另存的那一版**必须是带富文本红色的版本**(别拿我中途\"去富文本\"的版本去另存,那样没红色)。\n- **结论**:要\"单元格内某条标红/加粗\",**先 openpyxl 写富文本红色 → 再 WPS 另存规范化**(已验证可行);嫌麻烦或纯自动化场景,退而用**纯文本前缀**(`【需客户核实】`)。默认 Maggie 用 Excel、WPS 只是工具,但 WPS 另存这一步是打通富文本标红的关键。完整排查全过程见 `references/openpyxl-excel-richtext-pitfall.md`。
---
## ⚠️ 三角色分工(四眼分离,Maggie 2026-06-16 确立)
**核心原理**:做的人查不出自己的错(确认偏误)。有效校对必须是**独立角色拿合同原文重新核**,不是看着成品点头。技术上用 `delegate_task` 开上下文隔离的 subagent,校对员看不到承办思路,只看原文和成品 → 真四眼。
| 角色 | 谁来当 | 职责 |
|------|--------|------|
| **① 承办** | 小Maggie主审 + 独立 subagent | **法律审查(动作A)**:小Maggie本人主审,八维全面审查→法律风险清单(不外包,避免超时);**提取分析(动作B)**:独立 subagent,OCR→要素提取→模版比对→退租敞口→填表+提取依据清单 |
| **② 校对** | 独立 subagent ×2 | **法律校对**(维度1-4)‖ **格式校对**(维度5-6) |
| **③ 终审** | 小Maggie 本人 | 汇总校对结果、确认问题闭环、最后把关 |
**防线顺序**:承办 → 校对 → 小Maggie终审 → 交付 → **Maggie最终核对**。到 Maggie 手上前已过三道。
### 承办拆分规则(2026-06-16 小样验证后定为"方案3:小Maggie主审")
- **OCR 是分叉前的共享前置步骤(2026-06-16 厘清)**:合同 PDF → `.md` 文本只做一次,生成"只读底料"。**小Maggie(法律审查)和 subagent(提取分析)各读同一份 .md 原文,并行不冲突**(都是只读,像两个律师各拿一份复印件)。法律审查**必须亲自读合同原文**——不读原文无法做八维审查。旧流程"subagent 读文件出报告、小Maggie 只汇总"已废止;现在法律判断的源头(读原文)牢牢在小Maggie手里。
- **法律审查(动作A)由小Maggie本人主审,默认不外包 subagent**。两条理由:①法律审查最吃专业判断,小Maggie对合同全局上下文最清楚,质量最高;②重型八维审查单个 subagent 极易撞 10 分钟 ACP 超时被杀,半截活白干。
- **万达小样实证(2026-06-16)**:法律审查 subagent 跑满 600s 超时被杀;小Maggie补做的版本是本次质量最高的一份且无超时,并独有发现签署页瑕疵(签署日期空白等模版比对看不到的项)。⚠️ 注:该"签署日期空白"当时被评"合同效力存疑"高风险,2026-06-17 经 Maggie 纠正应降为🟢低风险(合同已实际履行、租期明确,形式瑕疵不影响效力)——发现瑕疵是对的,定级要克制,见「风险定级纪律」节。
- **提取分析(动作B模版比对)+ OCR + 填表 外包独立 subagent**,机械活可并行、不超时。
- **四眼分离不破**:小Maggie主审动作A → 独立法律校对员查;动作B由 subagent 做 → 独立校对员查。做者与校者始终分离(校对查的就是小Maggie主审的成果——正是万达小样里校对揪出"法条未标注"的场景)。
- **弹性**:若某段时间小Maggie任务过载、确实抽不出手,可临时降级为"法律审查 subagent",但必须二选一防超时——①放宽 ACP 超时(`--timeout`)②按维度把八维拆成 2-3 个轻 subagent 再拼。**默认主审,过载才降级。**
- 提取分析 subagent context 必须含:合同OCR原文路径、标准模版核心条款清单、"中性输出合规差距、不作风险判断、开头结尾各重申一次声明"。
### 填表分工(2026-06-16 确立:谁产出谁填,绝不让 subagent 填它没做过的内容)
**核心矛盾**:汇总表的内容来自**两个源头**——动作B(提取分析,subagent 产)+ 动作A(法律审查,**小Maggie主审产**)。若简单"填表外包 subagent",会让 subagent 去填一栏它根本没做、是小Maggie做的"法律风险",必然瞎填或失真。因此填表必须拆成两步两人:
| 步骤 | 谁干 | 填什么 |
|------|------|--------|
| **① 填表初稿** | 提取分析 subagent | 只填**它自己产出的**栏位:当事人/面积/金额/期限/核心内容/**模版差异** + 套用 12 列格式、配色、行高 |
| **② 合并法律风险** | **小Maggie(终审)本人** | 把主审的**法律风险**结论亲手并入"风险点/备注"栏;与模版差异**分列**、加方法论标注(法律风险 ≠ 模版差距) |
| **③ 格式校对** | 格式校对 subagent | 查格式统一、总览-分表一致、加总对不对 |
**铁律:谁产出谁负责那一栏。** 机械的格式骨架 subagent 搭,小Maggie做的法律风险小Maggie自己填,**绝不让 subagent 去填它没做过的法律判断内容**。这与"方案3 小Maggie主审法律审查"一脉相承——法律判断从审查到落表全程在小Maggie手里,不经 subagent 的手,杜绝失真。
> 🔴 **模版差异(L列)虽由 subagent 产,但必须满足两个强制条件(Maggie 2026-06-23 补充,对治"外包给 subagent 就不回原件"的隐患)**:
> 1. **subagent 的 context 必须含 07 原件路径 + "逐条打开原件比对"强制指令**:不是给一份归纳好的 checklist 让它套,而是给 `07- 房屋租赁合同.docx` 原件(或其全文),明确要求"逐条比对原件,禁止用'商业格式''标准格式'等抽象概念当参照系"。checklist 仅作定位导航,真值以原件为准。
> 2. **终审(小Maggie)对 L 列结论像法律风险一样回原件复核,不全信 subagent**:subagent 的模版比对是初稿,终审必须亲自回 07 原件抽查关键条款(缺失项、实质偏离项)是否找全、陈述是否中性、有没有把模版标配当"本合同优势"。这与"动作A 法律审查不外包源头"同源——模版比对的**校验源头**也要在小Maggie手里。
> - 教训来源:人民中路 L 列当初没回 07 原件、拿"星展商业格式"概念写差异,漏了第八条抵押"不得→可"、第十二条办学许可证免责款缺失两处实质偏离(详见「模版差异≠法律风险」铁律下的 07 原件铁律)。
> ⚠️ **退租敞口测算含法律判断,必须小Maggie把关定稿(2026-06-16 Maggie 指示)**:退租敞口(确定责任/不确定责任/可收回/净成本)本质是法律判断,不是机械提取。subagent 可出初稿框架,但**最终结论由小Maggie定稿**,与"法律风险栏"同等对待——不外包拍板。
> ⚠️ **现行 12 列汇总表中,法律风险与模版差异物理分列:K列=「风险点/备注」装法律风险(动作A产出),L列=「与标准模版差异」装模版差异(动作B产出,回 07 原件比对)。** 两列各自标注性质,绝不混列——这是「模版差异 ≠ 法律风险」铁律在表结构上的落地,防止读者把合规差距误读为法律风险。(此前「11列、K列同时承载两者」的旧表述已于 2026-06-23 作废,见 Step3 12列标准结构。)
### 六维校对清单(Maggie 列定,逐项打勾)
| # | 校对什么 | 谁校 | 自动化 |
|---|----------|------|:---:|
| 1 | 提取文字准确(面积/金额/期限/当事人回OCR原文核) | 法律校对 | 🟡半自动 |
| 2 | 总结要点无错漏(核心内容栏有无漏关键条款) | 法律校对 | 👤 |
| 3 | 法律风险完善准确(是否当新合同全面审、有无错漏、定性准否、法条现行有效) | 法律校对 | 👤 |
| 4 | 模版核对准确(差异找全、陈述中性、**没把差距当风险**) | 法律校对 | 👤 |
| 5 | 汇总要点齐备(字段齐全、总览-分表一致、加总对得上) | 格式校对 | ✅自动 |
| 6 | 格式统一(列结构、配色、行高、板块划分合模板) | 格式校对 | ✅自动 |
- 第5、6点 + 第1点数字部分写成**校验脚本**自动跑(字段空缺、总览-分表一致性、金额加总、列结构比对)——机器不漏不手抖
- 第2、3、4点是法律实质判断,法律校对 subagent 做,小Maggie终审复核
- **错别字、格式错误、语义逻辑错误是必校项(2026-06-16 Maggie 补充)**:贯穿全部六维,每份交付都要过——错别字(法律/格式校对都查)、格式错误(格式校对)、语义逻辑错误/指代不清(法律校对)。这是基本质量底线,不因走了 workflow 就免检。
### 校对发现问题后的处理机制(2026-06-16 确立,三角色闭环的"最后一公里")
校对员产出《校对意见清单》后,按以下机制流转——做、校、修、核、定一条龙,缺一环不算闭环。
**① 问题分级**
| 类型 | 例子 | 处理 |
|------|------|------|
| 🔴 **阻断项** | 数字提取错、法律风险定性错、**红线**(把模版差距当法律风险)、法条臆造/未标注 | **改完 + 复核通过前,不交付 Maggie** |
| 🟡 **建议项** | 轻微遗漏、措辞、可补充的小点 | 终审判断采纳与否,可当场补或仅记录 |
**② 谁来修:校对不下场,按问题大小分流**
- **铁律:校对员只挑错、不动手改**——一旦它下场改,就又变回"自己查自己",四眼分离失效
- 小问题(标注、个别数字、补一条风险)→ **终审(小Maggie)局部修**,最快
- 系统性问题(整段审查跑偏、大面积提取错、立场错)→ **退回对应承办环节返工**,重做那一环
- 优先级:**局部修 > 退回返工 > 重做**(与合同审查同一套)
**③ 改完必须闭环复核,不能"改了就算"**
- 修正后拿校对意见**逐条回核**,确认真解决了
- 能脚本验证的(数字、格式、标注一致性)就**脚本验证**
- 没复核通过 → 不算闭环、不交付
**④ 反复出现的问题 → 修根因,不只修个案**
- 某类问题被校对反复挑出(如法律审查老漏标法条)→ **打回本手册/承办指令模板**,从源头堵住
- 修一次性的错 vs 堵住错的来源,后者才治本
**⑤ 终审最终裁判权**
- 校对意见**非绝对权威**。若某条意见本身站不住,终审(小Maggie)有最终取舍权,但**须说明不采纳的理由**
- 对最终质量负责的是总负责人,不是机械执行校对清单(如同律所"校稿提意见、定稿人拍板")
**实战案例(万达小样 2026-06-16,全流程走通)**:校对员揪出"法律审查4个法条编号(722/585/725/496)未按报告自述方法标注〔待核实〕"——定为 🔴 阻断项 → 小Maggie(终审)局部修正统一加注 → execute_code 脚本复核5个编号全部到位 → 闭环 → 交付。校对同时提的"用电增容14千瓦未提及"为 🟡 建议项,不影响结论,记录即可。
### Maggie 审核成果后的修改流转(建议默认,2026-06-17)
交付后 Maggie 亲自审核成果 Excel,往往会调整内容。修改怎么流转,**按改动类型分流**——这是「Maggie最终核对」这道防线的操作细则。Maggie 问「我直接在表格里改,还是把意见给你来改」时,主动按下表建议,不要让她从零纠结:
| 改动类型 | 谁来改 | 为什么 |
|---------|--------|--------|
| **长文本**(法律风险表述、模版差异逐条、退租建议、整体风险分析) | **Maggie 给意见 → 小Maggie 改** | ①这些单元格是高度结构化长文本(🔴🟡分级、12项逐条、〔待核实〕标记、分段),在 Excel 单元格里手改长文本,换行/缩进/emoji 标记极易乱;②**跨 sheet 联动**——校区 sheet 改了,总览对应行的「主要风险点」要同步,手改易漏;③**规则一致性**——一套规则覆盖 N 个校区,改一处口径,小Maggie 能把同类表述在其他校区一并对齐,手改只能改一处 |
| **短数据字段**(金额、面积、日期、主体名称) | **Maggie 直接在表格改更快** | 纯数据订正,不涉格式/联动,绕小Maggie 反而慢 |
- Maggie 给意见的形式自由:表格里批注/标黄发回,或文字直接说「X校区某列某条改成……」。
- **兜底**:若 Maggie 倾向全部自己在表格改,小Maggie 至少要最后帮她**校对一遍格式 + 跨 sheet 一致性**(总览-分表同步、加总、配色、行高——见 Pitfall 1 行高重算)。
- 这道流转走完才真正闭环——接续三角色防线「承办→校对→终审→交付→Maggie最终核对」。
### Maggie 审核 = 规则提炼机会(边改边沟通,2026-06-17 确立)
Maggie 的目标是把她每一处审核修改**内化成 skill/规则**,让下次成果一次到位、不用她反复改。达成方式是固定协议,不是临时沟通:
- **节奏:边改边沟通,不是攒完一起说**(Maggie 2026-06-17 拍板)。理由:要内化的是「**为什么这么改(why)**」而非「改了什么(what)」——理由才是规则,改动只是表象。改完一大批再回头猜理由必猜偏,Maggie 过几天也未必记得当时考量。边改边说,理由最新鲜最准。
- 反面教训:培训合同「自已→自己」若只看改动会误提炼成"错别字不用改",是 Maggie 当场说"己字没错、是读取问题"才避免写错规则。
- **不打断 Maggie 节奏**:她改时带一句简短理由即可(哪怕几个字),不必等小Maggie回复就继续审。小Maggie 后台记录、提炼候选规则,**不刷屏**,攒到一个段落或她审完一个校区再汇总成规则清单发她确认/纠偏。
- **落地机制**:在工作目录建一份「<项目>审核·规则提炼追踪.md」,每处记一条 `修改点 → Maggie的理由 → 提炼的规则`,并标 `适用范围`(打样阶段写"先在X校区打样,暂不推广")+ `状态`(待确认/已确认)。Maggie 确认后才标"已确认"并落实,确认前不铺开到其他校区。
- **打样优先**:新规则先在一个校区(如万达)打样,给 Maggie 看落实效果(重写后的单元格 + 变化对照),她认可标注方式后才推广全部校区。Maggie 说"打样、不着急其他校区"时严格遵守,不自行铺开。
- 提炼出的规则最终固化进本 skill(或对应审核 skill)的正文,不只留在追踪文件里——追踪文件是过程载体,skill 正文才是长期记忆。
---
### 可回溯配套
承办填表时**同产一份「提取依据清单」**——每个关键字段标来源(第几页第几条)。校对员快速溯源,Maggie 核对时点开即知数字出处。
### 弹性
- **日常增量**(动1-2份):承办+校对+终审三角色
- **批量盘点**(5+份变动):校对拆**法律校对‖格式校对两个并行 subagent**,专业更纯
### ⚠️ 校对 subagent 防超时(2026-06-17 世茂重做实证)
重型**法律校对**(逐条核对多份合同回原文)极易撞 600s ACP 超时被杀(世茂4份合同的法律校对一次性跑→超时;物业组单独重试仍超时)。三条应对:
1. **按合同类型/数量拆小再并行**:4份合同别塞一个法律校对 subagent,拆成"租赁组(2份)‖物业组(2份)"两个并行 slot,每个工作量减半,更易在超时前完成。世茂拆分后租赁组顺利 completed 并揪出2个真问题(甲方违约13.4/13.5遗漏、装修延误违约金10倍vs1倍)。
2. **格式校对几乎不超时**(纯脚本核对),优先保它跑通。
3. **某组反复超时 → 终审(小Maggie)亲自接管核该组**:物业合同我已主审通读过全文,物业组校对超时后由我亲核物业K列即可,符合"终审最终裁判权+局部修"。**不无限重试消耗时间**。
- 检查铁律:`delegate_task` 返回后逐个查 `status`,`timeout`/`error` 的不能当没发生——要么拆小重试,要么终审接管,绝不跳过四眼校对环节。
### ⚠️ OCR 缺失数字不进表,标〔待核PDF〕不编造(2026-06-17 世茂高中物业实证)
提取分析 subagent 报的数字若 **OCR 原文无佐证**(如高中物业第九条违约金率正文 OCR 丢失、subagent 记"1%"实为参照青少物业推测),**终审必须回原文核**:搜不到原文支撑的数字,改写成"〔待核PDF〕提取报告记为X但无OCR原文佐证",**绝不让无依据数字当结论进交付表**。这是"OCR交付不把校验责任推给用户、不凭提取推测定稿"(Doro/Maggie 一贯铁律)在批量场景的落地。终审核对每个关键数字(违约金率、金额、面积、日期)时,区分"提取已核原文" vs "提取推测待核",后者一律标待核。
---
## ⚠️ 增量维护(汇总表是持续台账,不是一次性交付)
汇总表需随**新增合同 / 合同到期 / 提前解除**持续更新,每次变动走三角色分工,保证隔多久都输出同一套标准。
- 🟢 **新增**:拉最新版→承办(提取+法律审查)→按板块插行+同步总览→校对→终审
- 🟡 **到期**:状态改"已到期",历史行保留不删(台账可追溯),一般走格式校对
- 🔴 **提前解除**:状态改"已解除",退租敞口→实际结算结果,法律校对核结算
→ 完整步骤见 `references/incremental-maintenance-sop.md`。**所有变动前置铁律:绝不基于旧本地副本改,先从 Nextcloud 拉最新版 xlsx。**
---
## 处理流程
### 整体策略:两阶段法(推荐)
当校区数量≥5时,采用两阶段法比逐个校区做效率高得多:
**阶段一:批量生成分析报告(MD文件)**
1. 按复杂度分批,每批最多3个校区并行(delegate_task限制)
- 简单批(1-2份合同/校区):如仅物业合同或仅租赁合同的校区
- 中等批(2-4份合同/校区):如租赁+物业的校区
- 复杂批(5+份合同/校区):如有多份补充协议/扩租的校区,每个占1个slot
2. 每个subagent读取该校区的MD合同文件,输出一份分析报告到 `<校区名>/XX校区合同分析报告.md`
3. 所有批次完成后,确认17/17(或N/N)覆盖率
**阶段二:从分析报告提取数据→生成汇总Excel**
1. 遍历所有分析报告,提取关键字段
2. 构建campuses_data JSON
3. 一次性生成包含总览sheet的Excel
4. 上传Nextcloud + 发给用户确认
### 单校区处理流程 —— 统一编号 Step 0→7(Maggie 2026-06-23 定,防误读)
> 🔢 **权威编号(唯一口径,全 skill / 闸门脚本 / 对外沟通都用这套,不得另起别名)**:
> Step 0 盘点归类 → Step 1 取文件+OCR → Step 2 承办(法律审查‖提取分析) → Step 3 写12列Excel → Step 4 三角色校对 → Step 5 小Maggie终审 → Step 6 交付自查+存档 →〔全部校区定稿后〕Step 7 整合总览sheet。
> **Step 0→6 是单校区闭环**(每校区独立从头走一遍);**Step 7 是全局收尾**(所有校区定稿后才做一次,不属于单校区流程)。
#### Step 0: 文件盘点与归类(首次校区必做)
第一次接触某校区,**先盘点文件夹有哪些文件、如何排列,逐一核实确认**,再按"租赁/物业 → 场所/位置 → 签约时间"归类排序。详见 `references/file-inventory-classification.md`。归类层级直接映射校区 sheet 的板块划分。后续新签/变更/解除一律按此规则归位。
#### Step 1: 从Nextcloud取文件 + OCR
```python
# 1. docker cp 从 Nextcloud 容器复制 PDF 到本地
# 2. OCR 提取文本(参考 deepseek-ocr skill)
# - 先转图片: pymupdf → 200dpi PNG
# - 逐页 OCR: deepseek_ocr.ocr_file()
# - 合并保存为 .md 文件(页间用 ---PAGE--- 分隔)
# - 纯扫描件文字层=0 必须 OCR;大文件后台跑(见 Pitfall 4)
```
#### Step 2: 承办(四眼分离前半,两个动作并行)
本步是承办环节,**两个动作并行产出**(见「三角色分工」节):
- **动作A · 法律审查 = 小Maggie 本人主审**:亲自 `read_file` 逐字通读本校区合同 OCR 全文(含附件),按八维框架当**全新合同**全面审 → 法律风险清单(不外包 subagent,防超时+保质量)。详见 `references/independent-legal-review-framework.md`。
- **动作B · 提取分析 = 独立 subagent**:OCR 要素提取 + 模版比对 + 退租敞口测算 + 填表初稿。
- 🔴 **模版比对必须回 `07- 房屋租赁合同.docx` 原件逐条核**(subagent context 必带原件路径 + "逐条比对原件、禁用抽象格式概念"指令;终审回原件复核,见「填表分工」节)。
- 简单校区(1-2份合同)单个 subagent 可处理多个校区(最多4个);复杂校区(5+份)单 subagent 只处理1个。
- Subagent context 必含:所有合同MD文件路径、07原件路径、输出路径、报告格式模板、"站南通新东方(乙方/承租方)立场分析,输出中文"。
#### Step 3: 写入Excel汇总表(12列)
每个校区一个独立 sheet。结构如下:
##### Sheet结构
- **Row 1**: 标题(合并A:L),如"万达校区 — 租赁合同梳理"
- **Row 2**: 基本信息(合并A:L),物业地址、产权人、物业方(长信息行用 \n 分行防横向截断,见 Pitfall 17)
- **Row 3**: 分类标题(如"一、租赁合同"),蓝色底D6E4F0
- **Row 4**: 列标题(绿色底E2EFDA)
- **Row 5+**: 数据行
- 分类之间插入标题行
- 最后一个分类: 校区整体风险分析与建议(合并A:L)—— **必含板块,验收必查**
##### 12列标准结构(校区详情sheet,已确认模版 ✅ Maggie 2026-06-23 裁定)
| 列 | 内容 | 宽度 |
|---|---|---|
| A | 序号 | 5 |
| B | 文件名称 | 24 |
| C | 合同类型 | 14 |
| D | 合同当事人 | 26 |
| E | 租赁标的/服务范围 | 22 |
| F | 面积(㎡) | 10 |
| G | 合同期限 | 20 |
| H | 金额/费用 | 20 |
| I | 核心内容 | 40 |
| J | 当前状态 | 10 |
| K | 风险点/备注(**法律风险**,动作A产出) | 40 |
| L | 与标准模版差异(**模版差异**,动作B产出) | 40 |
> 🔴 **L 列独立(Maggie 2026-06-23 裁定)**:模版差异 = 独立的 L 列,**绝不并入 K 列**。这与「模版差异 ≠ 法律风险」铁律严丝合缝——K 列装法律风险(参照法律+司法实践)、L 列装模版差异(参照 07 原件),两件不同性质的事物理分列,杜绝下游把合规差距误读为法律风险。
> ⚠️ **此前一度出现「11 列、模版差异并入 K 列」的旧表述,已于 2026-06-23 作废**——全 skill 以 12 列含独立 L 列为唯一口径(与 `column-structure.md`、`independent-legal-review-framework.md` 第103行「分列」、增量维护 SOP「动作B独立产出」一致)。
> L 列内容必须回 `07- 房屋租赁合同.docx` 原件逐条比对(见「模版差异≠法律风险」铁律下的 07 原件铁律),物业/补充协议标注"无对应标准模版"。
> 文件名称必须用实际PDF文件名(如"北翼玖玖-房租合同.pdf"),不能自起名称。
> 按文件夹结构分板块——房租/扩租/物业等,跟客户实际的文件夹对应,不能自行重新归类。
> 📌 **H列三件套(每个金额/费用项标准动作)**:① 标条款号(数字真实出处,不标"详见附件X"指引条)② 换算"≈X个月月租"③ 推算付款截止日(推不出→总结+红字提示)。
> 📌 **需客户核实内容整条标红**(富文本红是最后一步→WPS另存/sharedStrings XML层;中间任何 load_workbook→save 会把红打回纯文本)。
##### 风险分析区(最后一个section)内容结构
1. 【整体风险分析】— 校区级别的风险概述
2. 【合同变更与提前解除综合分析】— 提前解约成本、部分退租、免责通道
3. 【文件汇总说明】— 文件清单与表格条目的对照关系
4. 【建议】— 针对性建议
#### Step 4: 三角色校对(四眼分离后半)
承办成果交独立校对,**做者与校者分离**(见「三角色分工」「六维校对清单」节):
- **法律校对 subagent**(六维1-4):提取文字准、要点无漏、法律风险准、**模版核对准(差异找全、中性、没把差距当风险)**。
- **格式校对 subagent**(六维5-6):字段齐备、总览-分表一致、加总对得上、列结构/配色/行高合模板。
- **校对只挑错不下场改**;产出《校对意见清单》→ 按 🔴 阻断项/🟡 建议项分级流转 → 改完闭环复核(见「校对发现问题后的处理机制」节)。
- ⚠️ 重型法律校对易撞 600s 超时:按租赁组‖物业组拆小并行;某组反复超时→终审亲自接管核该组(见「校对 subagent 防超时」节)。
#### Step 5: 小Maggie终审
- 合并主审的**法律风险**结论亲手并入 K 列(动作A产出,不经 subagent)。
- **回 07 原件复核 L 列模版差异**(像法律风险一样把关,不全信 subagent)。
- 汇总校对结果、确认每个问题闭环、最终裁判权(校对意见非绝对权威,不采纳须说明理由)。
#### Step 6: 交付前自查 + 存档归位
- **交付前自查(铁律,不是跑完代码就交)**:x2t 渲染 PDF → pdftotext 拍平 grep 验各板块文字完整 → vision 看渲染图验视觉(截断/错位/红色,**图先压<400KB防超时**)。详见 `references/onlyoffice-xlsx-render-and-rowheight.md`、`ocr-rate-symbol-verification.md`。
- **存档归位(Maggie 2026-06-23 纪律)**:汇总表存到**本校区自己的文件夹** `房租物业合同/<校区名>/`,命名 `<校区名/项目名>-梳理-MJ-YYYYMMDD.xlsx`,不放公共目录、不只留本地 /tmp。
- 上传 + scan + 清缓存:
```bash
docker cp <file> <container>:<nc_path>
docker exec <container> chown www-data:www-data <nc_path>
docker exec -u www-data <container> php occ files:scan admin --path=<path>
docker exec <onlyoffice> bash -c 'find /var/lib/onlyoffice/.../cache/files/ -mindepth 1 -delete'
docker restart <onlyoffice>
```
- 交付 Maggie 核对(用 MEDIA: 发),等她最终核对(见「Maggie 审核成果后的修改流转」节)。
#### Step 7: 整合总览sheet(⚠️ 全部校区定稿后才做一次,非单校区步骤)
**现行流程(Maggie 2026-06-22 授权调整,已取代旧的「每做完一个校区即时回填大总览」)**:
- **每个校区先单独做完它自己的汇总表**(Step 0→6),逐个交付给 Maggie 核对、定稿。
- **所有校区都做完、定稿后,最后再统一整合成总览 sheet**——不再每做一个校区就即时回填大总览。
- 总览 sheet 每行一个校区:承租主体、出租方、物业方、面积、期限、租金、物业费、押金、主要风险点、状态→「已梳理」。整合时从各校区已定稿的单表抽取,保证总览与分表一致。
- **理由**:单校区逐个打样定稿(格式/定级口径先在单表上对齐 Maggie 要求)再整合,避免在未定稿的口径上铺开大总览、回头大面积返工。与「打样优先、未定稿不铺开」一脉相承。
- ⚠️ 这是 Maggie **明确授权的 workflow 调整**,已固化于此——按本条执行;其余流程不得擅自改动(见开头「元规则」)。
- 存放:总览表存项目根目录(`履约期内非集采合同-综办/房租物业合同/` 或 Maggie 指定处),与各校区单表(存各自校区文件夹)分工明确。
## 分析报告模板(每校区MD文件)
每个校区的分析报告遵循以下标准结构:
```markdown
# XX校区合同全面分析报告
> **分析立场**:南通新东方(乙方/承租方)
> **合同数量**:X份(描述构成)
> **物业地址**:XXXX
## 一、合同概览
表格列出所有合同:甲方、乙方、位置、面积、期限、签约日期等。
如有多份合同,用总表一目了然。
## 二、租赁合同详情
表格:年租金(含分年列示)、递增规则、免租期、付款方式、押金/保证金、
逾期利率、拖欠解约门槛、提前解约赔偿等。
有补充协议/变更的,按时间线列出变更历史。
## 三、物业合同详情(如有)
表格:物业费标准、付款周期、滞纳金、电费单价、与租赁合同联动关系等。
## 四、终止框架分析
- 确定vs不确定期限
- 提前解约成本估算(确定成本+不确定成本-可收回金额)
- 恢复原状义务
- 免租期追回条款
- 民法典566条/580条适用分析(简要)
## 五、综合风险评级和建议
- 风险评级:🔴高/🟡中/🟢低 + 具体风险项
- 针对性建议(按优先级排列)
```
### 汇总表总览sheet 风险等级配色
```python
# Excel风险等级背景色
red_fill = PatternFill(start_color='FFC7CE', fill_type='solid') # 🔴高风险
yellow_fill = PatternFill(start_color='FFEB9C', fill_type='solid') # 🟡中风险
green_fill = PatternFill(start_color='C6EFCE', fill_type='solid') # 🟢低风险
```
### 总览sheet标准列(12列,已确认模版)
| 列 | 内容 | 宽度 |
|---|---|---|
| A | 序号 | 5 |
| B | 校区名称 | 10 |
| C | 承租主体 | 20 |
| D | 出租方(当前) | 18 |
| E | 物业方 | 18 |
| F | 当前租赁面积(㎡) | 12 |
| G | 租赁期限 | 20 |
| H | 季度租金(元) | 15 |
| I | 季度物业费(元) | 15 |
| J | 押金合计(元) | 12 |
| K | 主要风险点 | 35 |
| L | 备注 | 25 |
> ⚠️ 注意:没有"风险等级"列。风险评级信息写在K列(主要风险点)的开头即可。
> 不要自行添加列——必须与用户确认的模版完全一致。
## 模版对比方法论
> 🔴🔴 **铁律(Maggie 2026-06-23,"记住!!!"):所有"与模版的比对"一律以 `07- 房屋租赁合同.docx` 原件为唯一基准,必须用 python-docx 打开模版原件逐条核对。**
> - **禁止**用"标准商业地产格式""星展商业格式""商业格式常见"等抽象概念当参照系——那是凭印象的二手归纳,不是模版比对。
> - **禁止**拿本 skill 的 checklist/方法论归纳条款当模版替身——清单只是导航,真值在 07 原件 docx 里;每次比对都回原件读真身。
> - 模版原件取法:`docker cp nextcloud-nextcloud-1:"/var/www/html/data/admin/files/小Maggie协作区/南通新东方/参考文件/07- 房屋租赁合同.docx" /tmp/xxx/07模版原件.docx`(注意 `07-` 后有一个空格)。
> - **失效模式(2026-06-23 人民中路+悦拾光实证)**:两份梳理表 L列最初都拿"商业格式"概念写差异、没回 07 原件,结果 ①把模版本身的标配条款(0.1‰逾期、含疫情、优先权…)误当成"本合同特别友好"的优势;②漏掉真实偏离——人民中路第八条抵押被由模版"甲方**不得**抵押"改成"甲方**可**抵押"(对乙方不利)、第十二条办学许可证免责款(模版12.4)被整条删除(教培退出保护缺失)。回原件逐条核才暴露。详见 `references/template-comparison-checklist.md` 顶部铁律。
> - **同源判定**:南通新东方多数租赁合同就是 07 模版填空而成(条款号/措辞/顺序逐条对应),正确定性是"与 07 模版高度同源",差异只在填空值与被改动条款——别把模版标配当本合同特色,也别把"同源合同"误判成"非标准独立友好范本"。
### 关注的模版核心条款
| 条款 | 模版内容 | 对比要点 |
|---|---|---|
| Art.10.2 任意解除权 | 提前X天通知 + 年租金X%违约金 | 是否有此条款、通知期、违约金比例 |
| Art.10.3 甲方终止 | 违约金 + 退押金 + 装修损失公式 | 赔偿是否完整 |
| Art.10.4 逾期付款 | 0.1‰/日 + 15天催缴后X天 | 违约金率、宽限期 |
| Art.11 不可抗力 | 含疫情+行业治理+政策变更 | 范围是否完整 |
| Art.12.4 办学许可证 | 房屋/政策原因无法办证→免责解除 | 是否有此条款 |
| Art.8.4 续租 | 提前1个月通知 | 通知期 |
| Art.9 优先权 | 优先承租+优先购买 | 是否齐全 |
| Art.14.5 非竞争 | 不租给同类机构 | 是否有 |
| 补充条款 | 装修改造+标识广告+增容 | 是否有 |
### 对比原则
- 只关注实质性差异,不比对填空值
- 违约金比例差异必须量化
- 甲方为自然人的合同通常偏差大(非标准格式)
- 物业合同无标准模版,标注"物业服务协议,无对应标准模版"
## 提前退租风险分析框架(参照万达法律意见书)
每个校区的【合同变更与提前解除综合分析】部分,必须按以下框架展开(不是简单罗列条款原文):
### 1. 区分违约金条款的适用范围
- 合同中的违约金条款是否覆盖"无故提前退租"?
- 很多合同的违约金仅适用于列举的特定违约情形(如欠租、擅自转租等),**不能直接适用于主动退租**
- 如有任意解除权条款(模版Art.10.2),则按该条款计算
### 2. 确定责任 vs 不确定责任
| 类别 | 内容 | 说明 |
|------|------|------|
| **确定责任** | 有明确合同依据的(如押金没收、约定违约金) | 直接计算金额 |
| **不确定责任** | 需甲方举证实际损失的 | 列出可能范围 |
不确定责任的三大类:
- **空置期租金损失**:依据《江苏省高级人民法院关于审理城镇房屋租赁合同纠纷案件若干问题的意见》第26条,最长不超过6个月。实际支持金额取决于房屋实际空置时间和甲方是否积极减损
- **免租期租金追偿**:如合同约定了装修免租期,甲方可能主张免租期优惠前提(完整履行租期)不存在而追偿。司法实践中法院酌情处理
- **恢复原状费用**:视合同约定的迁离标准("按现状交付" vs "恢复原始结构")
### 3. 已付未使用租金
- 依据《民法典》第566条(合同解除后的清算规则),承租方有权要求返还已付未使用租金
- 与违约赔偿金额**相互抵扣**后计算净额
### 4. 继续履行风险评估
- 依据《民法典》第580条,租赁合同中承租人使用房屋的义务属非金钱债务,不适于强制履行
- 江苏地区司法实践:承租人明确表示不再租赁甚至已搬离的,法院通常判决解除+违约责任
- 结论:甲方要求继续履行通常不构成实质性法律风险
### 5. 操作建议
按优先级排列:
1. 优先协商解除(合同一般有"协商一致可解除互不担责"条款)——最优路径
2. 尽早发书面解约通知(EMS或可留痕方式)
3. 主动配合房屋交接
4. 注意恢复原状义务(如有)+注销营业执照地址
5. 保留全部往来证据
### 6. 综合成本估算表
风险分析区必须包含一个综合估算,让客户一目了然:
- 确定成本(违约金/押金没收)
- 不确定成本范围(空置+免租期+恢复原状)
- 可收回金额(押金退还/已付未用租金)
- 净成本 = 确定成本 + 不确定成本 - 可收回金额
> **注意**:此框架源自江苏地区司法实践,其他地区可能有差异。法条引用必须核实现行有效版本。
## 跨Session续做铁律
### 1. 维护 PROJECT_STATUS.md
在项目工作目录(如 `/tmp/nantong-hr/`)维护一个 `PROJECT_STATUS.md`,每次做完一批或中断前更新:
```markdown
# XX项目 — 状态文件
## 最新交付物
- 文件名:XXX-20260612.xlsx
- Nextcloud路径:小Maggie协作区/XX/
- 本地副本:/tmp/XX/XXX.xlsx
## 进度(N个校区)
- ✅ 校区A — sheet+总览已填,Maggie已核对通过
- ⏳ 校区B — 待梳理
## 当前任务
补齐XX和YY
## 模版格式
- 总览sheet:12列(列名...)
- 校区sheet:按文件夹分板块,每份合同一行...
```
### 2. 续做时的第一步:找最新交付物
续做时**不能只看本地 /tmp/**——上次的交付物可能只在 Nextcloud 上。必须:
1. 先读 PROJECT_STATUS.md(如果存在)
2. 去 Nextcloud 检查实际最新文件(`docker cp` 拉下来)
3. 打开 Excel 确认实际进度(哪些 sheet 已有、总览哪些行已填)
4. 然后才决定"还差什么"
**反面案例**:只看了本地的旧版 xlsx(20260609),以为只做了1个校区,从头重做了14个校区还换了格式——实际 Nextcloud 上已有15个校区的 20260610 版本。浪费了大量时间且被用户纠正。
### 3. 严格遵守已确认的模版格式
用户说"你看下北翼玖玖的打样"时,**必须逐列核对模版的实际结构**(列数、列名、有无风险等级列等),不能自行"改进"格式。如果认为需要调整格式,先提出建议让用户确认。
### 4. "之前改好的X校区"在哪个文件 → 改前必须确认版本,别在旧版上叠加(Maggie 2026-06-18 万达确立)
用户说"把之前改好的万达也改一下"时,**不能假设手头/主表里的那份就是"改好的"版本**。多校区项目存在两种载体:①独立单校区文件(如世茂样本单独成档)②18-sheet 主汇总表(各校区一个 sheet)。同一校区可能在两处都有,且**版本不同步**。
- **实证**:主汇总表 0612 版里的"万达" sheet,K列仍是 06-17 已被 Maggie 纠正、要降级/删除的旧判断("出租方自然人→偏向甲方"等)——说明 0612 主表里的万达**不是**"改好的"那一版。在它上面加条款号 = 在旧版上叠加,白做。
- **铁律**:改某校区前先 `search_files` 全 Nextcloud + 本地找出该校区的**所有** xlsx 载体,逐个开看哪份是"已改好"的终版(看 K列定级口径是否已对齐最新规则),**版本不明就停下来问 Maggie**,别动手。
- 牵连面:改 18-sheet 主表会重存整个文件(影响全部校区),范围比改单校区文件大,更要先确认动的是不是对的载体、是不是该动这个范围。
## Pitfalls
### 1. OnlyOffice合并单元格行高不自动扩展
合并单元格(如风险分析区A:L合并)设置`height=None`(自动)在OnlyOffice中不生效,内容会被截断。**必须手动计算并设置行高**。
估算方法:
1. 对每行的每列,计算可视行数:将文本按`\n`拆分,每行再按列宽折行(CJK字符占2单位宽,ASCII占1,每行可容纳约 `col_width × 1.2` 个字符单位)
2. 对合并单元格,有效列宽 = 所有合并列宽度之和(如A:K合并=231单位)
3. 取每行中可视行数最大的列
4. 行高 = 可视行数 × 15pt + 20%余量
5. **每次新增内容到K/L列或风险分析区后,必须重新计算该行行高**——不要假设原来的高度还够
> 📐 **行高 409.5/409.6pt 不是 xlsx 天花板,是 OnlyOffice 网页编辑器的 clamp 值**(2026-06-17 实测厘清):openpyxl 从文件层写 900pt 能保留、OnlyOffice x2t 引擎也不 clamp;只有在**网页版编辑器里打开保存**才会把超限行高压回 ~409.5。所以"调高行高显示全部内容"可行——只要从脚本写、走交付链路上传,别再用网页端编辑保存。完整三层行为、x2t round-trip 测法、截断 vs 数据完整的区分见 `references/onlyoffice-xlsx-rowheight-rendering.md`;行高估算用 `scripts/xlsx-rowheight-analyze.py <文件.xlsx>`(只读,标出"当前行高 < 建议行高"的行)。**注意**:若 Maggie 说"就按当前最高行距、不用调"则保持现状不折腾——能调高≠该擅自调(方案≠授权)。
### 1b. 局部编辑已交付 xlsx:openpyxl 只改 value 保格式 + 长单元格 PDF 截断真相(2026-06-17 万达打样)
Maggie 审核后要改某些单元格(如给法律风险列补条款标注)时,**不重新生成整表**,用 openpyxl 局部改:
```python
import openpyxl, shutil
shutil.copy2(SRC, OUT)
wb = openpyxl.load_workbook(OUT) # 不加 data_only,保留公式/样式
ws = wb['万达']
ws.cell(row=5, column=11).value = new_text # 只赋 value,字体/换行/对齐/填充/合并/行高自动保留
wb.save(OUT)
```
- **只赋 `.value` 不动 `.font/.alignment/.fill`**,样式自动保真。改完务必跑保真核对:逐项比对旧/新单元格的 `font.name`、`font.size`、`alignment.wrap_text`、`alignment.vertical`、`merged_cells.ranges` 数量、`row_dimensions[r].height` 是否一致。
- **定位长单元格别靠肉眼数行**:先 `ws.cell(row,col).value[:30]` 确认目标,写入后用 `关键词 in str(cell.value)` 验证内容到位、原错误词清零。
- ⚠️ **长单元格在 PDF/打印导出会被列宽截断,但数据层完整、OnlyOffice 在线编辑视图完整**(2026-06-17 实证:870字符的法律风险单元格,OnlyOffice x2t 导 PDF 后 pdftotext 提取不到条款标注,一度误判"写丢了")。排查时**别用 PDF 文本提取来验证 xlsx 内容是否写入**——要直接 `openpyxl ... data_only=True` 读单元格 value 确认。截断只是导出视图的固有限制(行高920磅+自动换行在编辑视图里足够容纳20行),不是写坏。若客户最终要打印/导 PDF 给客户看,才需另调版式(拆分单元格或缩字号),平时不用管。
- 交付命名走 Maggie 后缀式:`原名-rev. MJ-日期.xlsx`(见 file-naming-convention skill)。
- ⚠️ **pdftotext 验证长单元格文字完整性必须先去折行再 grep,否则假阴性(2026-06-22 世茂实证)**:x2t 渲染 PDF 后用 `pdftotext` 提取来验证某长句是否完整写入时,pdftotext 会**按单元格列宽把长句折行**(如"未明确具体月份(如是否为12月\n15日)"被换行符断开),直接 `grep "完整句"` 会**全部 ❌ 假阴性**,极易误判成"文字截断/写丢"。正解:先 `tr -d '\n' | tr -d ' '` 把整页拍平成单行,再 `grep` 各关键句——本次拍平后六段全 ✅,证明只是渲染折行、文字完整。**别拿带折行的 pdftotext 输出判截断**(同 Pitfall 1b"别用 PDF 文本提取验证 xlsx 内容"的延伸:要么读 xlsx 数据层,要么 pdftotext 拍平后再比对)。
- **交付前渲染自查(铁律,不是跑完代码就交)**:用 OnlyOffice 的 x2t 引擎(Maggie 同款)把 xlsx 渲染成 PDF 看一遍,再用 pdftotext grep 各板块末尾锚点确认无截断。完整配方+权限坑+行高409.5上限真相见 `references/onlyoffice-xlsx-render-and-rowheight.md`。行高统一 **409.6pt**(Maggie 2026-06-17 拍板"按当前最高行距",不再调更高)。
- **🟢 交付前加一道 vision 视觉验收(2026-06-22 vision 配好后确立)**:x2t 渲染 PDF→`pdftoppm` 转 PNG→`vision_analyze` 看图。实测能抓出**纯文字提取(grep)发现不了**的问题:① 红色标记是否真渲染成红色(配合 PIL 像素检测 `(R>120)&(G<90)&(B<90)` 数红像素双重确认)② 文字视觉截断/版面错位 ③ 跨页切断。这是"文字提取验证"的盲区补充——grep 只能证明文字在数据层,看不出视觉呈现。两层都过(pdftotext 拍平 grep 验文字完整 + vision 验视觉呈现)再交。
- 🔴 **vision 报"截断"先区分「PDF 分页切断」vs「真数据丢失」,别误判返工(2026-06-22 悦拾光实证)**:vision 看 x2t 渲染图报某超长行(如 736pt 的付款清单)"底部被切断、内容缺失"时,**先回数据层核**:① openpyxl 读该单元格 value(富文本用 `''.join(t.text...)` 取全文)确认内容完整、② 行高已设足够、③ 全 PDF(不只那一页)pdftotext 拍平 grep 该末尾内容——若三者都在,则 vision 看到的"截断"是 **PDF 分页边界把超长行切到下一页**的视觉现象,在 Excel/OnlyOffice 滚动查看完全正常,**不是数据丢失、不需返工**。只有"打印/导PDF给客户看"场景才需优化分页。区分判据:数据层完整 + 跨页能搜到 = 分页现象(不动);数据层就缺 = 真丢失(修行高/内容)。这与 Pitfall 1b「长单元格 PDF 导出截断但数据完整」同源——视图截断 ≠ 数据坏。
### 2. 核心内容栏不放基础设施规格
"最大供电≥150kW"等基础设施配套约定不是核心商业条款,不放I列(核心内容)。核心内容聚焦于:租金、违约金、解除权、优先权、不可抗力等对业务有实质影响的条款。
### 2b. 租赁标的(E列)房号/铺号写全,不用"等X铺"省略(Maggie 2026-06-18 世茂确立)
E列租赁标的的具体房号/铺号要**全部写上,方便查阅**,不要用"二层18-107-3等5铺"这种省略式。
- 改法:把"二层18-107-3**等5铺**"写全为"二层18-107-3、18-108-2、18-110-2、18-111-2、18-112-2号商铺(套内968.28㎡)"。
- ⚠️ **物业合同的房号以物业合同自己的原文为准核对**,不能直接套租赁合同的(虽多半相同,仍要回物业 OCR 原文核一遍——世茂物业第51行确含全部5房号,与租赁一致)。
- 顺手补套内面积,查阅更完整。
- 这条与"H列金额标条款号""K列风险标条款号"同属一个 Maggie 偏好:**交付物要可核对、信息要完整,不图省略**。
### 3. openpyxl heredoc字符串陷阱
在Python heredoc/f-string中写中文+引号混合内容容易触发SyntaxError。建议用`lines.append()`逐行构建长文本,不用多行字符串拼接。
### 4. OCR大文件用后台进程
4份以上大PDF(>5MB每份)在单个`execute_code`中会超300秒。写OCR脚本到临时文件,用`terminal(background=true, notify_on_complete=true)`运行:
```python
# Write script to /tmp/ocr_batch.py, then:
terminal(command="python3 /tmp/ocr_batch.py", background=True, notify_on_complete=True, timeout=900)
```
OCR跑着的同时,可以并行处理其他校区(先做文件少的校区的OCR+分析)。收到完成通知后再回来做分析和填表。
### 5. 盘点时必须包含空目录
文件夹存在但暂无PDF文件的校区(如待上传的)仍要列入总数和处理清单,标记为"待上传"。不要只统计有PDF的目录——会导致总数与总览sheet不一致。
### 7. 每完成一个校区就上传并发给用户确认
不要攒批——做完一个校区立即上传+验证+**用MEDIA:标签发给用户审阅**,确认格式和内容无误再做下一个。用户明确要求"分析完X先发我看下",逐个交付是硬性要求。
### 8. 检查同一校区是否有配套法律意见书
处理新校区时先搜索Nextcloud相关目录(不止合同目录,也查同名的独立项目文件夹,如`万达校区租赁/`),看是否有已出具的法律意见书。有的话提取核心结论纳入风险分析和K列。
### 9. 企微发文件用MEDIA标签
**不要用send_message工具发企微文件**(不支持)。在回复正文中写`MEDIA:/path/to/file`,gateway自动处理。详见 wecom-file-send-receive skill。
### 10. Nextcloud文件上传可能中断(Cloudflare Tunnel限制)
Nextcloud通过Cloudflare Tunnel暴露时,大文件(>1MB)上传会被截断——日志报"预期文件大小为X字节,实际写入Y字节",物理目录只有`.part`/`.ocTransferId*`碎片。
当用户说"文件已上传"但`find`找不到PDF时:
1. `php occ files:scan` 重新扫描
2. 检查物理目录是否有 `.part` 碎片(`find <path> -name '*.part'`)
3. 查MariaDB确认文件是否注册:
```sql
docker exec nextcloud-db-1 mariadb -u nextcloud -p<password> nextcloud -e "
SELECT f.fileid, f.path, f.name, f.size FROM oc_filecache f
WHERE f.parent IN (<parent_ids>) ORDER BY f.path;"
```
(DB密码在Nextcloud config.php的`dbpassword`字段,不是用户密码)
4. 如确认上传未完成,**不要反复让用户重试网页上传**——Cloudflare Tunnel的问题会持续存在
**替代上传方案**:请用户通过企微私信发文件给小Maggie,文件自动保存到`~/.hermes/cache/documents/`,然后用`docker cp`放入Nextcloud:
```bash
docker cp '<local_path>' <container>:'<nc_path>/<filename>'
docker exec <container> chown www-data:www-data '<nc_path>/<filename>'
docker exec -u www-data <container> php occ files:scan admin --path='<scan_path>'
```
### 11. delegate_task并行批次规划
按复杂度分三档并行处理(每批最多3个slot,是delegate_task的并发上限):
- **简单**(1-2份合同):可以多个校区塞进1个subagent处理(如1个slot做4个简单校区)
- **中等**(2-4份合同):每个校区1个slot
- **复杂**(5+份合同):每个校区1个slot,context需列出所有文件路径
典型3批调度:
```
Batch 1: [星月+解放+跃龙+通大(1 slot简单)] [通大附+通州金鹰(1 slot简单)] [万达(1 slot中等)]
Batch 2: [凤凰文化(1 slot)] [悦拾光(1 slot)] [人民中路(1 slot)]
Batch 3: [世茂(1 slot)] [金飞达(1 slot复杂)] [北翼玖玖(1 slot复杂)]
```
### 12. 总览sheet校区总数必须与目录一致
盘点校区时必须数目录数(包括空目录),不能只数有PDF的目录。出现空目录说明文件待上传,标记为"待上传"而非跳过。用户会核对总数。
### 13. 用户说"这个项目继续"时,先确认是哪个项目
用户说"继续做"、"接着做"等模糊指示时,不要猜——先用session_search查最近的相关session确认。Maggie有多个并行项目(宠物医疗手册、合同梳理、KnowHow协议等),搞错项目浪费双方时间。如果不确定,直接问。
### 14. 不要跨文件夹重新归类合同
客户的文件夹结构(房租/扩租/物业等)是sheet的板块划分依据。即使物业合同在"扩租"文件夹里,也要放在"扩租系列"板块——不能按合同性质重新归类到"物业系列"。客户对着文件夹找文件,必须一一对应。(用户20260609明确纠正过此问题)
### 15. subagent生成的JSON结构必须统一
并行调度多个subagent时,context中必须明确约定JSON输出格式(字段名、数据类型)。不同subagent可能用不同的key名(如`sections` vs `rows`、`risk_summary`是str还是dict),写入Excel时需要额外处理兼容。建议在context中给出JSON schema示例。
### 16. 租赁+物业双合同:客户在两份里的当事人身份常相反,填 D列勿混(2026-06-22 人民中路确立)
同一校区的租赁合同与物业合同里,客户(新东方)的身份**经常相反**:租赁合同里新东方是**乙方(承租方)**;物业合同里新东方常是**甲方(业主/付费方,向物业公司付费)**。填 D列当事人时**逐份回原文核"甲方/乙方分别是谁"**,别因为"都是新东方的合同"就套同一方向。人民中路实证:租赁甲方=琳大鞍(出租)、乙方=新东方;物业甲方=新东方(付费)、乙方=华光物业。处置:物业行 D列主动加注"本合同中新东方为甲方,与租赁合同当事人方向相反"防误读;**审查立场也随身份切换**——审租赁站承租方(乙方)立场,审物业站付费方(甲方)立场。
### 17. 行2基本信息等"长横向信息行"用 \n 换行排版,防 PDF 渲染右侧截断(2026-06-22 人民中路 vision 验收确立)
合并单元格(A2:L2)里塞一长串"项目 | 出租方 | 物业方 | 承租方"信息时,若写成**单行**,x2t/PDF 渲染会因超出页宽被**右边距截断**(人民中路初版"物业方"后的公司名被切掉,是 vision 视觉验收发现的)。正解:长信息行按主体**用 `\n` 分行排版**(项目一行、出租方+物业方一行、承租方一行),并把行高调够(3行约46pt)。这与 Pitfall 1(行高纵向截断)是两个轴:Pitfall 1 防纵向截断、本条防横向截断。**交付前 vision_analyze 看渲染图能抓出这类横向溢出**——是 vision 工具配好后新增的一道视觉验收价值点。
### 18. 🔴 新建/增补单校区汇总表:第一步加载本 skill + 对照已确认样本,禁止 openpyxl 裸做(2026-06-22 悦拾光教训)
被要求「做好/做一下某校区汇总表」时——哪怕指令看起来很简单——**第一步是加载本 skill 并对照已确认的样本(如世茂单校区表)逐板块复刻**,绝不直接 openpyxl 从头裸写。裸做必然漏标准板块、跳过校对。
- **悦拾光实证(两处当场被 Maggie 指出)**:① 裸做的悦拾光表**漏了「校区整体风险分析与建议」段**(skill「Sheet结构」明确要求每校区 sheet 末尾必有此板块,合并 A:L,含整体评价/主要法律关注点/提前解约成本/续签建议——见 ⑧ 整体风险分析段);② 整个**三角色 workflow(承办→校对→终审)被跳过**,没走 `delegate_task` 法律校对‖格式校对,没做终审闭环。
- **铁律①——整体风险分析与建议段是必须板块,单校区/新增校区表同样要有**:不因「只是一份表/只有一个校区」省略。它是校区 sheet 的收口板块,与逐条风险列同等必备。
- **铁律②——指令看似简单 ≠ 可绕过 workflow**:Maggie 给「做好汇总表」这类简短指令时,**仍要走完整 workflow**。她验收时会检查两件事:(a) 标准板块是否齐全(尤其整体风险分析与建议段);(b) 是否真走了 workflow。两者缺一即返工。
- **根因**:把「做表」误判为机械活、绕过 skill 直接裸写代码——于是 skill 里所有沉淀(板块结构、三角色、定级纪律、整体分析段)全部失效。**做表是法律梳理交付物的最后一公里,不是画格子;必须在 skill 框架内做。**
- 落地自检(动手前过一遍):① 我加载本 skill 了吗?② 我对照样本核过板块清单了吗(含整体风险分析与建议段)?③ 我走 workflow / 三角色校对了吗?三个都「是」才动手交付。
### 19. ✅ 列结构口径已裁定:校区详情 sheet = 12 列含独立 L 列(Maggie 2026-06-23 裁定,原矛盾已消除)
**背景(历史教训,留作记录)**:skill 内部曾对汇总表列数有两套未对齐的说法——SKILL.md 正文 Step3、第358行一度写"11 列、模版差异并入 K 列",而 `column-structure.md`、`independent-legal-review-framework.md`「分列」、增量维护 SOP「动作B独立产出」均为"12 列含独立 L 列"。2026-06-23 复盘提请 Maggie 裁定。
**✅ 裁定结果(唯一口径)**:**校区详情 sheet = 12 列,K 列装法律风险(动作A)、L 列「与标准模版差异」装模版差异(动作B),两者物理分列、绝不并入。** 此前的"11 列/并入 K 列"旧表述全部作废,相关位置(SKILL.md Step3 第525行、第363行、column-structure.md 顶部)已于 2026-06-23 同步更正一致。
- **为什么 12 列对**:与「模版差异 ≠ 法律风险」铁律严丝合缝——两件不同性质的事(法律风险 vs 合规差距)就该物理分列,挤在 K 列必然让下游误读。多数 reference 文件本就是 12 列口径,"11 列"是某次临时改动没回滚干净的异类。
- **模版差异内容铁律不变**:L 列内容**必须回 07 原件逐条核**(见「模版对比方法论」顶部铁律 + 填表分工节的 subagent 强制项),列数已定不影响这条,反而强化它。
- **教训**:skill 内部出现"两套说法"时,不应自己"倾向判断"某一套就默认执行(当时我倾向了错的"11 列"),而应提请 Maggie 裁定 + 裁定后立即全文对齐消矛盾——这正是本次的正确处理路径。
### 20. ⚠️ 三条「合同审查」轨道必须精确区分,别把本梳理线笼统叫「workflow」(2026-06-23 三轮追问教训)
环境里有**三条名字都含「合同审查」、但性质完全不同**的轨道,极易混为一谈。Maggie 2026-06-23 连问三次(「workflow里模版对比」→「南通新东方租赁合同审查的workflow」→「这个流程」)才让我对准——根因是我把**本 skill 的梳理线**笼统称作「workflow」、还一度跟 uwf 的 `review-contract.yaml` 混了。Maggie 是律师、要求术语精确(USER.md:不用模糊比喻指代有精确定义的技术对象),含糊命名本身就是返工信号。
| 轨道 | 归谁 | 走什么 | 模版对比? |
|---|---|---|---|
| **A. 批量合同审查** | Doro/邱律师团队 | uwf `review-contract.yaml`(classifier→reviewer→editor→复核→deliverer 五角色流水线) | ❌ 不做模版对比 |
| **B. 单份文件独立审核** | 南通新东方(Maggie 派) | `nantong-xindongfang-review` skill 的**手动**reviewer+editor 合一模式(**明确「不走 workflow」**) | ❌ 不做模版对比 |
| **C. 多校区梳理台账** | 南通新东方(Maggie 派) | **本 skill**(contract-portfolio-analysis)的 Step0→5 + 三角色 | ✅ **模版对比(L列)只在这条线**,回 07 原件 |
- **要害**:用户问「南通新东方租赁合同审查的 workflow / 模版对比」时,**99% 指的是 C(本 skill 梳理线)**——因为模版对比(L列)只活在 C。别下意识跳到 uwf 的 `review-contract.yaml`(那是 A,与南通新东方严格隔离、且根本不做模版对比,grep 它零命中模版内容)。
- **命名纪律**:C 这条线在跟 Maggie 沟通时,称「**南通新东方租赁合同梳理(组合分析)**」或「本 skill 的 Step0→5 流程」,**不要笼统说「workflow」**——「workflow」一词在本环境特指 uwf 那套 YAML 状态机(A),混用会让律师用户反复追问到底指哪条。本 skill 内部把 Step0→5 叫「workflow」是历史习惯(见「元规则」节),但**对外指代时要带限定词**说清是哪条线。
- 自检:被问到「合同审查的某个环节」先定位是 A/B/C 哪条,再答;拿不准就先回一句「你指的是 Doro 批量那条、南通单份审核、还是南通梳理台账?」一句话锁定,比答错三轮强。
### 21. 🔴 校区数/任何「总数」必须用精确列举得出,绝不眼估——南通新东方是 17 个校区(2026-06-23 教训)
**栽点**:我在 SKILL/脚本/记忆里写「21 个校区」,是扫了一眼 `find ... -type d` 的输出**眼估**的——那次 find 把 `参考文件/`、`万达校区租赁/`、`HRD协商解除/`、`业务合同-培训服务/` 等**非校区目录**也列进来了,我没数就拍了个数。Maggie 当场抓出「你为什么说是 21?是 17」。**最讽刺的是:这正发生在我为「不准凭印象、要逐字核实」写存档的当口**——立规矩的同一下笔就犯了规矩。
- **铁律:任何进交付物/skill/记忆的「数量、总数、覆盖率」(校区数、合同份数、N/N 覆盖、行数…)必须由精确命令得出,并把得数的命令一并留痕**,绝不眼估、绝不凭印象续写上次的数。
- **数校区的唯一正确姿势**:在 `房租物业合同/` 目录内跑 `ls -1d */ | wc -l`(只数子目录、不混入文件),或 `ls -1d */` 逐个列名核对——**不要用 `find -type d`**(它会把上层目录、参考文件、非校区项目目录全捞进来,计数虚高)。
- **南通新东方 = 17 个校区**(2026-06-23 精确核定):万达、世茂、人民中路、凤凰文化、北翼玖玖、南通大厦、小石桥晏园、悦拾光、星月、桃坞路、解放中路、跃龙路、通大、通大附、通州金鹰、金飞达、龙信。注意 `参考文件/`、`万达校区租赁/`(独立法律意见书目录)、`HRD协商解除/`、`业务合同-培训服务/`、`国际青创园租赁/`、`交通银行薪酬代发合作/` **都不是「房租物业合同」下的校区**,别误计入。
- **闸门脚本是数量的真相源,不是写死的数字**:`campus-workflow-gate.py` 用**实时 `ls`** 定位校区,靠它而不是任何文档里抄来的数字;文档里出现的「17」只是给人看的提示,若将来校区增减,**以脚本实时列举为准**,并回头改文档别留旧数。
- 这条是「第一铁律·逐字通读」「开工铁律·单校区独立闭环」在**计数动作**上的同一抓手:判断要回原文,**计数要回精确列举**,两者都禁印象式推断。
### 22. 🔴 改本 skill 自身的「多处联动规则」(SKILL.md + 闸门脚本 + reference 三处口径):一次一处原子改 + 改完 grep 验,别在同一轮里又 patch 又长篇说话(2026-06-23 编号统一耗时 40 分钟教训)
**栽点**:统一 Step 编号时,SKILL.md 正文、`scripts/campus-workflow-gate.py`、顶部引用三处要同步改。我连续几轮把 `patch` 调用和一大段解说塞在**同一轮回复**里,patch 没真正落地(被下一条用户消息打断/未执行完),我却**没在第一次核实发现「没落地」时就换方法**,反而重复同样的动作好几轮——直到 Maggie 问「为什么这么久,哪里卡住了」。本质是违反了本 skill「不能假设成功、要核实;发现没成功要立即换方法」的同一条纪律,只不过对象从合同换成了 skill 文件自己。
- **铁律①——一次一处原子改**:改多文件联动规则时,**一个 `patch`/`write_file` 调用只做一处改动,不在同一轮夹带长篇解说**。把「改」和「说」分开:先把这一处改干净、拿到成功回执,再说话/再改下一处。夹带 prose 的复合轮最容易让编辑动作没执行完就被打断。
- **铁律②——改完立即 grep 验残留,不靠「我以为改了」**:每改一处,紧接着用 `search_files`/`grep` 扫**旧串是否清零、新串是否到位**(如统一编号后 `grep "Step 3.5\|Step 0→5\|完整 6 步"` 必须为空)。没亲眼看到「旧串 0 残留 + 新串就位」之前,绝不说「改好了/检查好了」。这是「第一职业纪律·结论必有依据、自己核实」在改自己文件时的同一抓手。
- **铁律③——多处口径必须全绑定一起验**:Step 编号、列结构(12列/L列)、校区数这类「同一事实散落多文件」的口径,改完跑一次**全树扫描**确认所有副本一致(`grep -rn <旧口径> SKILL.md scripts/ references/`)。本 session 正面案例:最后用一次全树 grep 确认「✅ 全树零残留」+ 实跑闸门脚本看输出,才给出有依据的「好了」。
- **判据**:凡是「同一规则要改 N 个文件」的维护任务,N 越大越要原子化 + 每步验,绝不攒成一个大复合轮。被用户追问「卡在哪」时,先如实承认「前面几轮的编辑没落地、我没及时换方法」,再用原子改一次性修干净——不要继续掩饰式重试。
### 23. 🔴🔴 执行纪律:工具调用单独发、不与长篇 prose 同轮;落地用磁盘核实不靠「我以为」;被打断/没落地立即换方法不重复死动作(2026-06-23 人民中路实跑 40 分钟空耗教训,最高频反复犯)
**这是本 session 反复犯、被 Maggie 连环追问(「为什么这么慢」「哪里卡住了」「跟我发消息没关系,这个时间你早该完成」)的头号执行问题,比任何内容规则都更先拖垮交付。** 与 Pitfall 22 同根,但适用面是**所有任务执行**(OCR、读合同、跑脚本、改文件……),不限于改 skill 文件。
- **根因机制(必须正视)**:把「工具调用 + 一段解说话」塞进同一轮回复时,工具要等 prose 写完才执行;用户此时若发新消息,**这一轮被接管、那个还没跑的工具调用直接被丢弃**——不是工具坏,是它根本没执行。表现就是「我以为取了 07 模版/读了那两页,实际磁盘上没有」。而「慢」的体感来自:提交→被丢→下一轮先花一次工具核实没落地→再重做,一来一回空转。\n- **铁律①——动作与解说分轮**:要跑工具就**这一轮只发工具、最多一句话**,结果回来再解释/再决定下一步。绝不「边长篇说边夹个 patch/terminal」。prose 越长,被打断丢弃的窗口越大。\n- **铁律②——每轮先核实再行动,绝不假设上一步成功**:接用户消息的第一个动作 = 用一条命令查磁盘真实状态(`ls -la 工作目录` / `wc -l 产物`),确认上一步到底落没落地,再决定干什么。这是「不能假设成功」在执行层的落地,也是被打断后**唯一正确的续跑姿势**——产物落盘可续,核实即接上,不重来不做丢。\n- **铁律③——发现「没落地」立即换方法,绝不重复同一死动作**:同一个调用连续两轮没成功,就是信号——停下,换更简单/更原子的方式(如把复合 patch 拆成单行替换、把「边说边改」改成「纯工具一发」),而不是第三次第四次重复一模一样的提交。本 session 正是重复了好几轮才换法,才空耗 40 分钟。\n- **铁律④——被追问进度先如实承认,不掩饰**:用户问「卡哪了/为什么慢」时,先用一条命令核实当前真实进度并如实报「X 步没落地、根因是我边说边做被丢、我没及时换方法」,再原子修干净。绝不含糊搪塞「快好了」或继续掩饰式重试——Maggie 明确反感把她当测试员、反感空转。\n- **落地自检(每次要发工具前过一遍)**:① 这一轮我是不是又在「长篇说话 + 夹工具」?是→拆开,先发工具。② 上一步我**亲眼**在工具输出里见到成功回执了吗?没有→先核实别假设。③ 同一动作我是不是已经连试两轮没成?是→换方法别再重复。\n- 这条与「第一职业纪律·结论必有依据自己核实」「Pitfall 22·一次一处原子改+grep 验」三位一体:22 管改 skill 文件、本条管一切任务执行,核心同一句——**少说多做、单发即验、不假设、不空转**。
## 参考文件
- `references/file-inventory-classification.md` — **文件盘点与归类规则**:校区首现时建立,租赁/物业→场所→签约时间,贯穿全流程不跨文件夹重排
- `references/ocr-rate-symbol-verification.md` — **OCR 费率符号核对配方(‰ vs %)**:双跑交叉(整页 vs 裁图放大重 OCR)、量级常识闸门、两次不一致即升级人工带裁图、vision provider 未配退路
- `references/independent-legal-review-framework.md` — **独立法律审查框架(动作A,八维)**:把每份合同当新合同全面审,区别于模版比对
- `references/incremental-maintenance-sop.md` — **增量维护 SOP**:新增/到期/提前解除三类变动的三角色处理流程
- `references/chinese-diagram-rendering.md` — **中文流程图/图表渲染**:matplotlib 豆腐块根因+修复,HTML 首选方案,无法自检图像时的退路
- `templates/lease-review-flowchart.html` — HTML 流程图起始模板(语义配色、div+箭头字符,中文零乱码)
- `references/column-structure.md` — 12列Excel结构详细说明
- `references/onlyoffice-xlsx-rowheight-rendering.md` — **OnlyOffice 行高/合并单元格截断/渲染核查**:409.5pt clamp 真相(文件层 vs x2t vs 网页编辑器三层行为)、x2t round-trip 测 clamp、数据完整≠视图不截断、可复用 SOP
- `scripts/xlsx-rowheight-analyze.py` — **行高分析脚本**(只读):估算每个长单元格所需行高,标出可能截断的行
- `scripts/fix-richtext-rpr-order.py` — **富文本 rPr 顺序修复脚本**:openpyxl 标红(CellRichText)后把 `<rPr>` 子元素改成 Excel 合规顺序(rFont→sz→color)。⚠️ **实证:修了顺序 Excel 仍报"需要修复"**,本脚本不保证解决问题,仅作记录。支持 `--check` 只检查。**正解是别用富文本标红,用纯文本前缀。**
- `scripts/edit-redmarked-xlsx.py` — **安全编辑「已标红(WPS规范化)」xlsx 的脚本+工具库**:绝不用 openpyxl 重存(会毁红色),在 sharedStrings.xml XML 层外科手术只改目标 `<t>`、红 run 不碰。含 `--verify` 五查(sharedStrings在/红色数/zip+XML完整/Content_Types置首/与基线同构)、`--map`(单元格→si索引)、`--show-si`(看 run 结构),及可 import 的 `surgical_edit_si/delete_run_and_renumber/repack`(已验证正确,照抄别重写)。改红标 xlsx 必用。
- `references/openpyxl-excel-richtext-pitfall.md` — **openpyxl 富文本→Excel"需要修复"陷阱全记录**:为什么不能用 CellRichText 局部着色、试过的所有修法(rPr 顺序/charset/去富文本全失败)、为什么 WPS/x2t/openpyxl 都骗过自己唯独 Excel 报错、"本地验不了 Excel 就如实告诉用户别当测试员"的行为铁律、正解(纯文本前缀)。
- `references/template-comparison-checklist.md` — 标准模版对比清单
- `references/termination-risk-framework.md` — 提前退租风险分析框架(法律依据+分析模板+关键判断点)
- `references/nextcloud-file-diagnostics.md` — Nextcloud文件上传失败排查步骤(含MariaDB查询方法)
- `references/onlyoffice-xlsx-render-and-rowheight.md` — **交付前渲染自查配方**(x2t引擎+权限坑)+ 行高409.5上限真相(网页编辑器clamp根因,统一409.6)
@@ -0,0 +1,37 @@
# 批量校区审查执行纪律(2026-07-03 龙信返工总结)
## 根因
连续做6个校区(星月→跃龙路→通州金鹰→小石桥晏园→龙信→海门临时),到第4-5个时执行质量明显衰减:
- 跳过逐字通读(物业20页中间跳了)
- 套用前一校区结论代替独立审查(海门临时照搬龙信结论)
- K列法律风险没做八维框架(凭直觉归纳)
- 不跑物理检查点脚本
- 擅自删除旧文件(没被授权)
## 铁律
### 1. Session容量限制
**连续做3个校区后,必须主动提醒Maggie开新对话。** 不等用户发现,自己数到第3个就提醒。
### 2. 每份合同独立一行
3份合同=3行,不合并。Maggie原话:"3份合同分别提取,分别审阅,做成三行,不要混在一起写"
### 3. 所有租赁合同必须做07模版比对
不论是新东方制式还是甲方制式,租赁合同一律delegate做07比对。L列从比对文件逐条摘。龙信教训:第一版因"非新东方制式"就写了一句"无对应模版"被打回。
### 4. 不擅自删除任何文件
旧版汇总表保留,不清理。Maggie原话:"我没让你清理旧表,请恢复这两个旧表,没有让做的事情不要自己擅自进行"
### 5. 逐字逐句不是口号
被问"你是自己逐字逐句审阅的么?"——如果答案是否定的,诚实说,然后补做。不粉饰。衰减信号:
- 发现自己在写"条款结构与XX类似"→没独立审
- 物业合同20页只grep了关键词→没通读
- K列写完觉得"差不多"但说不出某条依据→没审到位
### 6. H列月租换算写法
行内括号写(如"年340,000元(月租≈28,333元)"),不另起行。
## 解决模式
- 做到第3个校区时:停下来,主动告诉Maggie建议开新对话
- 每份合同做完K列后自问:这条风险的条款号出处是什么?答不出=返工
- 交付前必跑kl-separation-check.py,不凭眼判断
@@ -0,0 +1,64 @@
# 北翼玖玖 / 金飞达纠偏:17份文件先出“按租赁物+时间顺序”的列表
## 触发场景
- 用户说“先把17个文件列表梳理出来”
- 用户强调“根据租赁物,同一租赁物相关合同或文件,按照时间顺序排列”
- 校区项目进入正式H/I/K/L审查前,需要先锁定文件编排顺序
## 本次纠偏结论
在北翼玖玖这类多文件校区任务中,**先交付的不是合同类型清单,而是“按租赁物归组 + 组内按时间顺序”的列表**。
### 具体要求
1. 先按**租赁物**分成主线板块;
2. 同一租赁物下的**租赁合同、补充协议、退租协议、物业合同、物业补充协议、说明文件**一并放入同组;
3. 组内按**签约时间/正文明确生效时间/权利义务转移时间**排序;
4. 如果签章页未载明日期,必须明确写“**签署日未载明**”,并说明采用何种时间线索排序;
5. 在用户要求“先梳理列表”时,**先给列表,不要抢跑到H/I/K/L审查进度汇报**。
## 推荐输出格式
### 租赁物A:……
1. 文件名
- 性质:……
- 时间:……
- 说明:……
### 租赁物B:……
1. 文件名
- 性质:……
- 时间:……
- 说明:……
最后补一句:
- 共几组
- 共几份
- 哪些日期系正文线索排序、哪些日期未载明
## 本次北翼玖玖实际归组方法
### A组:4幢101、102、201室 + 1幢507室
主线顺序:
- 房租主合同
- 物业主合同
- 物业减免补充协议
- 房租减免补充协议
- 物业退租协议
- 物业补充协议二
- 房屋部分退租协议
- 4幢补充协议三(权利义务转移)
- 两份物业电费/水电费收款变更补充协议
- 房租目录下特殊情况说明
### B组:1幢205/206/207/208/209室(核心租赁为206-209)
主线顺序:
- 扩租房屋合同
- 配套物业合同
- 扩租房屋租赁合同补充协议(权利义务转移)
- 扩租物业补充协议
- 扩租物业补充协议(电费教育科技)
- 扩租目录下特殊情况说明
## 操作提醒
- “按文件夹盘点”与“按租赁物输出”是两层动作:
- 盘点时尊重源目录(房租/扩租/物业)
- 输出时重建为租赁物主线
- 同一事项的重复版本/近似版本(尤其电费、水电费收款主体变更协议)**先保留进列表,再在后续审查中判断是否为同一事项不同版本**。
@@ -0,0 +1,50 @@
# 校区审查范围与排序铁律(2026-07-13 金飞达纠偏)
## 适用触发
当用户说:
- “开始某校区的审查和汇总”
- “某校区重新整套做”
- “按租赁物分类输出”
默认这是**校区全部合同/PDF文件**的梳理与审查,不是只抓主租赁合同。
## 范围铁律
必须先盘清该校区**全部相关文件**,至少包括:
- 租赁合同
- 补充协议
- 主体变更协议
- 物业合同
- 物业补充协议
- 授权书
- 其他与该租赁物直接相关的辅助文件
用户未明确排除前,**物业合同也在本轮审查和汇总范围内**。
## 组织铁律
输出结构必须:
1. **先按租赁物分类**建板块
2. 每个租赁物下放入该租赁物的**全部相关文件**
3. 板块内部按**签约时间顺序**排列
排序主键是:
- 第一层:租赁物
- 第二层:签约时间
- 不是文件类型
## 禁止事项
- 禁止把“租赁审查”理解成“只看租赁合同本体”
- 禁止先租赁后物业硬分组
- 禁止把同一租赁物的物业合同甩到别的板块
- 禁止先做主合同,后想起再补物业合同
## 例外
只有确实**无法挂到具体租赁物**的文件,才单列“其他文件”板块,例如:
- 校区层面的总授权书
- 无法判断对应租赁物的通用说明文件
## 与开工闸门的配合
开始前应先在开工确认中显式写出:
- 本校区有哪些文件
- 哪些租赁物板块
- 每个板块包含哪些文件
- 排序是否已按签约时间锁定
@@ -0,0 +1,38 @@
# 中文流程图/图表渲染:matplotlib 坑 + HTML 首选方案
> 2026-06-16 实战教训:给 Maggie 画流程图,matplotlib 反复把中文渲染成"豆腐块"(空心方块 tofu),折腾数轮。根因与可靠方案如下。
## matplotlib 中文豆腐块根因
- 系统装了文泉驿 `wqy-zenhei.ttc`,但 **matplotlib 默认不把 `.ttc` 注册进字体缓存**——`rcParams['font.sans-serif']=['WenQuanYi Zen Hei']` 按"字体名"查找会**静默失败、回退成豆腐块**,不报错。
- `fc-list :lang=zh` 能看到字体 ≠ matplotlib 能按名字用它。
## 可靠修复(若必须用 matplotlib)
**给每个 text 显式传 `fontproperties` 指向字体文件**,不靠字体名查找:
```python
import matplotlib.font_manager as fm
FP = fm.FontProperties(fname="/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf")
# Droid Sans Fallback 是 .ttf、已在缓存、支持全中文,比 .ttc 可靠
ax.text(x, y, "租赁合同", fontproperties=FP) # 每处都带 fontproperties
```
验证某字体文件能否渲染中文:渲染单字 '租',统计中心 1/3 区域笔画占比,>0.08 是真字、<0.05 是空心豆腐块。
## ★ 首选方案:用 HTML 而非 matplotlib PNG
**给非技术用户(Maggie,经企微/OnlyOffice 看)的中文图表/流程图,优先做成自包含 HTML**:
1. **中文渲染零风险**——浏览器直接调系统字体,`font-family:"Microsoft YaHei","PingFang SC","WenQuanYi Zen Hei",sans-serif`,绝不豆腐块。
2. **企微能直接打开**,可缩放。
3. **可自检**——`browser_navigate``file://` 后看快照里的文字是否正确,比赌 PNG 字体渲染可靠得多。
4. 用 div + border + 颜色块画节点、`↓`/`→` 字符画箭头,配色用语义色(蓝=主审、绿=subagent、金=交付)。
可复制的起始模板:`templates/lease-review-flowchart.html`
## ⚠️ 语义色点用纯 CSS 圆点,不要用 emoji(🔵🟢🔴)(2026-06-22 workflow流程图实证)
图例/节点里标语义色,**别用 emoji 彩色圆点**——无头浏览器截图环境(Browserbase 等)常缺 emoji 字体,截图里 emoji 渲染成空心方框 `▢`(文本层 emoji 字符其实完好、`browser_console``looksLikeBox:false`,只是那个截图环境字体缺失)。但你**无法保证 Maggie 的企微/设备一定有 emoji 字体**,交付物要万无一失。
- 正解:用**纯 CSS 圆点** `<span class="dot b"></span>``.dot{display:inline-block;width:11px;height:11px;border-radius:50%;border:1.5px solid;}`,各色 `.dot.b{background:#BBD9F0;border-color:#2E6DA4;}`…。任何设备、任何环境稳定显示,不依赖 emoji 字体。
- 节点本身已有语义底色时,标题里的 emoji 色点是冗余,直接删(靠底色表达语义即可)。
- 表意标记 emoji(⚠️📌✅)可保留——渲染更稳,且是真正的语义标记,不是纯装饰色点。
- 自检:`browser_navigate``file://` 后用 `browser_console``.lg` 的 textContent 确认文本层完整;再 `vision_analyze`/`browser_vision` 看截图确认色点真渲染成彩色圆点(不是方框)。
## 图像自检:vision 已配好,是主路径(2026-06-22)
vision_analyze/browser_vision 已由技术支持配好可用——交付 PNG/HTML 截图前**先自己 vision 看一遍**验证版面/配色/截断/乱码,这是主路径(实测能准确读出渲染图的配色、文字截断、版面错位、色点是否方框)。
- **HTML 双层自检**:`browser_navigate``file://` 看快照验证中文/结构(文本层)+ `browser_vision` 看视觉层(配色/排版/emoji 方框)——两层都过才发。
- **兜底(vision 万一又报 "No LLM provider configured for task=vision" 或连不上)**:不把"看不见的 PNG"直接甩用户,改用能推理正确性的格式——HTML(看 `browser_navigate` 快照验证文本层)或纯文字版流程图(方框+箭头字符,零字体依赖),HTML+纯文字兜底一起发。
@@ -0,0 +1,60 @@
# 南通汇总表列归类规则 (Maggie 2026-07-01 确认)
## H列(金额/费用)— 只放纯费用
✅ 属于H列:
- 租金标准(单价+月总额)
- 物业费标准
- 付款推算(各期金额+日期+❗标注)
- 履约保证金
- 供电增容费用归属
- 水电费标准(单价)
- 免费停车位
❌ 不属于H列(常见错误):
- 违约金 → 归I列(核心内容),违约金是合同权利义务安排,不是费用
- 逾期违约金 → 归I列
- 擅自转租违约金 → 归I列
## I列(核心内容)— 客观陈述合同约定
包含:用途限制、转租条件、装修改造权、广告标识、非竞争、维修责任、解除权机制、不可抗力、优先权、续租、消防义务、管辖、通知方式、**违约金条款**。
## K列(法律风险)— 聚焦风险,不放有利条款
K列数据行(r5/r8)只写:
- 整体评价(一段)
- 需注意的风险点(逐条编号)
- 提前退租法律后果分析
❌ "对乙方有利条款"不放K列 → 放整体段(row 10)续签建议里,措辞为"建议保留以下对乙方有利条款"
## K列·提前退租分析框架
### 原则:有约定写约定,无约定写法律规定,不做无锚点推测
### 情况一:合同有明确约定(如凤凰文化第十条第2款)
直接写约定内容,不再延伸:
```
① 单方解除:提前60日通知 + 初年年租金20%违约金 + 租金及其他费用结算至解除日
② 可收回:结算至解除日 + 押金30工作日退还
③ 其他法定/约定解除路径(办学许可证/不可抗力等)
```
**禁止**:"可能主张损失""建议协商规避""不确定责任"等无锚点推测。
### 情况二:合同无明确约定
三层分析(每层都有法律依据,不是推测):
1. **法定解除权**:《民法典》563条(不可抗力致目的不能实现、对方根本违约等)+ 租赁特有法定解除
2. **无法定事由 = 违约**:《民法典》584条——赔偿因违约造成的损失(含履行后可获利益),上限=可预见损失
3. **损失具体构成**:空置期租金(司法实践3-6个月)、押金是否可抵扣、预付租金结算
## 整体段(row 10)结构
```
【整体评价】
【提前退租法律后果】
【其他法律关注点】
【续签建议】
· 建议补充/修改:...
· 建议保留以下对乙方有利条款:①办学许可证免责解约权 ②不可抗力 ③优先权 ...
```
@@ -0,0 +1,70 @@
# 汇总表列内容归类规则(Maggie 2026-07-01 确认)
## H列(金额/费用)—— 只放纯费用信息
✅ 放H列:
- 租金标准+月租金(行内括号写法:如"年191,990元(月租≈15,999元)")+每期合计
- 物业费标准+月物业费
- 付款推算(逐期展开:区间+租金+物业=合计+付款期限+❗标注)
- 履约保证金/押金
- 供电增容费用
- 水电费标准(单价)
- 免费停车位(权益性质但与费用相关)
### 付款推算效率规则(2026-07-03)
付款推算直接从汇总表已有数据推导:
- G列 → 合同起止日期、免租期
- H列 → 租金/期、物业费/月、支付方式(半年/季度/年)、提前天数
无需每次回OCR原文。仅当数据存疑(如附件分期与正文不一致)时才回md核实。
❌ 不放H列:
- 违约金(属合同权利义务安排→归I列)
- 逾期付款违约金
- 擅自转租违约金
- 水电逾期违约金
- 单方解除违约金
## I列(核心内容)—— 客观陈述合同约定的权利义务安排
包含:
- 用途限制、转租条件、装修改造
- 非竞争条款、出租方变更、抵押限制
- 解除权机制(通知期+违约金+结算方式)
- 各类违约金条款(从H列移入)
- 不可抗力、续租、优先权
- 安全责任、消防义务
- 管辖、通知方式
## K列(法律风险)—— 聚焦风险,不列有利条款
结构:
1. 【整体评价·站承租方立场】一段话概述
2. 〇 需注意(逐条列风险点,标条款号)
3. ◎ 提前退租法律后果分析
4. 【续签建议】(改进建议)
### 提前退租分析框架
**核心原则:有约定写约定,无约定写法律规定——都要有依据,不做无锚点推测。**
#### 情形一:合同有明确约定
直接写约定内容,不做额外或然推测。
示例(凤凰文化):
> ① 单方解除(第十条第2款):提前60日书面通知+承担初年年租金20%违约金(约16,411元)+租赁租金及其他费用结算至合同解除日。约定明确,退租成本可控。
❌ 不写:"甲方可能主张实际损失""建议协商规避""不确定责任"
#### 情形二:合同无明确约定
写法律规定的三层分析:
1. 法定解除权(民法典563条+租赁特有法定解除)
2. 无法定事由=违约(民法典584条:赔偿可预见损失)
3. 损失具体构成(空置期3-6个月/押金/预付租金)
### 不总结"对乙方有利条款"
Maggie确认:不总结对客户有利的条款,对客户实际意义不大。
- K列数据行不写
- 整体段不写
- 续签建议也不写"建议保留"
@@ -0,0 +1,585 @@
# 南通汇总表各列规则(Maggie 2026-07-01 校准版)
优先级最高——与 SKILL.md 冲突时以本文件为准。
## 格式参照基准(Maggie 0703 指令)
**跃龙路校区为输出格式唯一参照**("输出格式参照跃龙路,别自己创设")。具体:
- D列:`甲方:XX\n乙方:XX`(简洁,不加括号说明"出租方""承租方")
- E列:地址+用途(`用途:办公`单独一行)
- H列付款推算:用`┃`分隔金额和付款期限(`170,000元 ┃ 2026.9.20前`
- H列物业费:必须逐期列出每期金额+付款截止日期,不得写"按年预收类推"
- K列标题:`【整体评价·站承租方(乙方)立场】`
- K列风险点标题:`〇 需注意`(非"【需注意的风险点】")
- 整体段:`【其他法律关注点】`(非"【法律关注点】")
## 🔴 输出格式铁律(Maggie 2026-07-03 龙信返工教训)
**跃龙路定稿是格式唯一参照物,不得自创格式。** 详见 `references/output-format-canonical-0703.md`
新建校区前必须先 `read_file` 跃龙路汇总表各列,照抄格式框架。关键差异点:
- K列标题:`【整体评价·站承租方(乙方)立场】` + `〇 需注意`(不是`【整体评价】`+`【需注意的风险点】🔴`
- K列风险编号:纯数字不加emoji(不用🔴🟡🟢)
- H列付款推算:`金额 ┃ 付款期限`(┃分隔)
- L列差异:`N.【标签】07模版:XX→本合同:XX`
- 整体段:`【其他法律关注点】`(不是`【法律关注点】`
- D列:`甲方:XX\n乙方:XX`(简洁,不加"出租方""承租方"括号说明)
- 物业行K列:直接"物业合同风险XX。关注点:"开头(不用【整体评价·...】框架)
---
## H列:金额/费用
**原则**:只写乙方正常履约需要支付的确定费用。
- **写什么**:租金标准及总额、物业费、押金、付款推算(各期金额+付款截止日期)等
- **不写什么**:违约金、滞纳金、银行账户信息、供电功率等
- **判断标准**:这笔钱是乙方正常履约就要付的吗?是→写;是出了问题才产生的→不写
- 上面的"写什么""不写什么"均为列举示例,不是穷举清单。按合同实际情况判断。
### ⚠️ H列混入违约金pitfall(小石桥0707教训)
物业合同的"逾期滞纳金X%/日"容易被误写入H列——因为它紧挨着物业费标准出现在同一条款中。**判断标准只看一件事:这笔钱是正常履约就要付的吗?** 逾期滞纳金=出了问题才产生=不写H列。应放K列作为风险项。
常见误入H列的项目(全部应删除):
- ❌ 逾期滞纳金/违约金(0.5%/日、0.1‰/日等)
- ❌ 供电功率(不是费用)
- ❌ 维修费承担方式(属权利义务分配→I列)
- ❌ 押金扣除条件(属违约后果→K列)
### ⚠️ H列条款号引用必须核对附件实际编号(龙信0708教训)
附件一/附件三的条目编号不能凭记忆或推测,必须核对原文实际标号。典型错误:
- 附件一结构为「1.租金 (1)标准 (2)支付期限 / 2.物业管理费 / 3.保证金 / 4.其他费用」
- 错误:把"支付期限"引用为"附件一第2项"(第2项实际是物业管理费)
- 正确:应引用为"附件一第1(2)项"
不同合同的附件结构可能不同(如临时合同无物业管理费项,编号直接跳到第4项)。**每份合同的附件引用必须独立核对该合同自己的编号结构。**
### H列月租换算格式(Maggie 0703 指令)
月租换算一律用**行内括号写法**,写在租金标准那行括号里,不单独另起行。
- ✅ 正确:`年191,990元(月租≈15,999元)`
- ❌ 错误:单独另起一行写"折合月租约15,999元"
### H列付款推算描述规则(通州金鹰0707教训)
**当合同有明确的付款日期表格时(如第三条租金表逐期列出付款日),H列付款推算的描述必须引用表格作为权威来源**,不得仅凭合同文本中的一般规则(如"每期结束前15天付下期")来描述。
- ✅ 正确:`按合同第三条租金表约定的付款日期(各期起始前1个月)`
- ❌ 错误:`每期结束前15天付下期(第三条第2款)`——如果表格实际约定的日期与"15天"不吻合
**如果文本规则与表格约定存在矛盾**(如文本说"15天前",但表格显示各期均为"起始前1个月"),须:
1. H列以表格日期为准(具体约定优先于一般规则)
2. K列补充"付款规则内部矛盾"作为风险项——对方可能援引文本规则主张违约
**首期付款日特殊处理**:如合同表格有明确的首期付款日期(如"2025-03-20"),须同时标注文本规则和表格日期:`合同生效后10个工作日内(合同表格约定2025.3.20)`
### 🔴 H列条款来源归属精准规则(0709悦拾光教训)
H列每项数据后的条款引用必须覆盖**所有实际数据来源**,不能只写主条款号:
- 若机制出自主条款(如3.3条描述"预充值电表由甲方代收"),但具体单价/代收主体出自附件(如交房确认书第4点写明"电费0.9元/度,星展代收"),则必须写"(3.3条+交房确认书第4点)"
- 禁止简化为仅引主条款——Maggie质疑"总结的依据是哪个合同条款"时,引用不全=无法溯源=不合格
- **自检方法**:H列每个带括号引用的数据项,逐一追问"这个数字/这个主体名字,在我引用的条款原文里能不能逐字找到?"——找不到就是漏了来源
- 典型场景:水电费单价(主条款写机制+附件写单价)、物业费发票开具方(主条款约定付款+三方协议约定收款方)、保证金退还条件(合同正文+补充协议修改)
### H列四检(交付前必过——租赁行+物业行都要过)
1. 每个金额都标注了条款号出处(且覆盖全部数据来源,见上条)
2. 做了月租换算——行内括号写法(≈X元/月)
3. 列出了各期具体付款日期
4. 未付期次句首标了❗
### 🔴 H列付款推算必须对应正确费率期(世茂0708教训)
**递增租金合同中,每期推算必须使用该时段对应的费率,不能全部按第1费率计算。**
典型错误:合同有第1费率期(2025.12-2027.2)和第2费率期(2027.3-2029.2),第3/4期付款推算全部按第1费率算→少算了递增部分。
正确做法:
1. 先确定每个付款周期的时间段
2. 检查该时间段落入哪个费率期(可能跨越两个费率期)
3. 跨费率的周期须分段计算:第1费率×N月 + 第2费率×M月
4. 验算:用合同附件三载明的第2期总额反推第2费率含税金额
**附件三有明确载明总额的周期可直接用合同数字**(如"第二期保底租金...金额为人民币401,363.92元"),无需自行推算——反向用它来验证费率是否正确。
### 🔴 合同中"/"=不适用,不提及(世茂0708教训)
合同格式文件中留空或填"/"的条款(如"营业额提成:/%"、"促销服务费:/元/㎡"),意味着该项不适用于本合同。**全表各列均不应提及这些不适用条款**:
- H列不写"营业额提成比例未填"
- I列不写营业额提成取高机制
- K列不写"提成比例未填存在争议风险"
- L列不写"保底+提成取高"作为差异
判断标准:如果合同原文用"/"明确表示空白/不适用,就当它不存在。
**⚠️ 物业行H列同样必须做付款推算**(跃龙路0707审计发现遗漏):
- 物业管理费+公共能耗费=每期总额,按合同约定的结算周期(年/半年/季)逐期列出
- 格式与租金推算一致:`❗第N期(起止日):XX元 ┃ 付款期限YYYY.MM.DD`
- 首期如早于合同起始日,注明"首期随签约支付"
- 电费等按实结算的只写计费标准,不做推算
- **总额必须反映实际合同期限**(含免租期内物业费),不能简单用"年费×整年数"。如合同期为5年4个月,总额=年费÷12×64个月(通州金鹰0707纠正:原写48000×5=240000,实际应为256000)
- **末期不足半年/一年的按比例计算**,单独注明"末期N个月按比例"
### H列付款推算效率(Maggie 0703 确认)
付款推算可以直接从 G列(起止日期)+ H列已有数据(支付方式、租金标准、递增规则、物业费单价)推算,不需要每次回 md 原文重新读取。具体:
1. 从G列取起止日期+免租期
2. 从H列取租金/期、物业费/月、支付方式(半年/季/年)、提前天数
3. 按周期切分区间 → 算出每期金额+付款截止日
只有遇到数据存疑(如附件分期跟正文对不上、免租分摊逻辑不清)才需要回 md 核实。
### 🔴 H列禁止"详见合同""同上"等空壳写法(世茂0708教训)
**每份合同的H列必须完整写明具体金额和付款推算,不得用"详见合同附件约定""同上"等模糊表述代替。**
物业合同尤其容易犯此错——因为金额较小、附件结构复杂、项目多,容易偷懒写"管理费详见附件"。但客户看汇总表就是不想翻原文,必须一目了然。
物业合同H列完整标准:
- 履约保证金具体金额
- 管理费:单价×面积=月费(不含税+税金+含税合计)
- 装修押金+装修管理费(一次性)
- 促销服务费(不适用则标"不适用(/)")
- 水电费计费标准
- 支付方式+完整付款推算(按年/半年/季,逐期列出)
- 合计总额
三层/扩租合同同理——即使条款结构相同,参数(面积、单价、周期)不同就必须独立写完整数字,不能"同上"。
---
## I列:核心内容
按固定类目逐项摘录合同原文,有就写没有就不写。格式统一为 `· [类目] 原文(条款号)`
## I列:核心内容
按固定类目逐项提炼合同核心事实,有就写没有就不写。
### 租赁合同(21个固定类目)
用途/转租/装修改造/广告标识/非竞争/维修责任/保险要求/物业服务联动/配套设施/出租方变更/解除权机制/违约金机制/不可抗力/征收拆迁/房屋抵押查封/政策变化/到期处理/恢复原状/优先权/管辖/备案
### 物业合同(15个固定类目)
物业服务内容/服务标准/公共能耗费/特约服务/共用设施管理/装修管理/安保措施/消防安全/保险要求/联动终止/违约责任/退出交接/免责条款/不可抗力/管辖
### 物业合同I列补充要点(0707审计发现遗漏)
除15个固定类目外,以下条款如合同有约定也应在I列提及(属于权利义务分配的实质内容):
- **违约确认时限**:如物业合同约定"违约侵权须N日内书面告知,逾期视为放弃权利"→必须写(影响维权时效)
- **不动产转让条款**:如约定"转让不影响合同继续履行"→写入[联动终止]类目
- **服务标准引用**:如约定"参照《XX省物业服务N级标准》"→写入[服务标准]类目
- **特约服务收费**:如约定"须事先公布收费标准,按实计付"→写入[特约服务]类目
### 格式规则
- 格式:`· [类目] 关键事实(条款号)`
- 一行一个类目,精简干练,只留核心事实+条款号
- 有就写,没有就不写
- 禁止冗余修饰语和重复描述——能用一句话说清的不用两句
- 违约金属于权利义务安排,放I列(违约金机制)而不是H列
- 同模板多合同时,后续行写"条款结构与XX一致。差异:·面积 ·租期 ·保证金"即可
### 精简标准(Maggie 0703确认)
- ❌ "未经甲方书面同意,乙方对于承租房屋不得以任何形式转租、转让、转借、抵押或其他有损甲方利益的行为"
- ✅ "未经书面同意不得转租/转让/转借/抵押(4.5条)"
- 原则:摘录核心事实≠原文照抄。用最少的字传达最准确的信息。
### ⚠️ 格式陷阱(星月0706发现)
-`【类目】内容(条款号)` —— 方括号用了中文书名号,早期校区残留
- ❌ 详细展开式(每项一大段话,如"【用途】仅限办公(第二条第1款);未经甲方书面同意不得改变用途(第二条第2款)\n【转租】允许部分或全部转租……")——早期跃龙路/通州金鹰残留
-`· [类目] 内容(条款号)` —— 半角方括号+圆点前缀,一行一类目
- 重审旧表时首先检查格式是否符合规范,不符合的一并改正
### I列审计/改写流程(0707跃龙路+通州金鹰+小石桥实践)
当被要求检查已有汇总表的I列时:
1. **先对照21/15类目清单做覆盖检查**——逐个类目看是否已在I列中出现
2. **判断"合同无此条款"vs"合同有但I列遗漏"**——回到合同md原文确认
3. **如果格式不是`· [类目]`式→整列改写**,不是在旧格式末尾追加。全部改为精简类目式一次到位
4. **改写后覆盖率自检**:租赁≥15/21,物业≥10/15(合同无相关条款的不计入分母)
5. **删除非核心条款**:合同份数、现场负责人、反舞弊等(见下方"常见应删除项")
### 汇总表审计完整流程(Maggie 0707 指令模式)
当Maggie说"检查下XX校区的汇总表"时,执行以下标准审计:
### 🔴 I列统筹提炼规则(2026-07-13 金飞达纠正)
**若同一租赁物没有单独物业合同,而物业管理/公共能耗/消防/装修管理/共用设施/安保等内容已经写进租赁合同正文,那么I列不能只按“租赁合同21类”机械提取。** 必须把该租赁物对应文件中的:
- 租赁合同21类要点
- 物业合同15类要点(凡已内嵌在租赁合同正文、附件、补充协议里的)
**一起统筹提炼**
也就是说,判断标准不是“有没有单独物业合同文件”,而是:
> **该租赁物的全部核心权利义务里,是否已经把物业类安排写进租赁合同。**
典型应一并纳入I列的内嵌物业要素:
- 物业运营管理费/物业费的构成与支付
- 公共能耗费、中央空调费、电梯电费、水电费调整机制
- 共用设施管理、公共区域布局调整、营业时间管理
- 装修管理、装修押金、装修管理费、装修验收
- 安保措施、经营管理公约、消防责任书
- 退出交接、物业随租赁联动终止
- 免责条款中与水电中断、设施故障、公共区域管理相关内容
**金飞达案例(2026-07-13)**:没有单独物业合同,但主租赁合同/扩租合同/499合同中已写入物业运营管理费、中央空调费、公共区域管理、经营管理公约、消防责任书、电梯电费、停车位等内容。此时I列应按“租赁+物业混合文本”统筹提炼,而不是误判为“缺物业合同所以只做21类”。
**Step 1: 拉取文件**
- docker cp 从Nextcloud取xlsx + 合同md文件到/tmp/
**Step 2: H列审计**
- 检查是否混入非费用项(违约金/滞纳金/供电功率→应删除)
- 检查物业费是否有逐期付款推算(没有→补充)
- 检查租金付款推算日期是否与合同表格吻合(不吻合→修正描述+K列补充矛盾)
- 检查总额计算是否反映实际合同期限(如5年4个月≠5年)
**Step 3: I列审计**
- 对照21/15类目清单做覆盖率检查
- 判断格式是否为精简类目式(不是→整列改写)
- 补充遗漏类目 + 删除非核心条款
**Step 4: K列审计**
- 逐项回到合同原文核验每条风险是否有据
- 检查是否遗漏明显风险(签约主体错配、合同内部矛盾、支付方向瑕疵等)
- 验证提前退租分析的金额计算
**Step 5: 上传**
- docker cp回Nextcloud + chown + files:scan
**报告格式**:按校区输出结构化检查报告,列明"问题→处理"表格。
### 非固定类目内容的处理(星月0706实践)
旧表中可能存在不属于21/15个固定类目的条目(如"保证金退还""发票""工商迁入""安全责任""甲方维修"等)。修订时处理原则:
- **可归入固定类目的→合并**:甲方维修→并入[维修责任];保证金退还条件→并入[恢复原状];甲方违约→并入[解除权机制]
- **纯信息性/不影响权利义务分配的→删除**:发票开具要求、工商迁入时限、安全第一责任人声明等
- **判断标准**:该信息是否直接影响乙方的权利行使或义务负担?是→找最近的固定类目归入;否→不写
- **格式统一**:旧表若用`【类目】`格式,修订时一律改为`· [类目]`
#### 常见应删除项(小石桥0707补充)
以下内容不属于核心权利义务,不应出现在I列:
- ❌ 合同份数("一式四份各执两份")
- ❌ 现场负责人姓名/电话
- ❌ 反舞弊/举报条款(程序性合规条款)
- ❌ 开票信息(户名/税号/账号)
- ❌ 签字盖章生效条款
- ❌ 补充协议效力条款
这些属于合同程序性/管理性条款,不影响乙方实质权利义务的行使或负担。
### 通读强制机制(悦拾光0703教训)
Step2动作A法律审查必须用read_file从第1行读到最后一行(分批500行/次),不得用grep抽查代替通读。
自检标准:I列类目覆盖数≥阈值(租赁15/21,物业10/15)。
建表后必跑 `scripts/i-column-coverage-check.py` 验证覆盖,不够就核查确认。
> **同模板简写行的⚠️警告是正常的**(桃坞路0703确认):使用"条款结构与XX一致。差异:…"简写的行(如扩租行)会因类目标记少而触发低覆盖警告——这不是遗漏,是规则允许的简写。只需确认首份详细行已达到21/21或15/15即可。
---
---
## J列:当前状态
按实际日期判断:
- 合同期限已过 → "已到期"
- 未到期且正在履行 → "履行中"
- 合同尚未开始(起租日在未来)→ "未开始履行"
不能不看日期直接写"履行中"。
---
## K列:法律风险(站乙方立场)
### 写什么
- 整体评价(简要概括合同对乙方保护程度)
- 需注意的风险点(标条款号,说清楚对乙方的实际影响)
- 提前退租法律后果分析
### 不写什么
- 对乙方有利的条款(对客户无实际意义)
- 续约建议(只放第三部分整体段)
- 引用模版做对比(那是L列的事)
### K列证据纪律(Maggie 0703 纠正)
K列所有判断必须有合同文本直接支撑,不得超出文本能证明的范围做推论。
### K列"用途与实际经营不一致"风险模式(龙信0708)
当合同约定的租赁用途很具体且看上去可能与实际经营不一致时(如约定"新东方学习机"但校区实际做教育培训),K列应提示此风险:
- 引用条款链:用途限定条款(如第二条)+ 禁止改变用途条款(如4.8条)+ 甲方解除条款(如6.2(4)"擅自改变用途")
- 建议:确认甲方是否知情并认可实际经营内容
- 注意:此判断属于**K列**(风险评价),不属于I列(I列只客观摘录约定用途是什么)
### K列"合同内部矛盾"风险模式(0707实践总结)
合同内部条款之间的矛盾是独立的K列风险项。常见模式:
1. **付款规则矛盾**:文本一般规则 vs 表格具体约定(如"15天前"vs表格"1个月前")→对方可择利援引
2. **数值矛盾**:正文 vs 附加条款(如供电"120千瓦"vs"65千瓦")→补充条款效力条款决定优先
3. **甲乙方方向矛盾**:条文写"乙方支付至甲方账户"但收款账户是乙方自己的→模版套用瑕疵
4. **签约主体矛盾**:合同载明乙方名称与实际盖章公章不一致→可能影响效力认定
写法统一为:`N. XX条款矛盾/不一致(第X条):条文A写"...",但条文B/表格/盖章为"..."。[实务判断]。`
**典型错误**
- 合同中物业方联系人与出租方为同一人→直接写"实为关联方""X控制的Y公司"
- 从联系人身份推断股东/法定代表人/实际控制人身份
**正确写法**
- "出租方(范存益)同时为物业方(东德物业)的联系人,电话地址一致,两者**可能**存在关联关系,物业服务质量纠纷时需注意利益一致性。"
- Maggie 0703 审核确认此措辞:提示了关联可能性,但不做无依据的确定性推断
规则:
- "联系人"≠ 股东/法定代表人/实际控制人,不能从联系人身份推断控制关系
- 要证明关联关系需要工商信息,合同文本只能支撑"可能存在关联"
- 用语梯度:合同直接写明→"为";同名同电话同地址→"可能存在关联关系";纯推测→不写
- 同样适用于其他推断(如"实际经营培训"——合同写"办公"就只能说"需确认是否一致")
### 提前退租分析规则
- 合同有明确约定的(通知期+违约金+结算方式),**写约定内容+展开分析"其他损失"**。
- 合同没有明确约定的,回到法律规定做三层分析:
①有无法定解除权(民法典563条)
→ ②无法定事由则单方退租=违约(584条赔偿可预见损失)
→ ③损失构成
- 两种情况都要有依据,不写"甲方可能会主张""建议协商规避"这类没有锚点的推测。
#### 写作风格(Maggie 0708 纠正)
- **语言简单明了,只给条款号,不引用合同原文全文**
- ❌ 错误:引用完整合同原文再做分析("第五条3款:'租赁期间,乙方如需提前解租的,应当提前3个月书面告知甲方。在此情况下……'")
- ✅ 正确:直接写结论+条款号("规范提前解约(第五条3款):提前3个月书面告知 + 没收保证金56,667元 + 30%违约金102,000元,合计约158,667元。")
- 原则:读者是律师,不需要被教条文写了什么,只需要知道后果是什么+出处在哪
#### 区分"规范退出"与"擅自退租"(龙信0708实践)
同一份合同往往有两条退出路径,法律后果不同,必须分别列明:
- **规范提前解约条款**(如5.3条):乙方主动通知+约定违约金→后果确定,有上限
- **擅自退租/违约解除条款**(如4.15条):未经同意中途退出→后果更重,可能无上限("不足弥补损失另行赔偿")
两条不能混写。如果只写5.3条却把4.15条"不足弥补另赔""退还已预付未使用"的后果也算进去,属于张冠李戴。
**写法示例(龙信0708确认格式)**
```
【提前退租法律后果】
一、规范提前解约(第五条3款):提前3个月书面告知 + 没收保证金56,667元 + 30%违约金102,000元,合计约158,667元。
二、擅自退租(第四条15款):甲方可解除 + 没收保证金 + 30%违约金 + 不足弥补损失另行赔偿(无上限)+ 退还已预付未使用租金。
注:5.3条为规范退出,后果封顶;4.15条为擅自退租,后果更重且无上限。
```
#### "其他损失"必须展开分析(Maggie 0706 纠正)
合同写"给守约方造成其他损失,违约方还应进行相应赔偿"或"违约金不足弥补损失的据实赔偿"时,**不能只写违约金数字就停**,必须展开分析甲方可主张的损失范围:
**标准分析结构**
1. 合同约定路径(正常退出):通知期+同意+违约金
2. 甲方可主张的其他损失(逐项列举+估算金额):
- 空置期租金损失(重新招租合理期间,通常3-6个月)
- 重新招商费用(中介佣金、广告支出)
- 免租期租金追溯(如合同有此条款)
- 恢复原状/装修修复费用
- 逾期搬离违约金(如合同约定日租金倍数)
3. 最大风险敞口估算(各项相加=总数字)
4. 擅自退出后果(未获同意时的路径:没收保证金+据实索赔)
**必须有具体金额**——用合同约定的租金标准×期限算出每项的元数。"约XX元"即可,不求精确到分。
#### 同模板多合同须分别分析(Maggie 0706 指出)
同一甲方制式合同、条款结构一致但参数不同(免租期、租金、面积)时,**提前退租法律后果必须逐份单独计算**,不能写"同上"。原因:
- 免租期不同→追溯金额可能翻倍(如90天 vs 6个月)
- 租金基数不同→赔偿金和空置期损失金额不同
- 递增比例不同→后期退出的风险敞口差异更大
写法:1楼K列单独列出完整的提前退租分析(含数字),末尾加"⚠️与2楼对比"说明差异原因。
---
## L列:与07标准模版差异
- **原则**:纯客观描述文本差异,不做风险判断
- **格式**:模版写什么→本合同写什么→差异在哪
- **禁止出现的词**:风险、建议、不利、详见
- **合同原文含禁用词的处理(桃坞路0703教训)**:合同原文本身可能使用"风险"等禁用词(如"经营风险由乙方承担")。L列不可直接引用含禁用词的原文,需改写为客观事实描述。
-`证照风险全归乙方`("风险"触发kl-separation-check)
-`能否获得许可属乙方经营事项,甲方不承担`(客观描述分配结果,不含禁用词)
- 原则:用"甲方不承担/由乙方自行解决/乙方经营事项"等客观描述替代含"风险"的原文引用
- **来源**:必须从子任务生成的比对文件里逐条摘取
- **适用范围(Maggie 0703 纠正)**:**所有租赁合同都必须与07标准模版做对比,不论是否为新东方制式。** 跃龙路(甲方制式)做了55条差异,龙信(甲方制式)做了50条——正因为不是新东方制式,差异才更大、客户才更需要知道缺少了哪些保护。
- **非07制式合同**:L列写明"本合同为XX制式(非新东方制式),与07标准模版差异极大。逐条对比如下:",然后**全部差异逐条列出**。仅物业合同/补充协议可以不做07比对。
### 🔴 L列差异数量诚实原则(龙信0708教训)
**写了"N项差异"就必须列出全部N项,不能只列"主要差异"十几项。**
- ❌ "与07标准模版差异极大(40项差异)。主要差异:1.…2.…(只列18项)"——说40项只列18项=不诚实
- ✅ "与07标准模版差异极大。逐条对比如下:1.…2.…(全部40项逐条列出)"
原因:Maggie会数。写了总数就要能对上。要么全部列出,要么不写总数——不允许"只列主要差异"这种偷工减料。
逐条列出的格式:按07模版条款顺序,分章节标题分组:
```
【第一条·租赁标的】
1. 产权查验:07→XXX;本合同→XXX
2. 供电功率:07→XXX;本合同→无此条款
【第二条·用途与转租】
3. ...
```
---
## 第三部分"整体风险分析与建议"结构
按以下顺序排列(Maggie 0701 确认):
1. **【整体评价】** 几句话概括
2. **【法律关注点】** 逐条列风险(先于退租分析)
3. **【提前退租法律后果】** 按合同约定写
4. **【续签建议】** 具体改进建议(只放这里,不放K列)
---
## 多合同校区规则
### 核心原则:每份合同独占一行(Maggie 0703 明确)
**每份独立的合同文件必须单独一行,不得合并。** 租赁合同、变更协议、物业合同——各自提取、各自审阅、各自一行。
- Maggie原话(0703):"3份合同分别提取,分别审阅,做成三行,不要混在一起写"
- ❌ 错误做法:把租赁合同和主体变更协议合成一行写(即使两者关联密切)
- ✅ 正确做法:租赁合同一行 + 变更协议一行 + 物业合同一行,各有独立的板块标题和表头
### 板块结构
每种合同类型独立一个板块("一、房屋租赁合同""二、主体变更协议""三、物业管理服务合同"),每个板块有自己的段标题行+表头行+数据行。
### 其他排序规则
- **排序**:按签约时间排序,先签的在前
- **同制式合同多份**:详细的K列、L列、I列内容放最早签约的那份行里,后续行写"同上+数据差异"
- **多租赁物**:先按租赁物分类,再按时间排列
- **板块划分听Maggie指令**:Maggie说"按租赁物分类"→板块标题=租赁物描述;说"按原租赁和扩租分类"→板块标题=业务关系。不自作主张选分类方式。
### 多租赁物表结构(Maggie指示"按租赁物分类"时)
板块划分从"按合同类型"变为"按租赁物"。每个租赁物独立板块,内部租赁在前物业在后。
**世茂校区实例(2026-07-03验证通过)**
- 一、二层商铺(商铺编号XXX,968.28㎡)→ 租赁合同 + 物业合同
- 二、三层3023号商铺(347.2㎡)→ 租赁合同 + 物业合同
- 三、校区整体风险分析与建议
**桃坞路校区实例(2026-07-03验证通过,按租赁物分类+时间排序)**
- 一、桃坞服饰城中区201室、中区202室、C区部分房屋(889㎡)→ 租赁合同 + 物业合同
- 二、桃坞服饰城C区二层C-818室部分(80.21㎡)→ 租赁合同 + 物业合同
- 三、校区整体风险分析与建议
> 特征:同一出租方(国企制式)、同一模板,扩租行使用"条款结构与XX一致。差异:…"简写。
> KL分离检查:扩租行K列只补充额外风险,不重复原租赁K列已写内容。
### 按业务关系分类(Maggie指示"按原租赁/扩租分类"时)
板块划分从"物理租赁物"变为"业务关系"。
**悦拾光校区实例(2026-07-03验证通过)**
- 一、原租赁(C204、C205、C206)→ 租赁合同 + 能耗费三方协议
- 二、扩租(C213、C214、C217、C218、C219、C220)→ 租赁合同 + 能耗费三方协议
- 三、校区整体风险分析与建议
### 辅助协议行处理(能耗费/收款变更/补充协议等)
非租赁、非物业的辅助协议(如公共能耗费三方协议、收款账户变更协议):
- 独立一行,不与主合同合并
- K列:简洁写法("收款账户变更协议,风险低。关注点:N."),不用【整体评价·...】框架
- L列:`XX协议,无对应07标准模版。`(不做07比对)
- I列:协议性质+核心约定(如甲方单方解除权等)
- H列:写实质费用变更内容(如收款账户信息)
---
## 文件纪律(Maggie 0703 纠正)
### OCR文本保存规则(Maggie 0703 指令)
**提取并校对好的OCR文本(.md文件)必须保存在同一文件夹下**——即与源PDF同目录。
- 原租赁合同.pdf → 同目录下保存 原租赁合同_全文.md
- 扩租/扩租合同.pdf → 扩租/ 目录下保存 扩租合同_全文.md
- 上传到Nextcloud时用 `docker cp` + `files:scan`,与汇总表一起保存
这些.md文件是审查的工作底稿,客户可对照PDF原件核对。
### 旧版保留规则
**旧版汇总表不得擅自删除/覆盖。** 新版文件命名带新日期(如 `-MJ-20260703.xlsx`),与旧版共存。
规则:
- 生成新汇总表时,保留所有旧版文件原位不动
- 不需要用户明确指示"保留旧表"——默认就是保留
- 用户没有说删除的东西,一律不删
- Maggie原话:"没有让做的事情不要自己擅自进行"
这条规则同样适用于:md文件、pdf原件、任何已存在于Nextcloud的文件。只有cleanup cron和Doro明确说"pass"才允许删除。
---
## 不创设原则(Maggie 2026-07-03 明确)
严格按 workflow 和既定规则执行:
- 不发明新规则、不加额外步骤、不自创标准
- 不擅自做未被指示的操作(如删旧表、改文件名规则、自创格式约定等)
- Maggie原话:"严格按照Workflow和既定规则,不要创设哈"
- Maggie原话:"没有让做的事情不要自己擅自进行"
这包括但不限于:
- 不自行决定清理/删除/覆盖旧文件
- 不自行增加workflow中没有的检查步骤
- 不基于推断创设新规则(如从联系人推断控制关系)
- 不在K列创设合同文本不支撑的判断
---
## OCR铁律(凤凰文化0701 + 悦拾光0703 + 桃坞路0713教训)
**所有OCR文本必须全文vision逐页校对,不只是"关键数据"。**
### 🔴 全文准确性要求(桃坞路0713·Maggie纠正)
**Maggie原话**:「需要核实的不只是核心数据,所有的文字都要准确。比如租赁合同第二条的租赁用途是商业,不是教育培训,你这次有看到么?」
**问题模式**:只核对数字(金额、面积、日期)正确就认为"md准确度没问题",忽略文字内容。OCR会把关键文字变乱码而数字正确。
**典型失败案例**
- 租赁用途"商业"→`Bik`(乱码),数字全对但用途丢失
- 大写金额全错:`会 万武任 硅 佰 建 拾 制 元 伍 角` →实为"叁万贰仟肆佰肆拾捌元伍角"
- "崇川区"→"喧川区","中区201室"→"服4区201"
- 物业费空白→OCR瞎猜成"泣_/元/嘿月"
**正确做法**:逐页vision全文识别→重写md为完整准确版本。每页都要过vision,包括"标准条款"页面。详见`ocr-and-documents` skill的"Vision Full-Text Extraction Workflow"节。
**大批量校区(如金飞达88页)**:分session处理(每session 20-30页),图片预先全部转好(`pdftoppm -png -r 200`),新session无缝衔接。避免context degradation导致后半段质量下降。
不是"看哪里乱就修哪里"——用脚本扫全文自动标红,强制逐条过一遍。
跳过任何一处乱码就可能整段关键条款丢失(凤凰文化10.2:整段"初年年租金20%违约金"丢失→审查结论反转→返工)。
### 🔴 grep抽查 ≠ 逐字通读(悦拾光0703教训·Maggie纠正)
**Maggie原话**:「不只是关键条款,逐字逐句都要核准校对」「准确严谨是一切工作的基础」
**栽点**:OCR断行严重时,一个完整句子散落在3-5行,grep只能命中片段无法还原完整语义。
**典型失败案例**
- 30.3条"每逾期一日甲方有权按拖\n欠金额【3】%\n的标准...逾期支付\n超过【7】日的,甲方有权停止...水、\n电、燃气供应"→grep"停水"零结果→错写"逾期30日停水电"(30日是解除门槛不是停供门槛)
- 第36条"可向该房屋所在地人民法院\n起\n诉"→grep"管辖"零结果→错写"合同未明确约定管辖法院"
**铁律**
1. **必须线性逐页read_file全文**——不能用grep替代通读,grep只是辅助定位
2. **grep无结果 ≠ 合同无约定**——可能是OCR用了不同字词("法院"不含"管辖"、"停止供应"不含"停水")
3. **关键条款必须两份合同交叉核实**——同模版的原租赁和扩租OCR质量不同,一份断行严重的另一份可能完整
4. **争议解决/管辖条款通常在合同末尾(第35-37条区域)**——必须显式确认到具体法院,不可因grep零结果就写"未约定"
5. **违约金条款常含多个阈值**(逾期N日=停供、逾期M日=解除、逾期K日=违约金起算)——必须完整读完整条,区分不同阈值对应的不同后果
---
## 执行诚实性纪律(龙信0703教训)
`references/execution-honesty-0703.md`。核心:
- "看起来差不多/跟上个校区一样/只有2个月风险有限"不是跳过审查的理由
- 每份合同必须独立做八维框架审查(①主体 ②标的 ③期限 ④租金 ⑤违约 ⑥维修 ⑦转租 ⑧争议),不能从另一份合同复制结论
- L列模版比对适用于**所有租赁合同**,不分制式。仅物业合同/补充协议可以不比对
- 速度不是质量的对手——宁可告知"工作量大需要更多时间",不偷偷跳过假装做完
@@ -0,0 +1,50 @@
# Excel汇总表12列结构说明
> ✅ **权威口径(Maggie 2026-06-23 裁定):校区详情 sheet = 12 列,含独立 L 列「与标准模版差异」。** 模版差异(动作B,回 07 原件比对)放 L 列,法律风险(动作A)放 K 列,两者**物理分列**,绝不并入。本文件与 SKILL.md Step3「12列标准结构」、第363行、`independent-legal-review-framework.md`「分列」为同一口径,互为引用。
> 此前一度出现的「11 列、模版差异并入 K 列」旧表述已全部作废(SKILL.md 正文两处、原 Pitfall 19 已于 2026-06-23 同步更正)。
## 列定义
| 列 | 字段 | 宽度 | 内容说明 |
|---|---|---|---|
| A | 序号 | 5 | 校区内连续编号 |
| B | 文件名称 | 24 | 完整PDF文件名 |
| C | 合同类型 | 14 | 如"房屋租赁合同(主合同)""补充协议(租金减免)""物业管理协议" |
| D | 合同当事人 | 26 | 甲方/乙方/丙方全称,换行分隔 |
| E | 租赁标的/服务范围 | 22 | 具体房间号或服务范围 |
| F | 面积(㎡) | 10 | 数值+说明(如"622\n(建筑面积)") |
| G | 合同期限 | 20 | 总期限+起止日期+免租期 |
| H | 金额/费用 | 20 | 租金/物业费/押金明细 |
| I | 核心内容 | 40 | 关键商业和法律条款摘要(不放基础设施规格) |
| J | 当前状态 | 10 | "履行中""已履行""已执行" |
| K | 风险点/备注 | 40 | 风险提示 + 【合同变更与提前解除】小节 |
| L | 与标准模版差异 | 40 | 租赁合同对比结果;物业/补充协议标注"无对应标准模版" |
## 格式规范
### 字体
- 标题行(Row 1): 微软雅黑 14pt 加粗,居中
- 基本信息行(Row 2): 微软雅黑 10pt,左对齐
- 分类标题: 微软雅黑 11pt 加粗,左对齐,底色D6E4F0
- 列标题: 微软雅黑 10pt 加粗,居中,底色E2EFDA
- 数据行: 微软雅黑 10pt,左对齐上对齐,自动换行
### 行高估算
- 标题/分类标题: 30pt
- 列标题: 30pt
- 数据行: 根据内容,K/L列内容多的需要300-400pt
- 风险分析区: 600-750pt(合并单元格必须手动设高度)
### K列【合同变更与提前解除】标准内容
1. 提前解约通知期和违约金
2. 押金退还条件
3. 甲方终止时的赔偿义务
4. 免责解除通道
5. 部分退租先例(如有)
6. 合同变更方式
### L列差异标注分级
- ⚠️ 开头 = 多处重大偏离
- ✅ 开头 = 高度一致
- 编号列举具体差异点
- 物业/补充协议: "物业服务协议,无对应标准模版" 或 "补充协议,非模版对比范围"
@@ -0,0 +1,72 @@
# 统一社会信用代码 OCR 核实配方
## 适用场景
合同首页甲乙方统一社会信用代码 OCR 乱码(如 "MA INAEBX26" 应为 "MA1NAEBX26"),需要核实正确值。
## 核实步骤
### 1. tesseract 重读首页
```bash
# 渲染首页 200 DPI
python3 -c "
import fitz
doc = fitz.open('合同.pdf')
page = doc[0]
mat = fitz.Matrix(200/72, 200/72)
pix = page.get_pixmap(matrix=mat)
pix.save('page1.png')
"
# 裁剪公司名+信用代码区域(通常页面 15-30% 高度)
python3 -c "
from PIL import Image
img = Image.open('page1.png')
w, h = img.size
crop = img.crop((0, int(h*0.15), w, int(h*0.30)))
crop.save('page1_credit.jpg', 'JPEG', quality=75)
"
# tesseract 读取
tesseract page1_credit.jpg stdout -l chi_sim+eng --psm 6
```
### 2. 企查查 web 搜索交叉验证
```
web_search: "公司全称" 统一社会信用代码 企查查
```
企查查结果通常直接显示完整 18 位信用代码。
### 3. 校验位验证(Python)
统一社会信用代码第 18 位是校验位,可用以下脚本验证:
```python
def verify_credit_code(code):
if len(code) != 18:
return False
weights = [1,3,9,27,19,26,16,17,20,29,25,13,8,24,10,30,28]
chars = '0123456789ABCDEFGHJKLMNPQRTUWXY'
code_map = {c:i for i,c in enumerate(chars)}
total = sum(code_map[code[i]] * weights[i] for i in range(17))
check = 31 - (total % 31)
if check == 31: check = 0
expected = chars[check] if check < len(chars) else '?'
return expected == code[17], expected, code[17]
```
## OCR 常见误识模式
- `MA1NAEBX26``MA INAEBX26`(数字0被识别为空格)
- `MADQRFT66P``MADQRFT66P`(通常正确,但需验证末位校验位)
- 数字 `0` 和字母 `O` 混淆
- 数字 `1` 和字母 `I` 混淆
## 判据
- tesseract 重读结果与企查查一致 → 采用
- tesseract 重读结果与企查查不一致 → 以企查查为准(企查查是权威工商数据源)
- 校验位验证不通过 → OCR 有误,回企查查取正确值
## 与 OCR 完整性检查脚本的关系
`ocr-integrity-check.py` 已修复:信用代码中的字母序列(前后有数字的)不再误报为"公司名乱码"。修复方式:检测字母序列前后是否为数字,如是则跳过。
## 2026-06-29 实证
- 解放中路:甲方 "91320600MA1NAEBX26"(企查查确认)
- 通大附:甲方 "91320600MA1NAEBX26"(企查查确认)
- 通大附:乙方 "91320602MADQRFT66P"(校验位 P 验证通过)
@@ -0,0 +1,45 @@
# DeepSeek-OCR 主链路与非交互 session 取 key 规则(2026-07-13)
## 适用场景
- 批量扫描件合同梳理
- 需要把 PDF / 图片先转成 Markdown 再审查/比对/回填汇总表
- 当前环境已把 `SILICONFLOW_API_KEY` 写在 `~/.bashrc`,但 agent 所在 session 不是交互式 shell
## 本次会话固化结论
1. **默认 OCR 主链路改为 DeepSeek-OCR**,脚本:`~/.hermes/scripts/deepseek_ocr.py`
2. **不要默认回到 marker-pdf / surya 本地链路**;本地 OCR 只作显式要求下的备选/对照,不是默认方案。
3. 批量扫描件建议流程:
- 先批量跑 `deepseek_ocr.py` 生成 md
- 再做 grep / 规则扫描,筛出乱码、过短、金额大小写冲突、主体名称异常、日期异常
- 最后只对异常页做 vision 定点校对
4. `deepseek_ocr.py` 已修成:
- 先读当前进程环境变量 `SILICONFLOW_API_KEY`
- 若当前 session 没带 key,则回退到 `bash -ic``~/.bashrc` 读取
- 读取成功后回填到当前进程环境
- 两边都没有才报错:`Missing SILICONFLOW_API_KEY in environment or ~/.bashrc`
## 为什么要这样做
非交互 shell 默认不加载 `~/.bashrc`。如果只在脚本里 `os.environ.get("SILICONFLOW_API_KEY")`,则新 session/批量 terminal 调用会误报“缺 key”,但实际上 `.bashrc` 里已经有 key。
## 执行口径
- 单文件:
```bash
python3 ~/.hermes/scripts/deepseek_ocr.py /path/to/file.pdf -o /path/to/file.md
```
- 批量:按合同目录遍历 `.pdf`,逐个输出同名 `.md`
- 批量产物出来后,优先筛这几类异常:
- 结果过短(只剩标题/前几行)
- 金额小写与大写不一致
- 主体名称错字/缺字
- 日期区间逻辑不通
- 银行账户等长数字串
## 已验证案例
- 北翼玖玖 17 份 PDF:已全量跑完 DeepSeek-OCR 输出 md
- 发现的典型异常:
- 某些物业合同只识别出标题,正文漏识别
- 押金小写金额与大写金额明显冲突
- 主体名称 OCR 错字(如“崇区川”)
## 给后续 agent 的一句话
**扫描件合同默认先跑 DeepSeek-OCR,再筛异常,再 vision 定点校对;不要一上来就切回本地 OCR。**
@@ -0,0 +1,64 @@
# DeepSeek OCR 默认主链路(2026-07-13)
## 结论
当任务目标是**把扫描件 / PDF 稳定识别出来**,默认主链路使用:
- `~/.hermes/scripts/deepseek_ocr.py`
- provider / API: **SiliconFlow**
- model: `deepseek-ai/DeepSeek-OCR`
- env var: `SILICONFLOW_API_KEY`
不默认回退到本地 `marker-pdf / surya`。只有在以下情形才改走本地链路:
1. 用户明确要求离线 / 本地 OCR;
2. DeepSeek OCR 存在不可回避的外部约束(如目标环境无法提供 API key 或必须完全脱网);
3. 任务本身不是“先把内容识别出来”,而是明确在做本地 OCR 工具验证 / 对比实验。
## 本次会话得到的稳定经验
### 1. 先定路线,再谈环境
本次一开始把“当前 Python 环境被 marker-pdf 安装污染”讲得太重,容易把讨论带到本地 OCR 包冲突上;但用户真正要的是:
> 能用 DeepSeek OCR 就直接用,不要默认折回本地方案。
所以遇到 OCR 任务,先回答:
- 目标是**稳定抽取内容**,还是**验证本地 OCR 工具**?
- 如果是前者,先走 DeepSeek OCR 主链路。
### 2. 验证必须是真跑,不是口头判断
本次实际验证命令:
```bash
bash -ic 'python3 ~/.hermes/scripts/deepseek_ocr.py "/home/maggie/contract-review/其他任务/ht_page-1.png" -o /tmp/deepseek_ocr_final.md'
```
成功返回:
- 输入/输出 token 计数
- Markdown 文件落地
- 读回输出文件后可见 OCR 表格内容
因此以后汇报“可用”时,必须至少给出:
1. 实际命令;
2. 实际返回;
3. 输出文件或结果句柄。
### 3. `.bashrc` 中的 key 只对交互式 bash 自动生效
本次确认:
- `SILICONFLOW_API_KEY` 写在 `~/.bashrc`
- 非交互 shell 直接执行时,脚本可能读不到该变量
- `bash -ic '...'` 能加载 `.bashrc`,因此验证通过
因此如果 workflow / 子进程依赖这条 OCR 链路,必须显式保证环境变量可见;不要因为一次 `bash -c` 读不到 key,就误判 DeepSeek OCR 不可用。
### 4. 脚本不得偷偷依赖硬编码 fallback key
本次还暴露出:`deepseek_ocr.py` 曾带硬编码 fallback key。正确做法是:
- 只从 `SILICONFLOW_API_KEY` 读取;
- 没有 key 就直接报错退出;
- 避免“看起来可用,其实在吃脚本里藏着的 key”这种假成功。
## 汇报口径
当用户指定“以后都用这个方案”时,回复应直接、简洁:
- 已切换默认 OCR 方案为 DeepSeek OCR;
- 已确认环境变量位置;
- 已实际跑通;
- 本地 marker/surya 不再作为默认主方案。
不要再把话题绕回“当前主对话模型是不是 DeepSeek”。聊天模型链路 ≠ OCR 链路。
@@ -0,0 +1,31 @@
# 帝奥地产格式合同 — 与07模版核心差异速查(金飞达实证 2026-06-27)
> **适用校区**:金飞达(所有合同均使用帝奥地产格式,与07模版结构完全不同)
> **比对基准**:07-房屋租赁合同.docx(15条+附加条款)
> **帝奥格式**:20条,结构完全重构,偏向保护出租方
## 缺失的07模版核心条款(帝奥格式均无)
1. **第七条 出租方变更**:无通知义务,无新业主继续有效
2. **第九条 优先购买权**:乙方明确放弃(第11.2条)
3. **第十一条 不可抗力**:无疫情/行业治理/政策变更减免权
4. **第十二条第4款 办学许可证**:无无法办证退出机制
5. **第十四条第4款 租赁备案义务**:无
6. **第十四条第5款 竞业限制**:无
7. **附加条款**:无装修配合/标识广告/配套设备
## 帝奥格式特有不利条款
| 条款 | 内容 | 风险等级 |
|---|---|---|
| 第10条 房屋返还 | 恢复原状/逾期7日视为放弃物品/停水停电强制措施 | 🔴 |
| 第11.2条 | 乙方放弃优先购买权 | 🔴 |
| 第11.3条 | 甲方转让后不再承担任何责任 | 🔴 |
| 第14条 免责 | 甲方全面免责(自然灾害/盗窃/设施故障/水电中断) | 🔴 |
| 第13.5条 逾期违约金 | 每日万分之五(07模版0.1‰的5倍) | 🟡 |
| 第13.2条 营业执照注销 | 每日月租金10% | 🔴 |
| 第19.5.1条 租金保密 | 泄密恢复原价(主合同120万/年,现价3.1倍) | 🔴 |
## 合同捆绑关系
金飞达777+633+499三份合同通过补充协议形成连锁捆绑:任一份出问题牵连全部。
@@ -0,0 +1,35 @@
# 执行诚实性纪律(龙信 2026-07-03 教训)
## 起因
Maggie 发现龙信三份合同的首次梳理没有按 workflow 逐字逐句执行,直接质问"你是自己逐字逐句审阅的么?",要求重做。
## 教训
1. **"看起来差不多/跟上个校区一样/只有2个月风险有限"不是跳过审查的理由**
- 海门临时合同虽仅2个月且已到期,但仍需完整做八维框架审查
- 每份合同独立做,不能从另一份合同复制结论
2. **L列模版比对适用于所有租赁合同,不分制式**
- 龙信广场(甲方制式)→做了40条差异
- 海门临时(甲方制式)→做了32条差异
- 正因为不是新东方制式,差异才更大,客户才更需要知道
3. **速度不是质量的对手**
- 宁可告知"工作量大需要更多时间",不偷偷跳过假装做完
- Maggie原话:"你不按照规则作出的东西很明显质量不行的,务必按照Workflow和规则执行,不能自己发挥"
## 行排序规则补充
默认按签约时间排序(column-rules-0701.md),但 **Maggie明确指定行顺序时以指定为准**
- 龙信0703:Maggie指定"租赁和物业放前两行,海门临时放第三行"→覆盖默认排序
- 原则:用户明确指令 > 默认规则
## K列"模版"禁词
kl-separation-check.py 对 K列 grep "模版|07模版" 是0容忍——即使上下文是"出租方制式模版"这种描述性用法也会触发。
**解法**:K列一律用"制式合同"或"制式文本"替代"制式模版"。
- ❌ "本合同为甲方制式模版"
- ✅ "本合同为甲方制式合同"
- ✅ "条款与龙信广场租赁合同几乎一致(出自同一出租方制式文本)"
@@ -0,0 +1,29 @@
# 文件盘点与归类规则(校区/项目第一次出现时建立)
> Maggie 2026-06-16 确立。**第一次接触某校区(或某新主体),先把文件家底摸清、按规则排好;此后一切变动都照此归位。** 这是台账可追溯的地基。
## 第一步 · 盘点
看校区文件夹里**有哪些文件、如何排列**,逐一核实确认。不要急着分析——先确认清单完整、文件可读(OCR 是否成功、有无缺失主合同/缺页)。空目录或缺失主合同要标注(如悦拾光缺一期主合同、跃龙路/通州金鹰缺租赁合同只有物业合同)。
## 第二步 · 按固定规则归类排序
```
校区文件夹
├─ 一、租赁合同
│ ① 按【租赁场所 / 位置】分类(如 原租/4幢、扩租/507室、不同铺位)
│ ② 每个场所内 按【签约时间】排序:
│ 主合同 → 补充协议1 → 补充协议2 → 变更协议 → 解除协议
└─ 二、物业合同(归类规则与租赁合同【完全相同】)
① 按【场所 / 位置】分类 ② 每个场所内按【签约时间】排序
```
> ⚠️ **物业合同的归类规则与租赁合同一模一样**(场所→时间),**不是"简化归类"**。
> **物业合同同样逐条审查、不挑不跳**(与 SKILL.md「每份合同逐条审查」铁律一致)——物业合同的特点只是**风险点天然少、且无对应标准模版**(L列标注"物业服务协议,无对应标准模版"),**绝不等于"简化审查"或"挑重点审"**。审查深度不打折,覆盖面不缩水;归类方式更不简化。
> **一般一份租赁合同对应一份物业合同(一对一、成对存在)**——盘点时按场所核对配对关系:每个租赁场所应有对应的物业合同,缺失的要标注(如跃龙路/通州金鹰只有物业、缺租赁合同;反之亦然)。
## ★ 铁律(贯穿全流程)
- 后续 **新签 / 变更 / 解除** 的合同,**一律按此规则归位**——不是堆到末尾,而是插进对应场所、对应时间位置。
- 规则贯穿 **生成 · 邮件 · 网盘** 全流程,前后一致。
- **不跨文件夹重新归类**:客户的文件夹结构(房租/扩租/物业)就是 sheet 的板块划分依据,即使物业合同放在"扩租"文件夹里,也归到"扩租系列"板块,不按合同性质重排(Pitfall #14,Maggie 20260609 已纠正过一次)。
## 与汇总表 sheet 板块的对应
校区 sheet 的板块("一、原租赁系列""二、扩租系列""三、物业"等)直接映射这个归类层级。盘点归类做对了,填表板块自然就对。
@@ -0,0 +1,49 @@
# 通读强制纪律(悦拾光0703教训)
## 事件
悦拾光校区审查中,Step2动作A"法律审查"实际做法是:
- grep关键词定位 → 读周围片段 → 参考旧版xlsx填补
这不是"逐字逐句综合全文理解判断"。证据:
- 第36条争议解决写得清清楚楚"该房屋所在地人民法院",但grep没命中"管辖"二字就写了"未明确约定"
- 30.3条"超7日停水停电"散在多行(OCR断行),只读到"每日3%"就停了
- 14.2条甲方维修影响使用应减租——通读时本应纳入
- 26.2/26.5/27.5/8.3/25.5/25.6等条款——如果真通读了不可能漏
## 根因
grep只能命中精确关键词。合同OCR断行严重,一个完整句子散落多行,grep无法还原语义。
旧表印象≠独立审查。参考旧表填补本质上是复制粘贴不是审查。
## 纪律(三层保障)
### 第一层:通读强制
Step2动作A必须用read_file从第1行读到最后一行(每批500行),不得用grep代替通读。
grep只用于**二次定位验证**(如确认某条款的确切行号),不用于替代阅读。
### 第二层:覆盖计数自检
I列写完后检查条款标签数:
- 租赁合同≥15/21类目(有就写没有不写,但必须逐一确认过)
- 物业合同≥10/15类目
不够就跑 `scripts/i-column-coverage-check.py` 报警核查。
### 第三层:争议解决+管辖必检
每份合同I列写完后,手动确认:
- "管辖"类目是否填写?
- 填写内容是否与合同末尾条款原文一致?
如果I列"管辖"为空或含"未约定"→必须回原文最后5页重新确认。
这是悦拾光教训的直接产物。
## Maggie原话
> "不只是关键条款,逐字逐句都要核准校对"
> "对应的法律审查和风险分析你是逐字逐句综合全文理解判断的么?"
> "准确严谨是一切工作的基础"
> "你还是要设计一个机制,让你不会偷懒"
## 自检信号
- I列条款标签数 < 合同章节数×1.5 = 没通读
- "争议解决"写错/写漏 = "逐字逐句"没做到的铁证
- K列风险点与旧版雷同但条款号缺失 = 复制旧表没独立审查

Some files were not shown because too many files have changed in this diff Show More