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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,85 @@
# Classifier甲方误判——手动修复流程
## 背景
2026-06-11,智慧医院云项目监理服务合同。甲方"上海市青浦区卫生健康事业发展中心"(名单#17)被classifier误判为"朱家角镇社区卫生服务中心"(因文件在同批朱家角合同中处理,classifier从上下文路径推断了错误的顾问单位)。
## 错误交付物
1. 修订合同中甲方名称处有不应有的批注"请确认名称是否准确"
2. 多交付了一个不属于该顾问单位的"审查意见"文件
## 手动修复步骤
### 1. 删除修订合同中的错误批注
```python
from docx import Document
from docx.oxml.ns import qn
doc = Document('delivered.docx')
# 删除comment本身(comments part)
for rel in doc.part.rels.values():
if "comments" in rel.reltype:
comments_xml = rel.target_part._element
for comment in comments_xml.findall(qn('w:comment')):
cid = comment.get(qn('w:id'))
# 只删除错误的批注(按id或内容判断)
text = ''.join(t.text for t in comment.iter(qn('w:t')) if t.text)
if '请确认名称是否准确' in text:
comments_xml.remove(comment)
# 删除文档中对应的anchor元素
body = doc.element.body
for tag in ['w:commentRangeStart', 'w:commentRangeEnd']:
for elem in body.findall('.//' + qn(tag)):
if elem.get(qn('w:id')) == target_id:
elem.getparent().remove(elem)
# 删除commentReference(在run中)
for ref in body.findall('.//' + qn('w:commentReference')):
if ref.get(qn('w:id')) == target_id:
run = ref.getparent()
run.getparent().remove(run)
doc.save('delivered_fixed.docx')
```
### 2. 删除多余的审查意见文件
如果审查意见文件不应存在(顾问单位没有审查意见的需要):
```bash
sudo docker exec nextcloud-nextcloud-1 rm "/var/www/html/data/doro/files/Doro合同审查任务/任务交付/XXX-审查意见.docx"
```
如果审查意见表格中有多余行(如"甲方名称"行):
```python
from docx import Document
from docx.oxml.ns import qn
doc = Document('opinion.docx')
table = doc.tables[0]
tbl = table._tbl
# 删除index=1的行("甲方名称"行)
tr_to_remove = tbl.findall(qn('w:tr'))[1]
tbl.remove(tr_to_remove)
doc.save('opinion_fixed.docx')
```
### 3. 重新上传 + 清缓存
```bash
# 上传修复后的文件
sudo docker cp fixed.docx nextcloud-nextcloud-1:/path/to/任务交付/filename.docx
sudo docker exec nextcloud-nextcloud-1 chown www-data:www-data /path/to/file
# scan + 清OnlyOffice缓存
sudo docker exec -u www-data nextcloud-nextcloud-1 php occ files:scan --path="doro/files/Doro合同审查任务/任务交付"
sudo docker exec nextcloud-onlyoffice-1 bash -c 'rm -rf /var/lib/onlyoffice/documentserver/App_Data/cache/files/data/*'
sudo docker restart nextcloud-onlyoffice-1
```
## Workflow根因修复(已于2026-06-11执行)
三处修改防止复发:
1. `~/.hermes/workflows/review-contract.yaml` classifier step 5: 匹配必须以合同正文主体名称为准
2. `review-contract.yaml` classifier step 7: 删除路径推断逻辑和contract_summary注入指令
3. `review-rules.md` 主体条款: reviewer独立核验,不完全信任classifier
@@ -0,0 +1,43 @@
# Contract Queue Serial Execution
## Problem
Multiple contracts arrive at once but workflow only processes one at a time (shared /tmp/contract-review/).
When auto_notify script is down, no automatic serial scheduling exists.
## Solution: run_contract_queue.sh
Location: `~/.hermes/scripts/run_contract_queue.sh`
Usage:
```bash
bash ~/.hermes/scripts/run_contract_queue.sh \
"合同1.docx" "合同2.docx" "合同3.docx"
```
Behavior:
- Cleans /tmp/contract-review/ between each contract (preserves rules/)
- Creates uwf thread + executes synchronously (not --background)
- If suspended: auto-retries once, then skips to next
- Logs to /tmp/contract_queue.log
- Reports final tally (success/fail counts)
## When to use
- auto_notify script is down and multiple contracts need processing
- Manual batch processing after catching up on a backlog
- Pair with a wrapper that polls current running thread before starting queue
## Wrapper pattern for waiting on current thread
```bash
# Poll until current thread finishes, then run queue
while true; do
STATUS=$(uwf thread show <THREAD_ID> --format json 2>&1 | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('value',{}).get('status','unknown'))")
[ "$STATUS" != "running" ] && break
sleep 30
done
exec bash ~/.hermes/scripts/run_contract_queue.sh "file1.docx" "file2.docx"
```
## 2026-06-15 context
5 contracts arrived, auto_notify was dead since June 12. Gold蛋糕 contract ran manually,
then queue script was written to handle remaining 3 (赵巷消防→练塘健康积分→徐泾维保)
after 端午节 contract finished.
@@ -0,0 +1,76 @@
# 已交付合同逐份审查方法(2026-07-13 实战)
## 触发条件
Doro说"逐一审查已交付的合同"/"告诉我有什么问题"。
## 审查前必做
1. **先读review-rules.md全文**——通用规则+顾问单位特殊规则(如朱家角要审查意见、练塘要脚注)
2. 列出全部待审查文件(按时间排序)
3. 确认哪些已pass、哪些未pass
## 每份合同审查步骤
### Step 1: 打开文件确认性质
**不要凭文件名或tracked changes数量推断文件内容。** 必须实际检查:
- `w:ins`/`w:del`数量(tracked changes)
- `word/comments.xml`是否存在(批注)
- 如果ins=0 + del=0,**不要直接说"无修改"**——可能有批注(comments.xml)
- 2026-07-13教训:v2文件ins=0/del=0被误判为"原文副本",实际有6条WB批注
### Step 2: 提取全部WB修订内容
- 遍历所有段落,列出每个WB INS/DEL的位置和文本
- 同时列出comments.xml中的所有WB批注
### Step 3: 对照审查清单逐条核查
对照review-rules.md的10项审查清单,逐条标记"✅已覆盖"/"❌缺失"/"N/A不适用"。
常见遗漏:
- 规则4保密三要素(存续+泄露赔偿+数据归属)只做了一两个
- 规则7转包连带的"连带"二字遗漏
### Step 4: 编号/numPr检查(**重点**)
**新增INS段落是否有numPr:**
1. 找到新增INS段落所在区域
2. 检查该区域的原文段落是否有numPr(自动编号)
3. 如果原文有numPr但新增INS段没有 → **编号断裂(严重问题)**
4. 2026-07-13教训:原文"十、其它事宜"下P32-P37全部有numPr(numId=4)渲染1-6编号,workflow用add_clause插入的维权费用段P39缺numPr,导致编号链断裂
**编号顺延检查:**
- 渲染接受修订版(pdftotext),数完整编号链
- 注意markup视图的"十十二"是DEL(十)+INS(十二)叠加显示,不是错误
### Step 5: 格式/字体检查
1. 运行 `wb-ins-font-verify.py`
2. **"MISSING HINT (无同段原文可比)"是已知假阳性**——整段INS段落无法比对,不算真问题
3. 真正的mismatch:INS有显式属性但同段原文run没有(或反之)
4. **原文不同段落可能有不同字体方案**(有的段explicit ascii=宋体,有的段完全无rFonts)
→ 字体修复必须per-paragraph匹配,不能全局统一
### Step 6: 新增标题格式(ind缩进)
- 对比新增标题段的ind与原文同级标题段
- 注意原文自己可能不统一(如P28用firstLine=482,其他标题用start=420)
- 按多数原文标题的格式设置新增标题
### Step 7: 同模板一致性
- 同一顾问单位的同模板合同(条款结构一致的),修订必须保持一致
- 逐条比对:编号策略、条款结构、用语措辞、天数等商业参数
### Step 8: 特殊交付物
- 朱家角:必须有【审】审查意见文档
- 练塘:必须有脚注"法律顾问修订版"
- 缺失的标记为问题
## 报告格式
```
## 【修】合同名称
**字体验证:** PASS/FAIL
**修订内容(N处):** 列举
**发现的问题:**
1. ...
2. ...
```
## 铁律
- **先看文件再下结论**——Doro原话"你看文件去,不是让你瞎说""文件打开阅读核实清楚再说"
- 不凭段落数量或tracked changes数量推断文件性质
- 编号问题不能只看XML结构,必须看渲染结果
@@ -0,0 +1,81 @@
# 已交付合同批量质检方法 (2026-07-13)
## 触发条件
Doro要求"逐一审查已交付合同的修订有哪些问题"
## 方法
### 准备
1. 读通用 review-rules.md + 对应顾问单位特殊规则——**每次都tool call读取,不凭记忆**
2. 确认哪些是今天交付的(按任务交付目录文件的mtime筛选)
3. 一份一份做,pass一份再做下一份
### numPr断裂检查(2026-07-13 消防设施检测案教训,最易遗漏)
新增WB INS段落如果插在auto-numbered序列(原文段落有numPr)中,必须有相同numPr:
- 检查原文被插入位置前后段落是否有numPr
- 如果有(如numId=4, ilvl=0),新增段落也必须有同一numPr
- `add_clause`默认strip numPr(A类设计),在B类自动编号段落中会导致编号断裂
- **修法**:手动用zipfile+lxml克隆邻近段落的pPr(含numPr)给新增段落
### 独立段落 vs 合并到现有条款的判断
当新增内容插在有手动编号(1、2、3、)的章节中时:
- 新内容与前一条**主题紧密相关**(如数据归属+保密)→ 合并到前一条末尾,不独立编号
- 新内容是**独立主题**→ 编号为下一个序号
- 这是审查者应当自行判断的专业决定,不问Doro
### 字体修复的per-paragraph策略
同一合同中不同段落可能有不同的字体继承模式:
- 段落A: `ascii=宋体, hAnsi=宋体, cs=宋体, sz=24`(显式设置)
- 段落B: 完全无rFonts(靠docDefaults继承)
**不能全局统一处理**——必须逐段对比orig run属性,INS run匹配同段orig run
### 每份合同检查项
#### A0. 完整性检查(先于内容审查)
- **必须同时检查** tracked changes + comments.xml + footers/headers
-`zipfile``word/comments.xml` 提取所有批注数量和内容
- 段落文本100%相同 ≠ 文件完全相同(可能有批注但无修订)
- 2026-07-13教训:v2被误判为"与原文完全相同",实际有6条WB批注。python-docx的`.text`不反映批注内容
#### A. 检测重复处理
待审查文件是否已有WB修订?如果有,交付版是否产生了重复文字/重复INS。
#### B. 修订内容 vs 审查清单(10条逐条对照)
1. 主体条款:名称是否正确、是否直接改了不批注
2. 违约责任:是否有维权费用(律师费、诉讼费、保全费)
3. 争议解决:是否甲方所在地法院
4. 保密/数据:数据归属+保密存续+泄露责任
5. 知识产权:成果归甲方+终止后继续使用
6. 第三方侵权:乙方全责+全额赔偿甲方
7. 转包/分包:限制条款
8. 价款条款:金额不一致是否批注
9. 服务成果持续使用权
10. 条款逻辑
#### C. 批注合规检查
- 每个WB批注逐条对照规则:
- 是否涉及商业条款?(日期/期限/金额/支付方式/频率/天数 → 不应有)
- 是否能直接修订?(能改就改不批注)
- 是否有【】标签或理由解释?(禁止)
- 是否有"建议考虑""请考虑是否"等模糊措辞?(禁止)
#### D. 格式检查
- INS run的rPr与同段原文run逐属性对比(sz/rFonts/bold)
- 新增章节标题格式是否与原文章节标题一致
- 新增正文段落pPr(ind/spacing/numPr)是否与原文同类型段落一致
- 单段正文不加numPr("有2才有1"规则)
#### E. 交叉引用/编号
- 新增条款后原文编号是否顺延
- 接受修订后无重复编号/无跳号
### 报告格式
每份合同列出:
- 问题1/2/3...(严重/中等/轻微)
- 格式是否OK
- 是否需要修复
### 铁律
- 先查文件再说话(不凭印象判断)
- 一份做完确认后再做下一份
- 修复时从待审查原文出发,不在被污染的交付版上修
@@ -0,0 +1,90 @@
# 已交付合同审查工作流(Doro要求逐份复核)
## 2026-07-13 实战确立
当Doro要求"逐一审查已交付合同,告诉我有什么问题"时,执行以下流程。
## 触发条件
- Doro说"重新审查"/"逐份检查"/"没说pass的都要重新审查"
- 发现workflow出错率高需要全面复核
## 流程(每份合同严格按顺序)
### Step 0: 读规则
**必须先读review-rules.md**(通用+顾问单位特殊规则),不凭记忆。
### Step 1: 获取原文
从Nextcloud待审查目录取原始文件(.doc需libreoffice转.docx)。
### Step 2: 获取交付物
从Nextcloud任务交付目录取【修】文件。
### Step 3: 格式对比(原文 vs 交付物)
**必须做,不可跳过:**
1. **原文run属性基线**:读原文P4等代表性段落的run rPr(eastAsia/ascii/hint/sz各是什么)
2. **交付物同段落orig run属性**:读交付物中相同段落的orig run rPr
3. **比对是否被污染**:ContractEditor已知会给orig runs添加eastAsia/cs/sz属性
- 原文: `ascii=宋体, hint=eastAsia, szCs=21`(NO eastAsia, NO sz)
- 被污染后: `ascii=宋体, hint=eastAsia, eastAsia=宋体, cs=宋体, sz=21`(多了3个属性)
- 后果:原文字号从继承docDefaults变成显式值,可能导致整体缩小0.5pt
4. **INS run属性检查**:wb-ins-font-verify.py 跑一遍
5. **关键区分**
- "MISMATCH" = 真实问题(INS与orig不一致)
- "MISSING HINT (无同段原文可比)" = 整段INS新增段落的脚本已知限制,非真实问题
### Step 4: 内容覆盖核查
逐条对照review-rules.md审查清单(10项)打勾:
- 规则4(保密/数据)必须同时有:存续 + 泄露赔偿 + 数据归属
- 规则7(转包)必须有:限制 + 连带责任
- 同模板合同必须一致(修订方案、编号、措辞完全统一)
### Step 5: 编号检查(最易遗漏!)
**新增独立段落的编号问题是最高频错误:**
1. **有numPr的序列中插入新段落**:新段落必须加入相同numId序列,否则编号断裂
2. **手动文本编号序列中插入新段落**:新段落必须带编号前缀(如"4.9""7.6"),或合并到前一条末尾
3. **判断方法**:查看前后段落是否有编号(numPr或run文字中的"X.X"格式),有则新段也必须有
### Step 6: 特殊交付物检查
- 朱家角:是否有【审】审查意见文档
- 练塘:footer是否有"法律顾问修订版"
### Step 7: 文件命名检查
- "【修】+ 原文件名一字不动"
- 原文自带【修】前缀时,交付物名应为"【修】【修】..."
## 报告格式
```
## 【修】合同名称
字体:PASS/FAIL
编号:OK/问题描述
修订内容:(列出所有WB changes)
审查清单:
✅/❌ 1. 主体
✅/❌ 2. 违约
...
问题:
1. xxx
2. xxx
```
## 铁律(2026-07-13 Doro多次纠正)
1. **"查清楚再说"**:任何结论必须基于tool call读文件的结果,不凭之前输出的印象。被Doro说"你看文件去不是让你瞎说"就是违反了这条
2. **不要遗漏**:先前说"无问题"后被Doro追问"编号没问题吗?"→ 说明审查不够仔细。每份合同必须完整过完所有检查项
3. **一份一份**:Doro说pass再做下一份,不要批量报告
4. **发现问题就修**:Doro说"按照workflow的规则,手动修改"时直接修,不要再问
## 常见遗漏清单(每份必检)
- [ ] 新增INS段落是否有编号(numPr或手动文本编号)
- [ ] 保密条款是否三要素齐全(存续+泄露赔偿+数据归属)
- [ ] 原文run属性是否被ContractEditor污染
- [ ] 同模板合同是否一致
- [ ] 特殊交付物(审查意见/脚注)是否齐全
- [ ] 文件命名是否正确
@@ -0,0 +1,150 @@
# 劳务派遣"反委托"代发工资安排的法律风险
> 2026-06-30 + 2026-07-01 深度研究。审查此类协议时的权威参考。
## 一、法律规范层面
### 1. 《劳务派遣暂行规定》(人社部令第22号)第八条第(三)项
> 劳务派遣单位应当对被派遣劳动者履行下列义务:……(三)按照国家规定和劳务派遣协议约定,**依法支付被派遣劳动者的劳动报酬和相关待遇**。
这是**强制性规定**。向被派遣劳动者支付工资是派遣单位的法定义务,不是可以通过协议转移的任意性义务。
### 2. 《劳动合同法》第五十八条
> 劳务派遣单位是本法所称用人单位,应当履行用人单位对劳动者的义务。
派遣单位作为法定用人单位,支付工资是其核心义务之一。"反委托"安排实质上是让用工单位替代派遣单位履行该义务,**与法定用人单位制度直接冲突**。
### 3. 劳社部发〔2005〕12号《关于确立劳动关系有关事项的通知》第二条
> 认定双方存在劳动关系时可参照下列凭证:(一)**工资支付凭证或记录**(职工工资发放花名册)、缴纳各项社会保险费的记录……
工资支付记录是认定事实劳动关系的**首要证据**。甲方直接向派遣员工发工资,等于主动制造了认定自身与派遣员工存在劳动关系的直接证据。
## 二、司法判例
### 案例1:广东省高院(2022)粤民再30号——梁某诉某汽车公司(广东高院劳动争议十大典型案例之三)
- **事实**:梁某与咨询公司签劳动合同,被派至汽车公司工作。**梁某工资由汽车公司直接发放**。咨询公司与汽车公司签有劳务派遣协议,但对劳动报酬等重要事项均未约定。咨询公司无劳务派遣资质,未对梁某进行任何管理。
- **裁判**:汽车公司通过虚假劳务派遣规避主体责任的行为无效。梁某按汽车公司的规章制度接受管理,**工资报酬亦由汽车公司支付**,双方具备实质劳动关系特征。认定汽车公司与梁某之间存在劳动关系,由汽车公司承担用人单位主体责任。
- **要点**:法院将**"工资由用工单位直接支付"**作为认定事实劳动关系的关键因素之一。
### 案例2:山东高青县法院(2022)鲁0322民初834号
- **事实**:李某在甲公司(皮革公司)工作,甲公司与乙公司签外包协议。**李某工资由甲公司计算后交乙公司发放**,乙公司为李某缴纳工伤保险。
- **裁判**:法院认定李某与甲公司存在劳动关系。核心理由之一:**"李某的劳动报酬实际是甲公司计算并交由乙公司发放,李某与甲公司存在经济上的依附性"**。
- **要点**:即便有书面外包协议、有第三方发放工资和缴社保,法院仍穿透认定实际用工关系。**事实劳动关系不能单纯凭借用人单位与第三方的劳务外包协议或第三方为员工支付工资、缴纳工伤保险而予以否定。**
### 德恒律师事务所案例分析
> "某公司直接向劳务派遣劳动者发放工资,**不符合劳务派遣劳动关系的法律要件**,故需要某公司与劳务派遣公司在双方的派遣协议中约定代发工资事宜并……"
德恒的结论是:用工单位直接发工资本身就不合规,即便在派遣协议中约定了"代发",也只是在形式上做了补救,**不能从根本上消除事实劳动关系认定的风险**。
## 三、风险具体化——甲方可能面临的全部法律后果
| 风险类别 | 具体后果 | 法律依据 |
|----------|----------|----------|
| **事实劳动关系认定** | 甲方被认定为用人单位,派遣隔离失效 | 劳社部发〔2005〕12号第一条、第二条 |
| **未签劳动合同双倍工资** | 最长11个月的双倍工资差额 | 《劳动合同法》第八十二条 |
| **经济补偿金/赔偿金** | 解除时支付N或2N经济补偿 | 《劳动合同法》第四十六条、第八十七条 |
| **社保补缴+滞纳金** | 补缴全部社保费用,每日万分之五滞纳金 | 《社会保险法》第六十三条、第八十六条 |
| **工伤保险责任** | 未参保期间工伤,甲方承担全部工伤待遇 | 《工伤保险条例》第六十二条 |
| **个税扣缴风险** | 甲方被认定为扣缴义务人,补扣+罚款 | 《个人所得税法》第九条、《税收征管法》第六十九条 |
| **增值税发票风险** | 派遣公司发票与实际付款不匹配,进项抵扣不合规 | 《增值税暂行条例》第八条 |
| **虚开发票风险** | 费用支付主体与协议主体不一致,"三流不一致" | 《发票管理办法》第二十二条 |
## 四、"反委托"安排在补充协议中能否规避风险?
**结论:不能。**
即使补充协议明确约定"甲方系受乙方委托代为发放工资"、"不构成劳动关系",法院在认定事实劳动关系时采取的是**实质审查**标准,不以当事人之间的协议约定为准。
法院的审查要素是:
1. **谁实际管理和指挥劳动者** → 用工单位
2. **谁实际支付工资** → 用工单位(反委托后)
3. **劳动者的工作是否为用工单位业务组成部分** → 是
4. **劳动者是否接受用工单位规章制度约束** → 是
当这四个要素全部指向用工单位时,即便协议约定"不构成劳动关系",法院仍会认定事实劳动关系。**协议的约定不能对抗法律的强制性规定**。
## 五、审查要点(甲方=用工单位立场)
### 核心结论
**不建议签署反委托代发工资协议。** 工资应由乙方(派遣公司)直接向派遣员工支付。
### 如甲方仍决定签署的保护性修订清单
1. **鉴于条款定性**:增加"双方确认,乙方仍为派遣员工的用人单位,甲方系受乙方委托代为发放工资"
2. **第一条修改**:将"甲方应按月足额向员工支付工资"修改为"甲方受乙方委托,按月足额向员工支付工资"
3. **对等追偿权 + 事实劳动关系兜底**:乙方因未依法履行用人单位义务导致甲方损失的,甲方有权追偿;如因本协议导致甲方被认定为事实劳动关系,乙方赔偿甲方全部损失
4. **税务责任限定**:甲方仅承担自身原因导致的税费差额,增加"因乙方过错导致的除外"
5. **税务配合义务**:如因本协议导致甲方被税务机关认定为扣缴义务人,乙方应配合并承担额外费用
6. **社保义务对等**:乙方应及时办理社保缴纳手续,未及时办理的由乙方承担全部责任
7. **劳动关系确认条款**:派遣员工与乙方劳动关系不因本协议变更,甲方代发工资不构成建立劳动关系
8. **乙方资质维持**:劳务派遣许可证持续有效,变动三日内通知甲方
9. **乙方用工管理义务**:乙方应依法签订劳动合同、办理用工登记、建立职工名册
10. **争议解决**:甲方住所地法院管辖
### 承诺书处理
**删除全文(tracked deletion),批注说明法律依据。不建议签署。**
## 六、追偿条款的法律局限性(2026-07-01 Doro追问确认)
**甲乙之间的追偿协议只能约定内部关系,不能免除甲方对外的法定责任。**
一旦法院/仲裁认定甲方是用人单位:
- **对外**:甲方必须直接向派遣员工承担双倍工资、经济补偿金、工伤赔偿等——这是法定义务,甲乙之间的内部协议**不能对抗劳动者**
- **对内**:甲方承担完对外责任后,可以依据追偿条款向乙方追偿
**实际效果**:甲方先赔,再找乙方要回来。如果乙方赔不起(破产、跑路),甲方就兜不住了。
**强化保护手段**
- 履约保证金(乙方在签署时缴纳,金额留空由甲方填写)
- 银行保函(甲方认可的银行出具)
- 三方签署(甲方、乙方、派遣员工共同签署,派遣员工确认知悉代发安排)
## 七、两版本审查法(2026-07-01 Doro要求)
当合同存在根本性法律安排风险时,应提供两个修订版本:
**版本1:法定安排(推荐)** — 删除有风险的反委托安排,回归法定模式:
- 标题去掉"委托支付工资"
- 鉴于条款改为一般性补充协议
- 所有条款围绕乙方(派遣公司)法定义务展开:支付工资、缴纳社保、签订劳动合同等
- 甲方监督权、扣款权、退回权
- 乙方资质维持、用工管理义务、不得转派
- 保密条款、争议解决(甲方住所地法院)
- 效力条款:期限明确 + 份数平等(一式肆份各贰份)
**版本2:保留反委托+最大化保护** — 保留原安排但加强保护:
- 鉴于条款定性"委托代发",明确乙方仍为用人单位
- 甲方身份改为"受乙方委托"代发
- 事实劳动关系兜底(乙方十日内赔偿甲方全部损失)
- 税务责任限定 + 乙方配合义务
- 社保义务对等
- 劳动关系确认条款
- 乙方资质维持 + 用工管理义务
- **履约保证金**(金额留空)
- **三方签署要求**
- 争议解决(甲方住所地法院)
## 八、删除承诺书的技术实现
当决定删除整份文件(如承诺书)时:
1. 用tracked deletion(w:del, author=WB)标记承诺书全部段落
2. 在关联条款(如补充协议的鉴于条款)处加批注,说明删除的法律依据
3. 批注内容引用具体法条(如《劳务派遣暂行规定》第八条),给出明确的风险评估和建议
4. 审查意见文档中单独列出该文件的评估结论和处理建议
### 批注内容模板
```
【核心风险·不建议签署本补充协议】
根据《劳务派遣暂行规定》(人社部令第22号)第八条,劳务派遣单位应当依法向被派遣劳动者支付劳动报酬。
由甲方(用工单位)直接向派遣员工发放工资,违反该规定,在司法实践中极易被认定甲方与派遣员工之间存在事实劳动关系(参考:(2022)粤民再30号等裁判),导致劳务派遣的法律隔离效果完全失效。
一旦认定事实劳动关系,甲方将面临:未签劳动合同双倍工资、经济补偿金、社保补缴及滞纳金、工伤赔偿等全部用人单位责任,远超正常派遣的管理费成本。
建议:不签署本补充协议,维持原协议安排,由乙方(派遣公司)直接向派遣员工支付工资。
附件《承诺书》为甲方单方面向乙方作出的全面兜底承诺,对甲方极为不利,一并建议不予签署(已在修订版中删除)。
```
@@ -0,0 +1,88 @@
# 劳务派遣"反委托"代发工资法律风险分析
## 核心结论
用工单位(甲方)直接向派遣员工发放工资,违反《劳务派遣暂行规定》第八条第(三)项强制性规定,在司法实践中极易被认定为事实劳动关系。不建议签署此类安排。
## 法律依据
### 1. 《劳务派遣暂行规定》(人社部令第22号)第八条第(三)项
> 劳务派遣单位应当对被派遣劳动者履行下列义务:……(三)按照国家规定和劳务派遣协议约定,**依法支付被派遣劳动者的劳动报酬和相关待遇**。
支付工资是派遣单位的**强制性法定义务**,不可通过协议转移。
### 2. 《劳动合同法》第五十八条
> 劳务派遣单位是本法所称用人单位,应当履行用人单位对劳动者的义务。
### 3. 劳社部发〔2005〕12号《关于确立劳动关系有关事项的通知》第二条
> 认定双方存在劳动关系时可参照下列凭证:(一)**工资支付凭证或记录**(职工工资发放花名册)、缴纳各项社会保险费的记录……
工资支付记录是认定事实劳动关系的**首要证据**。
### 4. 《劳动合同法》第九十二条第二款
> 用工单位给被派遣劳动者造成损害的,劳务派遣单位与用工单位承担**连带赔偿责任**。
### 5. 《劳务派遣暂行规定》第二十四条
> 用工单位违反本规定退回被派遣劳动者的,按照劳动合同法第九十二条第二款规定执行。
## 司法判例
### 广东省高院(2022)粤民再30号(劳动争议十大典型案例之三)
- **事实**:梁某与咨询公司签劳动合同,被派至汽车公司。**工资由汽车公司直接发放**。咨询公司无劳务派遣资质,未对梁某进行任何管理。
- **裁判**:汽车公司通过虚假劳务派遣规避主体责任的行为无效。工资由用工单位支付+接受用工单位管理=事实劳动关系。汽车公司承担用人单位主体责任。
### 山东高青县法院(2022)鲁0322民初834号
- **事实**:李某在甲公司工作,甲公司与乙公司签外包协议。工资由甲公司计算后交乙公司发放。
- **裁判**:认定事实劳动关系。核心理由:"李某的劳动报酬实际是甲公司计算并交由乙公司发放,李某与甲公司存在经济上的依附性"。
- **要点**:即便有书面外包协议、有第三方发放工资和缴社保,法院仍穿透认定实际用工关系。
### (2019)沪0109民初12453号
- 用工单位以严重违法规章制度为由退回劳动者属于**违法退回**,由此造成派遣公司违法解除的,用工单位承担连带责任。
## 一旦认定事实劳动关系的后果
| 风险类别 | 具体后果 | 法律依据 |
|----------|----------|----------|
| 未签劳动合同双倍工资 | 最长11个月的双倍工资差额 | 《劳动合同法》第82条 |
| 经济补偿金/赔偿金 | 解除时支付N或2N | 《劳动合同法》第46条、第87条 |
| 社保补缴+滞纳金 | 补缴全部社保,每日万分之五滞纳金 | 《社会保险法》第63条、第86条 |
| 工伤保险责任 | 未参保期间工伤,甲方承担全部工伤待遇 | 《工伤保险条例》第62条 |
| 个税扣缴风险 | 被认定为扣缴义务人,补扣+罚款 | 《个人所得税法》第9条 |
| 增值税发票风险 | 发票与实际付款不匹配 | 《增值税暂行条例》第8条 |
## "反委托"协议能否规避风险?
**结论:不能。**
甲乙之间的协议只能约定**内部追偿**关系,不能免除甲方对外的法定责任:
- **对外**:甲方必须直接向派遣员工承担法定义务——这是强制性规定,内部协议**不能对抗劳动者**
- **对内**:甲方承担完对外责任后,可向乙方追偿。但乙方无力赔偿时(破产、跑路),损失由甲方自行承担
法院采取**实质审查**标准,不以协议约定为准。
## 退回派遣员工的法律风险
### 法定退回情形(《劳务派遣暂行规定》第12条)
用工单位可以退回的情形仅限三种:
1. 客观情况重大变化/经济性裁员
2. 用工单位破产/解散
3. 派遣协议期满
### 违法退回的后果
- 《劳动合同法》第92条第2款:用工单位给被派遣劳动者造成损害的,**连带赔偿**
- 第24条:违反规定退回的,按第92条第2款执行
### "退回后与甲方无涉"条款的风险
- 甲乙之间的协议约定不能免除甲方的法定连带责任
- 可能被认定为"免除自己法定责任"而无效(《劳动合同法》第26条)
- **安全写法**:明确退回必须依据法定情形 + 乙方安置义务 + 乙方对甲方的赔偿义务
- **不安全写法**:"与甲方无涉"——过于绝对,有被认定无效的风险
## 如甲方仍决定签署的保护措施
1. **三方签署**(甲方、乙方、派遣员工),派遣员工确认知悉安排
2. 鉴于条款定性为"委托代发",明确乙方仍为用人单位
3. 事实劳动关系兜底条款:乙方赔偿甲方全部损失
4. 履约保证金/银行保函
5. 乙方资质维持条款
6. 乙方用工管理义务条款
7. 退回条款限定法定情形 + 乙方安置义务 + 赔偿义务
@@ -0,0 +1,26 @@
# final_review 角色越权上传重复文件
## 问题描述
`review-contract.yaml``final_review` 角色在完成终审后,会自行上传文件到 Nextcloud,与 `deliverer` 角色已上传的文件形成重复。
## 表现
- 任务交付/ 中出现同一合同的两个版本,文件大小完全一致(内容相同)
- final_review 上传的版本使用完全不同的命名格式(如 `朱家角-巷泽...-修订版-邱庭-20260629.docx`),与 deliverer 的 `【修】合同_朱家角.docx` 不一致
- 还可能额外生成审查意见 .txt 文件(final_review 自行生成的,非 editor 产出的 companion)
## 已确认的案例(2026-06-29)
1. 巷泽居委会办公家具采购项目合同 — deliverer 上传 `【修】合同_朱家角.docx`,final_review 又上传 `朱家角-巷泽居委会办公家具采购项目合同-修订版-邱庭-20260629.docx`
2. 医疗急救中心救护车采购合同 — deliverer 上传 `【修】医疗急救中心救护车采购合同.pdf`,final_review 又上传 `【修】上海市青浦区医疗急救中心救护车采购合同-邱庭-20260629.pdf` + `上海市青浦区医疗急救中心救护车采购合同-审查意见-邱庭-20260629.txt`
## 判别方法
- `stat` 比较文件大小:完全一致 = 重复
- 命名格式不同:`【修】原名.docx` vs `前缀-全名-修订版-邱庭-日期.docx`
## 处理方式
保留 deliverer 上传的版本(命名符合 `【修】+ 原始文件名` 规则),删除 final_review 的重复文件。
## 根因
`final_review` 角色的 procedure 中包含了上传文件的逻辑(它从 /tmp/contract-review/ 读取文件并上传),但 deliverer 已经做过这一步。两个角色都上传 = 双份。
## 修复方向
修改 `review-contract.yaml``final_review` 角色 procedure,删除上传相关步骤,只保留检查 + 通知 Doro 的逻辑。属于 workflow YAML 改动,需要 WeiWei 确认。
@@ -0,0 +1,95 @@
# 格式复核常见模式与数值参考
## firstLine 缩进一致性
不同合同模板的 firstLine 值不同,但同一合同内应一致。
### 常见值
- 政府示范文本(仿宋):通常 firstLine=600(约 10.5pt 字号的两倍字符缩进)或 596/602(±2 的微小差异属正常)
- 正文段落和条款标题通常使用相同的 firstLine 值
- 数值偏差 > 20 即视为不一致
### 2026-06-26 计生合同教训
新增条款段落 firstLine=753,现有段落 firstLine=596/600/602。差值 153 twips ≈ 2.7mm,视觉上明显不一致。
**根因**:editor 使用 `add_clause` 时段落属性从未正确匹配的相邻段落克隆。
### 检查方法
```python
# 提取所有段落的 firstLine 值
for i, p in enumerate(all_ps):
pPr = p.find(qn('pPr'))
if pPr is not None:
ind = pPr.find(qn('ind'))
if ind is not None:
fl = ind.get(qn('firstLine'))
if fl:
print(f"P{i}: firstLine={fl}")
```
将新增段落的 firstLine 与原文同级段落对比,超出 ±20 视为不一致。
## 加粗(bold)一致性
### 规则
- 中文合同中"第X条"编号部分**始终加粗**(bold=True)
- 条款标题文字("第X条"之后的部分)可能加粗也可能不加粗,取决于原文模式
- 新增条款标题的加粗应与**最近的原文条款标题**一致
### 常见模式
| 模式 | 示例 | 原文样式 |
|------|------|----------|
| 全加粗 | "第一条、项目名称、时间" | 整个 run bold=True |
| 编号加粗 | "第七条、" bold + "项目费用" not bold | 编号 run 和标题 run 分开 |
| 仅编号加粗 | "第八条" bold + "、违约责任" not bold | 编号和内容分开 |
### 2026-06-26 计生合同教训
新增 P31 "第九条、第三方侵权责任" 和 P33 "第十条、转包与分包" 整体 bold=False。但原文所有条款标题中"第X条"均为 bold=True。最近的原文标题 P29 "第八条" bold=True。
### 检查方法
对每个 WB INS 新增条款标题段落,检查其 INS run 的 rPr 中是否有 `<w:b/>``<w:b w:val="1"/>`。与最近的原文条款标题段落的 bold 状态对比。
## hint='eastAsia' 属性
### 规则
中文文档的 WB INS run 的 rFonts 应有 `w:hint="eastAsia"`。缺失会导致某些渲染器回退到非 CJK 字体。
### 2026-06-26 计生合同教训
P35 INS "第十一条" 和 P37 INS "第十二条" 的 rFonts 缺少 hint 属性。其他所有 WB INS run 均有 hint='eastAsia'。
### 检查方法
```python
for ins in body.iter(qn('ins')):
if ins.get(qn('author')) != 'WB':
continue
for r in ins.findall(qn('r')):
rpr = r.find(qn('rPr'))
if rpr is None:
continue
rfonts = rpr.find(qn('rFonts'))
if rfonts is not None:
hint = rfonts.get(qn('hint'))
if hint != 'eastAsia':
# 标记为缺失
```
## x2t 视觉验证的局限性
### 字体回退
OnlyOffice 容器(`nextcloud-onlyoffice-1`)可能未安装合同使用的字体。当 x2t 找不到指定字体时:
- 所有文本回退到 WenQuanYi Zen Hei Mono(等宽 CJK 字体)
- **加粗状态无法通过 x2t PDF 验证**——回退字体下 bold 始终为 False
- 字体族名称也无法验证——所有文本显示为同一回退字体
### 适用场景
x2t 仍可用于验证:
- 内容完整性(文本是否缺失、是否被截断)
- 分页和布局
- 段落是否存在(标题/正文分离检查)
- 文本溢出(截断警告)
### 不适用场景
x2t **不能**用于验证:
- 字体族(仿宋/宋体/黑体)
- 加粗/斜体
- 字号精确值
**字体和格式属性必须以 XML 为准。**
@@ -0,0 +1,46 @@
# 法律意见边界案例(2026-07-01 Doro纠正汇总)
## 核心原则
审查工具的角色 = 执行审查规则 + 呈现发现。不是法律顾问。
| 行为 | 允许? | 说明 |
|------|--------|------|
| 客观呈现法律风险 | ✅ | "本安排存在被认定事实劳动关系的风险" |
| 建议保护方案 | ✅ | "建议增加三方确认/保证金条款" |
| 做法律价值判断 | ❌ | "强烈建议采用版本1" |
| 否定客户商业安排 | ❌ | 把"甲方代发"改成"乙方直接发" |
| 填写合同空白内容 | ❌ | 空白处填"12个月" |
| 编造法律依据 | ❌ | "司法实践中认定…"(无权威来源) |
## 案例1:反委托代发工资协议(最严重)
- **客户安排**:甲方(用工单位/客户)代发工资
- **错误做法**:版本1直接改成"乙方直接发工资"→ 否定了客户的商业决策
- **正确做法**:保留代发安排,在该框架内加保护条款(三方确认、保证金、乙方兜底赔偿)
- **教训**:合同审查的立场是客户利益最大化,不是法律合规最大化
## 案例2:质量保证期空白
- **合同状态**:质量保证期___个月(空白待填)
- **错误做法**:直接填入"12个月"
- **正确做法**:批注提示"此处需根据招标文件/投标文件要求填写具体月数"
- **教训**:空白处是当事人商业条款,审查方无权填写
## 案例3:医疗补助金答复
- **问题**:6/9/12个月医疗补助费的法律依据
- **错误做法**:编造"司法实践中认定"结论(来源是律所文章二手总结)
- **正确做法**:如实说明"6个月依据《上海市劳动合同条例》第44条,重病/绝症加成依据劳部发481号文(已废止),上海法院酌情参考"
- **教训**:确立法律检索铁律——信息来源仅限官方资料
## 案例4:学习机滞纳金
- **错误做法**:表格对比+分段教学式分析(像AI教学不像律师意见)
- **正确做法**(Doro纠正后):先列要点(递进排列),再给整体修改文本,不教学、不引法条、不用表格
- **教训**:法律意见的输出格式应像律师给客户的简洁方案,不是法学课件
## 案例5:生育友好协会合同
- **合同性质**:公益捐赠/协会合同
- **错误做法**:加了严苛的商事对抗性条款(转包违约金、单方解除权等)
- **正确做法**:根据合同性质调整审查策略,公益合同不加对抗性条款
- **教训**:催生了 contract_nature 字段——区分商事/公益/行政/劳动用工
## 案例6:劳务派遣协议
- **错误做法**:修订后条款前言不搭后语、语句不通顺
- **教训**:修订时必须通读上下文,确认修改后整段话语法正确、逻辑自洽
@@ -0,0 +1,94 @@
# Workflow审查遗漏手动补修方法(2026-07-09 职业卫生合同实证)
## 场景
Doro要求"查看交付合同哪里不符合审查规则"并指示手动修复。
## 诊断步骤
### 1. 提取修订清单
```python
# 从交付文件提取所有WB tracked changes
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)
# INS
for ins in root.findall(f'.//{{{W}}}ins'):
if ins.get(f'{{{W}}}author') != 'WB': continue
texts = [t.text for t in ins.iter(f'{{{W}}}t') if t.text]
text = ''.join(texts).strip()
if text:
print(f"[INS] {text[:100]}")
# DEL
for d in root.findall(f'.//{{{W}}}del'):
if d.get(f'{{{W}}}author') != 'WB': continue
texts = [t.text for t in d.iter(f'{{{W}}}delText') if t.text]
text = ''.join(texts).strip()
if text:
print(f"[DEL] {text[:100]}")
```
### 2. 逐条对照review-rules.md
将每一条审查规则(10大项)与已做修订比对,找出"规则要求做但没做的"。
### 3. 用ContractEditor补修
```python
import sys
sys.path.insert(0, '/home/maggie/contract-work')
from contract_docx_lib import ContractEditor
editor = ContractEditor(work_path)
# tracked_replace: 在现有文本上追加/替换
editor.tracked_replace("原文片段。", "原文片段并新增内容。")
# add_clause: 在某段落之后插入新条款
# 参数: (full_text, after_search, use_title_format=False)
editor.add_clause(
"X.X 新增条款内容。",
"在包含此文本的段落之后插入" # after_search
)
editor.save(work_path)
editor.validate() # 返回空列表=无问题
```
### 4. 字体验证
```bash
python3 ~/.hermes/skills/legal/contract-reviewer/scripts/wb-ins-font-verify.py <file.docx>
```
已知误报:混合字体段落(如"甲方:"用仿宋,名称用宋体)中的INS匹配相邻run但脚本比对首个run——属于false positive,无需修复。
### 5. 上传替换
```bash
sudo docker cp <local.docx> nextcloud-nextcloud-1:<NC路径>
sudo docker exec nextcloud-nextcloud-1 chown www-data:www-data <NC路径>
sudo docker exec -u www-data nextcloud-nextcloud-1 php occ files:scan --path="doro/files/..."
sudo docker exec nextcloud-onlyoffice-1 bash -c 'rm -rf /var/lib/onlyoffice/documentserver/App_Data/cache/files/data/*'
sudo docker restart nextcloud-onlyoffice-1
```
## 2026-07-09 职业卫生合同具体修复
| # | 类型 | 位置 | 修订内容 | 规则 |
|---|------|------|----------|------|
| 1 | tracked_replace | 1.4条 | "承担全部责任。"→"承担全部责任并全额赔偿甲方因此遭受的一切损失。" | 规则6 |
| 2 | add_clause | 4.8.1后 | 新增4.8.2数据归属 | 规则4 |
| 3 | add_clause | 4.8.2后 | 新增4.8.3泄露责任 | 规则4 |
| 4 | add_clause | 5.7后 | 新增5.8持续使用权 | 规则9 |
同时修复了"有2才有1"编号问题:原4.8.1是唯一子项(违规),加上4.8.2和4.8.3后变为3个子项(合规)。
## 关键注意事项
1. **ContractEditor.add_clause参数是(full_text, after_search)**,不是keyword argument `after_text=`
2. 新增子项时注意"有2才有1"规则——单独一个子项就不要加子编号
3. P4字体验证FAIL是已知false positive(混合字体段落),详见`references/wb-ins-font-verify-false-positive.md`
4. 手动修复直接替换交付目录文件,不重跑workflow
@@ -0,0 +1,56 @@
# 手动审查陷阱(2026-06-12 数字健康城区运维合同V3)
## 事件
workflow reviewer阶段连续超时9次,Doro批准手动接管。第一遍只找到8个法律问题直接修订交付,被Doro批评"但你认真点",第二遍逐字通读补出5个文字问题,从原文重做全部13处修订。交付后Doro pass但评价"加入的格式并不准确"。
## 遗漏的5个文字问题
| # | 段落 | 原文 | 问题 | 类型 |
|---|------|------|------|------|
| 9 | 53 | "甲方应及时**进行**根据合同的规定**进行**服务验收" | 双"进行" | 重复用词 |
| 10 | 63 | "(3)2027**月**6月" | "月"应为"年" | 笔误 |
| 11 | 82 | "**由于因**甲方工作人员" | "由于"和"因"重复 | 重复用词 |
| 12 | 90 | "经过**买卖**双方商定" | 全文甲乙方,仅此一处"买卖" | 称谓不一致 |
| 13 | 86 | "如果**或**证实服务是有缺陷的" | "或"应为"经" | 笔误 |
## 第一遍正确找到的8个问题
| # | 类型 | 内容 |
|---|------|------|
| 1 | 笔误 | 第八条标题"甲方(甲方)的权利义务"重复 |
| 2 | 违约金上限 | 误期赔偿最高限额5%应删 |
| 3 | 条款缺失 | 争议解决无维权费用条款 |
| 4 | 语法 | 保密条款双"不得" |
| 5 | 条款缺失 | 无数据归属条款 |
| 6 | 条款缺失 | 无知识产权归属+系统交接 |
| 7 | 笔误 | 年份2025应为2026 |
| 8 | 对甲方不利 | "视为验收通过"条款应删 |
## 格式不准确的问题(Doro评价"加入的格式并不准确")
手动用etree构建新增段落(make_ins_para函数),只设了pStyle和rFonts hint=eastAsia,没有完整克隆相邻原文段落的格式属性。与ContractEditor库的add_clause相比缺失:
- w:ind(缩进)没有从相邻段落复制
- w:sz(字号)没有显式设置
- w:rFonts只有hint,没有ascii/hAnsi/eastAsia/cs四属性
- w:spacing(段前段后间距)没有设置
**正确做法**:即使手动模式也应使用ContractEditor库,或者至少从相邻原文段落完整复制pPr和rPr,而非只靠pStyle编号。
## xlsx列顺序写反的问题
手动写xlsx Row 187时凭记忆写列,结果列顺序完全错误:
- 错误:A=日期, B=序号, C=原始文件名, D=顾问单位, E="已完成"
- 正确:A=序号, B=日期, C=顾问单位, D=合同名称, E=交付文件名
**正确做法**:写入前先读上一行(如Row 186)确认列顺序,不凭记忆。
## needs_clarification捷径
《医疗器械购销协议》乙方空白,classifier设status=needs_clarification等人工确认。Doro指出:甲方九州通不在顾问单位名单,空白方必然是顾问单位,审查立场已明确,不需要等确认具体名称就能开始审查。
## 教训
- **法律问题和文字问题是两个不同的阅读模式**——法律审查关注条款缺失和权利义务,文字校对关注措辞准确和一致性
- 政府采购合同模板常见问题:年份未更新、上版模板用词残留(买卖→甲乙)、重复词未校对
- 手动审查时没有reviewer独立复核环节兜底,文字校对更容易被跳过
- **手动不等于降级**:不走workflow时更应该用ContractEditor库保证格式一致性,裸写etree XML太容易出格式偏差
- **xlsx手动写入必须先读上一行确认列结构**
@@ -0,0 +1,42 @@
# 手动审查预检清单(2026-07-13 两份合同返工后确立)
## 背景
Doro要求"一份一份来"审查新合同,小Maggie跳过读规则直接开始,结果两份合同被评"错漏百出":
- 职业卫生监督抽检合同:保密条款只加存续漏了泄露赔偿、INS字体属性多余
- 练塘可降解环保袋合同:同样问题
## 强制预检步骤
### 1. 读规则(每次都读,不凭记忆)
```bash
# 通用规则
sudo docker cp "nextcloud-nextcloud-1:/var/www/html/data/doro/files/Doro合同审查任务/review-rules.md" /tmp/review-rules-general.md
# 顾问单位专属规则(确认甲方后查)
sudo find ~/nextcloud/data/data/doro/files/Doro合同审查任务/ -name "review-rules.md" -not -path "*/参考文件/*"
```
### 2. 确认特殊交付物
| 顾问单位 | 特殊要求 |
|---------|---------|
| 练塘镇社区卫生服务中心 | footer脚注"法律顾问修订版" |
| 朱家角镇社区卫生服务中心 | 审查意见文档(模板+格式) |
| 其他 | 从专属rules中识别 |
### 3. 审查清单三要素逐条打勾(重灾区)
规则4(保密/数据)**必须三个全有**:
- [ ] 数据归甲方或相关权利方所有
- [ ] 保密义务不因合同终止而终止
- [ ] 泄露赔偿:"乙方违反保密义务导致甲方损失的,应全额赔偿"
### 4. INS字体属性原则
**原文run有什么INS就有什么,原文没有的INS也不该有。**
- 原文run ea=None(靠继承)→ INS不该显式设ea=宋体
- 原文run hint=None → INS不该设hint=eastAsia
- save后必须跑wb-ins-font-verify.py,HINT/EASTASIA MISMATCH如果原文=None则需strip
### 5. 对照原文检查格式
交付前打开原文和修订版逐段对比:
- plain runs的rPr有没有被意外修改(spurious sz添加等)
- pPr(ind/spacing)是否与原文一致
- 新增段落的格式是否克隆了正确的邻近段落
@@ -0,0 +1,50 @@
# 新增条款编号缺失+不加粗 (2026-06-10 安全测试合同)
## 事件经过
1. **editor**`add_clause` 新增"维权费用"条款("因履行本合同发生争议的,违约方应承担守约方因维权产生的全部合理费用……"),插入在第十一条和原第十二条之间
2. 新增段落**没有编号**(缺"第十二条"),也没有顺延后续编号
3. **reviewer终审**(第3轮强制pass)未发现缺编号——尽管 `contract-reviewer` skill 明确要求检查
4. **Doro退回**:"没有编号,你终审没核查出来"
## 第一次修复(编号正确但不加粗)
用XML直接在段落开头插入 `<w:ins author="WB">` 元素:
```xml
<w:ins w:author="WB" ...>
<w:r>
<w:rPr>...</w:rPr> <!-- 从同段落另一个WB INS的rPr复制 -->
<w:t>第十二条 </w:t>
</w:r>
</w:ins>
```
同时把原P99的WB INS "第十二条"改为"第十三条"(该段落原本是"第十一条"被editor改为"第十二条",现在需要再改为"第十三条")。
**问题**:复制的rPr来自同段落正文内容的INS(不加粗),而原文所有"第X条"标题都是加粗的。
## 第二次修复(加粗)
定位P98中包含"第十二条"文本的WB INS run,给其rPr补上 `<w:b/>`
```python
rpr = r.find(qn('rPr'))
if rpr is not None and rpr.find(qn('b')) is None:
etree.SubElement(rpr, qn('b'))
```
## 根因分析
1. **editor层面**`add_clause``suggested_fix` 中没有包含编号。reviewer给的建议是"建议增加:因履行本合同……",没有写"第X条"编号
2. **reviewer层面**:终审时规则明确要求"逐个检查每个WB INS段落是否以编号开头",但执行时跳过了
3. **手动修复层面**:插入编号时应该从**相邻条款标题**的rPr复制(含bold),而不是从同段落正文内容的rPr复制
## 改进措施
1. `contract-reviewer` skill 的编号检查项增加了"编号加粗一致性"必检子项
2. `scripts/wb-ins-font-verify.py` 增加了Phase 2: Title bold consistency check——收集所有"第X条"模式的bold状态,不一致的标为issue
3. reviewer的suggested_fix对于新增独立条款,必须包含完整编号(如"建议增加第X条:……"),不能只写正文内容
## 受影响文件
- `contract_docx_lib.py``add_clause` 方法:本身无bug,是调用时suggested_fix没带编号
- 安全测试合同最终交付版:第十二条(维权费用)编号加粗,第十三条(原第十二条生效条款)编号已顺延
@@ -0,0 +1,41 @@
# numPr Section Continuity Check (2026-07-12 盈浦健康科普案)
## Problem Pattern
Contracts where **every section's body paragraphs use auto-numbering (numPr)** — each chapter heading (一、二、三…) is followed by body paragraphs that all carry a shared `numId`, rendering as "1." "2." "3." etc.
When workflow inserts new paragraphs via `add_clause`:
1. **Missing numPr**: New paragraph gets no numPr → breaks numbering sequence (siblings show "1." "2." "3." but new paragraph has no number)
2. **Wrong numId**: New paragraph inherits numPr from wrong section (e.g., clones neighbor paragraph's numId=11 which belongs to "七、不可抗力", making the new "转包" content render as "3." in the wrong sequence)
## Diagnosis Steps
1. Run `numbering-diagnose.py` on both original and modified files
2. For each WB INS paragraph, check:
- Does it have numPr? Should it?
- If yes, does its numId match the **section it belongs to** (not an adjacent section)?
3. Map numId → section by looking at which chapter heading precedes the numId's first occurrence
## Real Example (盈浦健康科普 2026-07-12)
Original structure:
- 五、保密条款: P59-P61 all numId=9 → renders "1." "2." "3."
- 六、违约责任: P64-P68 all numId=10 → renders "1." "2." "3." "4." "5."
- 七、不可抗力: P71-P72 numId=11 → renders "1." "2."
- 八、争议解决: P75-P78 numId=12 → renders "1." "2." "3." "4."
Workflow output problems:
- **P62 (new data breach clause under 五、保密)**: No numPr at all. Should be numId=9 to continue as "4."
- **P75 (转包 body text under new 八、转包)**: Got numId=11 (不可抗力's sequence!) → renders as "3." continuing 不可抗力's list. Should either have no numPr (single-paragraph section doesn't need numbering) or a fresh numId with start=1.
## Reviewer Checklist Item
When reviewing workflow output for contracts with section-level auto-numbering:
- [ ] Each new INS paragraph: does it need numPr? (Yes if sibling paragraphs in same section have numPr)
- [ ] If it has numPr: does numId match the correct section?
- [ ] If it lacks numPr: do sibling paragraphs in the same section have numPr? (If yes → missing, severity: critical)
- [ ] New standalone sections (like 转包): body paragraph should NOT inherit previous section's numId
## Root Cause
`add_clause` in ContractEditor clones the **adjacent paragraph's pPr**. When inserting after the last paragraph of section N (which has numId for section N), the new paragraph for section N+1 inherits section N's numId. The library's A-type strip logic removes numPr for "standalone clauses" but doesn't always fire correctly when the clone source has numPr.
@@ -0,0 +1,67 @@
# PDF 批注修复脚本(pymupdf/fitz)
## 用途
修复已生成的 PDF 合同批注中的颜色不一致和内容问题。
## 使用场景
- workflow 生成的 PDF 批注中 Highlight 和 Text 注解颜色不一致
- 批注内容过于空洞(只说"请核实"不给建议)
- 需要统一所有批注为同一颜色
## 修复脚本
```python
import fitz
UNIFIED_COLOR = [1.0, 1.0, 0.0] # 统一黄色
doc = fitz.open("input.pdf")
for page_num in range(doc.page_count):
page = doc[page_num]
for annot in page.annots():
# 1. 统一颜色
current = annot.colors['stroke']
if current != UNIFIED_COLOR:
annot.set_colors(stroke=UNIFIED_COLOR)
annot.update()
# 2. 修复 Text 注解内容(按需)
if annot.type[0] == 0: # Text annotation
content = annot.info.get("content", "")
if "请核实" in content and "建议" not in content:
# 替换为有实质内容的建议
new_content = fix_annotation_content(content)
rect = annot.rect
color = annot.colors['stroke']
page.delete_annot(annot)
new_annot = page.add_text_annot(rect.tl, new_content)
new_annot.set_colors(stroke=color)
new_annot.set_info(title="WB")
new_annot.update()
doc.save("output.pdf")
doc.close()
```
## 验证
```python
# 验证所有批注颜色一致
doc = fitz.open("output.pdf")
colors = set()
for page in doc:
for annot in page.annots():
colors.add(tuple(annot.colors['stroke']))
assert len(colors) == 1, f"Found {len(colors)} different colors"
doc.close()
```
## 注意事项
- pymupdf 的 `annot.info["content"]` 修改后需要 `delete_annot` + `add_text_annot` 重建才能生效
- 直接设置 `annot.info["content"] = new_value` + `annot.update()` 对某些 PDF 不生效
- 颜色统一后必须清 OnlyOffice 缓存:`docker exec nextcloud-onlyoffice-1 bash -c "rm -rf /var/lib/onlyoffice/documentserver/App_Data/cache/files/data/*"`
## 2026-06-29 实证
医疗急救中心救护车采购合同 PDF,14对批注中6对颜色不一致(Highlight 黄色 + Text 红色)。修复后全部统一为黄色。同时修复了2处空洞批注:
- "验收方式未勾选,请选择(1)或(2)" → "验收方式未勾选,建议选择第(1)种方式(买方收货后自行检查验收)"
- "质量保证期月数未填写,请填写具体月数" → "质量保证期月数未填写,请根据招标文件要求填写具体月数"
@@ -0,0 +1,44 @@
# "二选一"条款选择编号不同步——终审遗漏案例
## 日期
2026-06-10
## 合同
青浦精神卫生-委托检验协议(修).docx
## 问题
第十条(纠纷的解决)采用"按以下第__种方式解决(填选1或2,只能二选一)"格式:
- 第1种:仲裁(Crystall已填入"上海仲裁委员会")
- 第2种:法院(原文为"乙方所在地")
Reviewer要求改为甲方所在地法院(R1-001 critical)。
Editor正确修改了P68(第2种方式:乙方→甲方,提出→提起),但**漏改P66的选择编号**。
P66 accepted text修改前:"协商不能解决的,双方同意按以下第 **1** 种方式解决:"
P68 accepted text:"2、双方同意向**甲方**所在地有管辖权的人民法院提**起**诉讼。"
矛盾:形式上选了第1种(仲裁),但实际意图是第2种(法院)。
## 通过了哪些检查仍未发现
- classifier ✅
- reviewer第1轮 ✅(发现管辖问题)
- editor ✅(改了法院条款但漏改选择编号)
- reviewer第2轮复核 ✅(验证了法院条款修改正确,漏查选择编号)
- reviewer第3轮复核 ✅(只查了登陆→登录的新修订)
- deliverer终审10项 ✅
- 全部6个workflow步骤通过,交付到任务交付目录
## 修复
WB DEL("1") + WB INS("2"),替换Crystall原来的INS("1")。
## 根因
Reviewer的R1-001 suggested_fix只说"删除仲裁选项,将争议解决条款修改为甲方所在地法院",
没有明确提到要改选择编号。Editor按字面意思只改了法院条款内容。
复核轮只验证了"甲方所在地"是否存在,没有回溯到选择编号。
## 教训
"二选一"格式的合同条款,修改选项内容时必须同步修改选择编号。
这是reviewer和editor都需要检查的:
- Reviewer:suggested_fix必须包含"将选择编号从X改为Y"
- Editor:执行时除了改选项内容,还要找到选择编号并修改
- Reviewer复核:必须验证选择编号指向的是正确的选项
@@ -0,0 +1,109 @@
# Handling Versioned Files with Pre-Existing Tracked Changes
## Problem
Contract files versioned as v1.1, v1.2, etc. typically contain tracked changes (`w:ins`, `w:del`) from other authors (e.g. 祺帆, 社区, 俊玮 吴). These must be preserved per review-rules ("已有他人的修订模式保持原样不动").
## Critical Issue: python-docx `.text` vs Actual Content
**python-docx `paragraph.text` does NOT include text from `w:ins` elements.**
This means reading the contract via python-docx shows the *original* text (before others' modifications), not the *accepted state* (what the document actually says after accepting all changes). This leads to:
- Identifying issues that have already been fixed
- Missing the actual current state of clauses
- Wrong `tracked_replace` targets (searching for text that no longer exists in rendered form)
### Correct Approach: lxml XML Parsing for Accepted State
```python
def get_para_full_text(p):
"""Get paragraph text in 'accepted' state (includes w:ins, excludes w:del)"""
ns_w = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
text = ''
for t in p.iter(f'{{{ns_w}}}t'):
if t.text:
# Check if inside a w:del - skip deleted text
parent = t.getparent()
in_del = False
while parent is not None:
if parent.tag in (f'{{{ns_w}}}del', f'{{{ns_w}}}delText'):
in_del = True
break
parent = parent.getparent()
if not in_del:
text += t.text
return text
```
**First step when receiving a versioned file**: Always use this function to read the contract's actual state before starting review.
## `tracked_replace` Failures Near Other Authors' Changes
### Symptoms
- `tracked_replace` returns `False` (text not found)
- `ValueError: Element is not a child of this node` when the target text spans or touches a `w:ins` from another author
### Root Cause
`tracked_replace` searches for text in regular `w:r` runs. Text inside `w:ins` from other authors is in a different element hierarchy — the runs are children of `w:ins`, not direct children of `w:p`.
### Workarounds
1. **Text not found**: The accepted-state text differs from what `tracked_replace` searches. Re-check what the actual run text says (without ins content) and target that.
2. **Double-period pattern** (2026-07-06 赵巷健康云):
- Original: `"调解不成则。"` (run ends with period)
- Other author's ins: `"向甲方所在地法院提起诉讼。"` (also ends with period)
- Rendered: `"调解不成则向甲方所在地法院提起诉讼。。"` (double period)
- Fix: Manually create `w:del` for the orphaned original period run:
```python
import copy
from lxml import etree
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
# Find the trailing period run (last regular w:r in paragraph)
runs = [c for c in paragraph if c.tag == f'{{{W}}}r']
last_run = runs[-1] # verify it's "。"
# Create w:del tracked change
rev_id = str(editor._next_id())
del_el = etree.Element(f'{{{W}}}del')
del_el.set(f'{{{W}}}id', rev_id)
del_el.set(f'{{{W}}}author', 'WB')
del_el.set(f'{{{W}}}date', editor._revision_date)
del_run = etree.SubElement(del_el, f'{{{W}}}r')
orig_rpr = last_run.find(f'{{{W}}}rPr')
if orig_rpr is not None:
del_run.append(copy.deepcopy(orig_rpr))
del_text = etree.SubElement(del_run, f'{{{W}}}delText')
del_text.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
del_text.text = ''
# Replace run with del element at same position
idx = list(paragraph).index(last_run)
paragraph.remove(last_run)
paragraph.insert(idx, del_el)
```
## Font Verification: Mixed-Attribute Paragraphs
When a paragraph has runs with mixed `rFonts` attributes (some `eastAsia=None`, some `eastAsia=宋体`), the WB INS inherits from the _adjacent_ run. If that adjacent run has `ea=宋体` but the paragraph's first run has `ea=None`, the font-verify script reports a false-positive EASTASIA MISMATCH.
**Fix**: Set the WB INS rFonts to match the immediately preceding original run (the one `tracked_replace` copied from). If the preceding run has `ea=None`, remove the `eastAsia` attribute from the WB INS:
```python
rfonts = ins_run_rpr.find(f'{{{W}}}rFonts')
if f'{{{W}}}eastAsia' in rfonts.attrib:
del rfonts.attrib[f'{{{W}}}eastAsia']
```
## Workflow: Review Checklist for Versioned Files
1. **Read accepted state** via lxml (not python-docx `.text`)
2. **Identify existing change authors** — know what's already been modified
3. **Re-assess issues** — many may already be resolved by prior revisions
4. **Target tracked_replace carefully** — use the raw run text, not accepted-state text
5. **Handle edge cases manually** — double periods, text spanning ins boundaries
6. **Verify font** — expect false positives in mixed-attribute paragraphs
@@ -0,0 +1,70 @@
# Pre-Modified File Detection (v1.x with Existing Tracked Changes)
## Problem
Files arriving as "v1.x" or similar versions may already contain tracked changes from other parties (e.g., the counterparty's legal team). The review-rules state: "已有他人的修订模式保持原样不动,不接受也不拒绝,只叠加我们自己的审查修改."
## Technical Impact
1. **python-docx `paragraph.text`** only shows non-tracked-change text (skips content inside `w:ins` elements), so it does NOT reflect the "accepted state" of the document
2. **ContractEditor.tracked_replace** searches through ALL text including `w:ins` content — search strings must match the accepted state
3. **ContractEditor.tracked_replace may fail** when the target text spans across or is inside an existing `w:ins` from another author (lxml parent-child mismatch)
## Detection Procedure
```python
from lxml import etree
ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
# 1. Detect existing tracked changes and identify authors
ins_els = editor.body.findall('.//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}ins')
authors = set()
for ins in ins_els:
author = ins.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}author', '')
if author:
authors.add(author)
# If authors contains names other than 'WB', the file is pre-modified
```
## Reading the Accepted State
```python
def get_para_accepted_text(p):
"""Get paragraph text as it would appear with all changes accepted."""
text = ''
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
for t in p.iter(f'{{{W}}}t'):
if t.text:
parent = t.getparent()
in_del = False
while parent is not None:
if parent.tag == f'{{{W}}}del':
in_del = True
break
parent = parent.getparent()
if not in_del:
text += t.text
return text
```
## Workflow When Pre-Modified File Detected
1. **First pass**: Read accepted state of all key paragraphs to understand current document content
2. **Compare** accepted state vs what python-docx `.paragraphs[].text` shows to identify what's been changed
3. **Assess** which of our review issues have already been addressed by the existing modifications
4. **Only add** our own changes for issues NOT already covered
5. **If tracked_replace fails** on text inside another author's `w:ins`: the text is already part of someone else's tracked change. Options:
- Skip if the existing change already addresses our concern
- Use direct XML manipulation to add our `w:del` + `w:ins` around the problematic text (complex, error-prone)
- Note it as "already addressed by [author]" in review notes
## 2026-07-06 Case Study (健康云服务费-赵巷v1.2)
- File arrived with tracked changes from authors: 祺帆, 社区, 俊玮 吴
- python-docx showed P59 as "2026年1月20日" but accepted state was "2026年11月20日" (already fixed by 祺帆)
- P26 (1.4 侵权条款) already had robust language added by 祺帆
- P109 (7.4) already had comprehensive损失赔偿 language added by existing changes
- P115 (8.2) had double period due to 祺帆's ins adding "向甲方所在地法院提起诉讼。" after original "则。" — required manual XML w:del to fix the trailing original period
- P118 (9.1) already had 转包连带责任 language added
**Key lesson**: Without reading accepted state first, we would have duplicated 5+ modifications already made by other parties.
@@ -0,0 +1,42 @@
# 同模板合同一致性规则(2026-07-02 朱家角恭兴+肃言案确立)
## 问题
同一顾问单位同批送审多份同模板合同(标题、条款结构高度相似,仅乙方名称和商业条款不同),workflow串行独立审查导致修订不一致:
- 恭兴:发现6.4条(药监局法规过时)但遗漏7.3条侵权兜底
- 肃言:发现7.3条但遗漏6.4条
## 根因
1. relay-runner串行处理,每份合同完全独立,session间零状态共享
2. LLM对同一段文字的注意力分配每次不同,问题发现有随机性
3. 审查意见生成无固定脚本,格式/字体靠临场写代码(概率性遗漏)
## 规则
### 修订一致性
- 模板级问题(称谓统一、错别字、法规引用过时、兜底条款缺失等)→ 所有同模板合同**全部**修订
- 个案问题(金额不一致、设备清单差异等)→ 按各合同实际情况处理
- 批注也必须统一:同模板合同的通用性批注(如设备清单"请注意确认金额")每份都加
### 审查意见一致性
- 行顺序按条款号排列
- 同模板的公共修订行内容完全一致
- 个案行(如恭兴金额问题)按实际情况添加
- 字体统一:中文仿宋、英文Times New Roman
- 不做理由说明,只写原文和修订后的内容(包括批注内容)
### 审查意见格式(以review-rules.md为准)
- 使用对应顾问单位的模板
- 标题用合同正文中的正式名称填入《》
- 有修改意见时删除"无法律修改意见。"
- 保留"审查意见:"标题和签名
- 批注内容也要体现在审查意见表格中
### reviewer检查机制
1. 审查前先看同一目录下是否有其他同模板文件
2. 如已有同模板合同审查完毕:提取其tracked changes列表作为baseline,本次至少覆盖这些点
3. 如同模板尚未审查:在notes中标注供后续对齐
## 优化方向(待落实)
- classifier阶段增加batch检测
- 审查意见生成脚本化(固定字体,不靠LLM临场写代码)
- final_review增加跨合同一致性验证和审查意见字体验证
@@ -0,0 +1,53 @@
# Tracked Replace 相邻文本检查(2026-06-26 CT维保合同教训)
## 问题场景
R1 reviewer 发现「造与」→「造成」的 typo(R1-009),editor 执行 tracked_replace 修复。R2 reviewer 复核时发现修复后的文本为「造成济损失」——缺少「经」字,应为「造成经济损失」。
## 根因
原文为「造与与济损失」——**双重 typo**:
1. 「造与」→ 应为「造成」(R1-009 已发现)
2. 「济损失」→ 应为「经济损失」(**R1 reviewer 未发现**)
Editor 修复了 R1-009 后,渲染文本为「造成济损失」,第二个 typo 仍然存在。
## 教训
**tracked_replace 不是「只检查那一处」的信号**。当 editor 在一个段落内执行了 tracked_replace 后,reviewer 复核时必须:
1. **提取整个段落的「接受修订后」渲染文本**`accept_revisions_text`),不只看 track change 标记
2. **逐字通读整个段落**,检查是否有:
- 与被修复 typo 相邻的**其他 typo**(如本例「济损失」缺「经」)
- tracked_replace 操作**意外引入**的新错误(如删多了字、删错了位置)
- 双重 DEL 导致的**文本碎片化**(如「造[-与-]成[- -][-与-]济损失」)
## 验证方法
```python
# 对每个有 WB track changes 的段落,提取接受修订后的完整文本
def accept_revisions_text(p):
result = []
for elem in p.iter():
if elem.tag == qn('delText'):
continue
if elem.tag == qn('t'):
parent = elem.getparent()
if parent is not None and parent.tag == qn('del'):
continue # skip runs inside w:del
if elem.text:
result.append(elem.text)
return ''.join(result)
# 复核时对每个被修改的段落,输出完整渲染文本
for pi in modified_paragraphs:
text = accept_revisions_text(paras[pi])
print(f"P{pi}: {text}")
```
## 审查清单补充
复核轮的文字校对,在「逐字通读」基础上,对**每个有 WB track changes 的段落**额外执行:
- 全文输出该段落的接受修订后文本
- 人工逐字通读,检查是否有遗漏的 typo
- 特别注意 tracked_replace 操作的**边界**(DEL/INS 前后的原文 run)
@@ -0,0 +1,50 @@
# WB INS Font Fix + Bold-Stripping Disaster (2026-06-10)
## Problem
Doro rejected 【修】青浦精神卫生-委托检验协议(修).docx: WB insertions displayed in wrong font.
Terminal review passed 10 checks but missed basic font inconsistency.
## Root Cause
`contract_docx_lib.py`'s `_ensure_rfonts_complete()` only filled hAnsi/cs, missing:
1. `w:eastAsia` — CJK falls back to docDefaults (Times New Roman vs 宋体)
2. `w:hint="eastAsia"` — required for CJK rendering priority
3. `w:sz` — not inherited inside `w:ins`; must be explicit
## Fix Applied (contract_docx_lib.py, 4 locations)
### 1. `_ensure_rfonts_complete()` — fill all 4 + hint
### 2. `_extract_formats()` — ensure sz on body_rpr/title_rpr
### 3. `tracked_replace()` — ensure sz on INS rpr
### 4. `tracked_replace()` — fallback for hint-only rFonts runs
See previous version of this file for code details.
## CRITICAL: Bold-Stripping Disaster
### What happened
First fix attempt: after noticing font issues, **batch-stripped ALL `<w:b/>` tags from all WB INS runs**. This destroyed the original bold formatting:
- P19: 风险律师费条件(原文加粗)→ stripped → not bold ✗
- P18: 金额部分(原文加粗)→ stripped → not bold ✗
- P30: "如若违反,律师费将另行重新计付"(原文加粗)→ stripped → not bold ✗
Doro: **"要保持原文的格式不变"**
### Why it was wrong
`tracked_replace` correctly inherits the original run's rPr INCLUDING bold. When the original text "在代理过程中..." was bold, the replacement text "风险律师费仅按..." SHOULD also be bold — that's correct format preservation.
### The rule
- `tracked_replace` INS inherits original run's rPr → **leave it alone**
- `add_clause` uses `_body_rpr` (no bold) or `_title_rpr` (bold) → **correct by design**
- **NEVER post-process WB INS to strip/add formatting attributes globally**
### Correct validation approach
Compare each WB INS run against the original run **in the same paragraph**, not against a global template. Use `scripts/wb-ins-font-verify.py` for document-agnostic validation.
## Verification Script
`scripts/wb-ins-font-verify.py <docx>` — per-paragraph comparison, checks rFonts/sz/hint, exit code 0=pass 1=fail. Document-agnostic (no hardcoded font names).
## Three font-source scenarios in tracked_replace
1. Run has explicit font names → `_ensure_rfonts_complete` fills gaps
2. Run has hint-only rFonts (no names) → fallback to `_body_rpr` font names
3. Run has no rPr at all → use `_body_rpr` directly
@@ -0,0 +1,44 @@
# wb-ins-font-verify.py 加粗检查输出解读
## 脚本输出格式
```
PASS: all N WB INS runs font-consistent, M title(s) bold-consistent
```
两个指标独立判断:
- **font-consistent**:`w:rFonts` 四属性 + `w:hint` + `w:sz` 与原文一致
- **bold-consistent**:`w:b` 状态与原文同级标题一致(仅对标题段落检查)
## "0 title(s) bold-consistent" 的含义
**不要假设"0 titles = 没有标题需要检查"。**
脚本自动检测标题段落(基于编号模式如"第X条""X.XXX"等),但检测策略可能漏检。输出 `M title(s) bold-consistent` 时,M 表示**通过加粗检查的标题数**。
- `M > 0`:有 M 个标题通过了加粗检查
- `M = 0`:**需要人工复核**——可能是:
- (a) 脚本未检测到任何标题段落(检测策略遗漏)
- (b) 所有检测到的标题都未通过加粗检查
- (c) 确实没有标题(纯内容合同)
**处理方式**:当 `M = 0` 时,reviewer 必须手动逐条检查所有 WB INS 新增标题段落的加粗状态。
## 强制手动加粗检查(当 M=0 时)
1. 提取所有 WB INS 新增段落(`w:ins[@author='WB']` 所在的 `<w:p>`
2. 判断哪些是标题段落(含编号如"第X条""X."等)
3. 对每个标题段落,提取其 INS run 的 `w:rPr`,检查是否含 `<w:b/>`
4. 与相邻原文标题段落的 rPr 对比(取最近一个原文标题段落)
5. 不一致的标记为 format issue(severity: major)
## 实例(2026-06-27 朱家角标识标牌合同)
脚本输出:`PASS: all 17 WB INS runs font-consistent, 0 title(s) bold-consistent`
- P84 "19.知识产权" 标题为 WB INS 段落
- P82 "18.合同转让和分包"(原文标题)rPr 含 `<w:b/>`
- P84 INS run 的 rPr **缺少** `<w:b/>`
- 脚本未将 P84 识别为标题段落(或 bold check 覆盖不到),`M=0` 未报警
**结论**`M=0` 时必须手动逐条检查。不能因为脚本 PASS 就跳过加粗验证。
@@ -0,0 +1,67 @@
# wb-ins-font-verify.py 误报场景与处理
## 场景:混合内容段落的参考run匹配偏差
### 触发条件
当段落同时包含数字/英文前缀和中文正文时,脚本的"首个非trivial原文run"匹配策略可能选错参考run。
### 实例(2026-06-26 朱家角可降解环保袋服务合同)
P79 段落结构:
```
原文run: "7.2" → hint=None (数字前缀)
原文run: "因火灾..." → hint=eastAsia
原文run: "10" → hint=eastAsia
原文run: "内" → hint=eastAsia
原文run: "提交政府..." → hint=eastAsia
WB INS: "日" → hint=eastAsia ← 插入在中文文本中间
```
脚本匹配到第一个非trivial原文run "7.2"(hint=None),判定WB INS "日"(hint=eastAsia)为HINT MISMATCH。
**实际**:"日"插入在中文文本中间,相邻run均为hint=eastAsia,格式完全正确。
### 判断方法
`wb-ins-font-verify.py`报告HINT MISMATCH时,做以下三件事:
1. **检查段落结构**:该段落是否同时包含数字/英文前缀和中文正文?
2. **检查相邻run**:WB INS的相邻原文run(前后各一个)的hint值是什么?
3. **判断**:如果相邻原文run的hint与WB INS一致,则为脚本误报——WB INS的hint与周围中文文本一致是正确的。
### 不适用场景
以下情况NOT误报,需作为真实格式问题处理:
- 所有原文run的hint一致(如全部为eastAsia),但WB INS的hint不同
- 新增段落(无原文run可比),WB INS缺hint
- 同一段落内所有原文run字体一致,WB INS字体不同
## 场景二:段落内原文run属性混合(ea=None vs ea=仿宋)
### 触发条件(2026-06-29 卫生信息平台运维合同)
当段落由多个来源的文本拼接(如合同模板+填充内容),原文run的`eastAsia`/`ascii`/`sz`属性可能不一致:
```
原文run[ea=None sz=None]: "为了保障上海市青浦区..." (模板标题部分)
原文run[ea=None sz=None]: "维管理"
原文run[ea=仿宋 sz=21]: "服务外包的形式组织各" (正文部分)
原文run[ea=仿宋 sz=21]: "运维服务方"
del[WB]: 针
ins[WB][ea=仿宋 sz=21]: 面 ← 插入在仿宋文本中间
原文run[ea=仿宋 sz=21]: ",对上海市..."
```
脚本匹配到第一个非trivial原文run(ea=None sz=None),判定WB INS(ea=仿宋 sz=21)为EASTASIA/SZ MISMATCH。
**实际**:WB INS与相邻的仿宋run完全一致,格式正确。段落前半部分ea=None是因为那些run继承默认样式,不代表段落整体字体是None。
### 判断方法
当脚本报告EASTASIA/ASCII/SZ MISMATCH时:
1. **检查段落全部原文run的属性**:是否部分run有显式属性、部分没有?
2. **定位WB INS的相邻run**:前后各一个原文run的属性是什么?
3. **判断**:如果WB INS与相邻run一致,则为误报——脚本选了错误的参考run。
### 结论
脚本的"首个非trivial原文run"匹配策略在属性混合段落中可能产生误报。reviewer复核时需结合段落结构和相邻run上下文判断,不可盲信脚本输出。
@@ -0,0 +1,98 @@
"""
Document-agnostic WB INS font verification script.
Compares each WB insertion's rPr against adjacent original runs in the same paragraph.
Works for any font (宋体, 仿宋, etc.) and any sz value.
Usage: python wb-ins-font-verify.py <docx_path>
Or import verify_wb_ins_fonts(path) → returns (issues: list[str], total: int)
"""
import zipfile, io, sys
from lxml import etree
def qn(tag):
return '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}' + tag
def get_rpr_attrs(rpr):
if rpr is None:
return {'ea': None, 'ascii': None, 'hint': None, 'sz': None, 'bold': False}
rfonts = rpr.find(qn('rFonts'))
sz = rpr.find(qn('sz'))
b = rpr.find(qn('b'))
return {
'ea': rfonts.get(qn('eastAsia')) if rfonts is not None else None,
'ascii': rfonts.get(qn('ascii')) if rfonts is not None else None,
'hint': rfonts.get(qn('hint')) if rfonts is not None else None,
'sz': sz.get(qn('val')) if sz is not None else None,
'bold': b is not None,
}
def verify_wb_ins_fonts(docx_path):
with open(docx_path, 'rb') as f:
raw = f.read()
with zipfile.ZipFile(io.BytesIO(raw)) as z:
tree = etree.fromstring(z.read('word/document.xml'))
body = tree.find(qn('body'))
issues = []
total = 0
for p in body.findall(qn('p')):
# Collect WB INS runs
wb_runs = []
for ins in p.findall('.//' + qn('ins')):
if ins.get(qn('author')) != 'WB':
continue
for r in ins.findall(qn('r')):
t = r.find(qn('t'))
if t is not None and (t.text or '').strip():
wb_runs.append((t.text[:50], r))
if not wb_runs:
continue
# Collect original (non-tracked) runs for comparison
orig_attrs = None
for r in p.findall(qn('r')):
parent = r.getparent()
if parent.tag in [qn('ins'), qn('del')]:
continue
t = r.find(qn('t'))
if t is not None and (t.text or '').strip():
orig_attrs = get_rpr_attrs(r.find(qn('rPr')))
break
for text, r in wb_runs:
total += 1
wb = get_rpr_attrs(r.find(qn('rPr')))
# Font name check: must have eastAsia and ascii set (not None)
if wb['ea'] is None:
issues.append(f"MISSING eastAsia: '{text}'")
if wb['ascii'] is None:
issues.append(f"MISSING ascii: '{text}'")
if wb['hint'] != 'eastAsia':
issues.append(f"MISSING hint=eastAsia: '{text}'")
if wb['sz'] is None:
issues.append(f"MISSING sz: '{text}'")
# Cross-check with original runs in same paragraph
if orig_attrs and orig_attrs['ea']:
if wb['ea'] and wb['ea'] != orig_attrs['ea']:
issues.append(f"FONT MISMATCH: '{text}' wb={wb['ea']} orig={orig_attrs['ea']}")
if wb['sz'] and orig_attrs['sz'] and wb['sz'] != orig_attrs['sz']:
issues.append(f"SIZE MISMATCH: '{text}' wb={wb['sz']} orig={orig_attrs['sz']}")
return issues, total
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Usage: python wb-ins-font-verify.py <docx_path>")
sys.exit(1)
issues, total = verify_wb_ins_fonts(sys.argv[1])
if issues:
print(f"FAIL: {len(issues)} issues in {total} WB INS runs")
for i in issues:
print(f" - {i}")
sys.exit(1)
else:
print(f"PASS: all {total} WB INS runs font-consistent")
@@ -0,0 +1,37 @@
# Workflow Monitoring Pitfalls — 2026-06-15
## auto_notify脚本挂掉不自知
- `auto_notify_new_file.sh` 依赖 `inotifywait` 持续监控,进程消失后无自动重启
- 6月12日后进程消失,6月15日凌晨5份合同完全未被检测到
- 教训:inotifywait类脚本需要进程守护机制(systemd service或cron watchdog)
## workflow suspended无人发现
- workflow因API 500错误suspended后,小Maggie未主动检查
- 规则:"启动后必须跟进到完成(不能启动了不管)"
- 必须有机制持续检查`uwf thread list`的状态
## workflow通知时序
- 旧版:deliverer先通知→final_review后终审→$END(不通知)
- 问题:终审rejected时Doro已收到虚假"完成"通知
- 修复:deliverer去掉通知,final_review approved后才通知
- workflow hash变更记录:DQVH5MWCGYXMX → 6EZEZJF8M15V6 → 6T8SCPEQJ7DWJ
## 禁止把Doro放入自动化流程
- Doro明确拒绝❌被加入cron监控或自动通知链
- 技术问题应自行解决,不能设计成"出问题就通知Doro"
## reviewer agent进程反复崩溃
- 端午节合同两次在reviewer阶段因agent进程崩溃失败("agent command failed")
- 非workflow逻辑问题,疑为API调用超时或内存问题
- 处理方式:重新启动thread(第三次成功),不修改workflow逻辑
## 串行调度脚本(auto_notify挂掉时的临时方案)
- 位置:`~/.hermes/scripts/run_contract_queue.sh`
- 用法:`bash run_contract_queue.sh "file1.docx" "file2.docx" ...`
- 逐份创建thread并同步执行,suspended自动重试一次,失败则跳过继续
- 日志:`/tmp/contract_queue.log`
- 等待当前运行中的thread完成后再启动队列:写wrapper脚本轮询`uwf thread show`的status直到非running
## contract_docx_lib.py bug修复记录(2026-06-15)
- **add_clause/add_clause_before numPr继承bug**:deepcopy相邻段落pPr时会连带numPr,导致新增独立条款错误继承上一条的子编号列表。修复:deepcopy后自动剥离numPr
- **签署页INS字体不一致**:editor照抄被替换文字的rPr(sz=24 bold=no),但签署页标签是sz=28 bold=YES。修复:final_review检查清单新增g项和h项
@@ -0,0 +1,79 @@
# Workflow交付物审查清单(2026-07-13 Doro要求逐份审查已交付合同)
## 触发场景
Doro要求"审查已经交付的合同有什么问题",逐份对照原文检查。
## 审查流程(铁律)
### 1. 准备
- 从NC待审查目录拉原文(.doc需libreoffice转docx)
- 从NC任务交付目录拉【修】文件
- 如有同模板合同,一并拉取比对
### 2. 字体审查
```bash
python3 ~/.hermes/skills/legal/contract-reviewer/scripts/wb-ins-font-verify.py <file>
```
- **PASS/FAIL只是初步信号**
- FAIL时区分:真实mismatch vs MISSING HINT(新增段落无orig对比,已知限制)
- **关键铁律:对比待审查原文的run属性,不信v1中的orig runs**(库可能污染了orig runs)
### 3. 内容审查(逐条打勾)
对照review-rules.md的10项审查清单:
1. 主体名称
2. 违约责任(上限删除、维权费用)
3. 争议管辖(甲方所在地法院)
4. 保密/数据(存续+泄露赔偿+数据归属)
5. 知识产权
6. 第三方侵权(全责+赔偿甲方)
7. 转包(不得转包+连带)
8. 价款(大小写核对)
9. 持续使用权
10. 条款逻辑
### 4. 编号审查
- 新增段落是否加入了正确的编号序列(numPr自动编号 or 手动文本编号)
- 后续编号是否顺延
- 新增段落插入自动编号序列时必须有numPr
### 5. 格式审查
- 新增标题段bold状态与原文一致
- 新增段落缩进(ind)与原文同级一致
- 注意原文自身格式不统一的情况(如有的段用start=420有的用firstLine=482)
### 6. 同模板一致性
- 同一顾问单位同批同模板合同的修订必须一致
- 差异逐条列出
### 7. 特殊交付物
- 朱家角:需要【审】审查意见文档
- 练塘:需要"法律顾问修订版"脚注
## 报告格式
```
## 【修】合同名称
**字体:PASS/FAIL**
**编号:完整/问题**
**修订内容(N处):**
- 逐条列出
**问题:**
1. ❌ 严重问题
2. ⚠️ 次要问题
**结论:** 无问题 / 需修复
```
## 2026-07-13 审查教训
1. **v2文件可能是workflow错误产出**:同一合同出现v1+v2时,必须分别检查内容,不能假设v2是v1的接受版——可能是完全不同的交付策略(如v1有修订、v2只有批注)
2. **他人修订(非WB)不能漏看**:如"梁一"已插入"香花桥"修正名称,审查时要看完整markup包括他人INS
3. **ContractEditor污染orig runs是系统性问题**:洋励案120处、舜葵案18处。审查时凡遇font FAIL,先对比待审查原文确认是"INS缺属性"还是"orig被加属性"
4. **一份一份做,Doro说pass再下一份**:不要批量报告,逐份等确认
5. **Doro说"你自己决定"时果断决定**:不要反复请示低风险决策(如数据归属合并到保密条款 vs 独立编号)
@@ -0,0 +1,47 @@
# WPS Format Handling & Renumber Order Pitfall
## WPS File Conversion
邱律师有时发送 `.wps` 格式文件(WPS Office 原生格式)。python-docx 无法直接读取。
**转换命令:**
```bash
libreoffice --headless --convert-to docx "/path/to/file.wps" --outdir /tmp/contract-review/
```
**注意事项:**
- 转换后文件名保留原名但扩展名变为 .docx
- 转换后仍需复制为简短ASCII文件名避免路径问题
- 交付时文件名前加【修】,扩展名用 .docx(不恢复为 .wps)
- 设备清单可能是图片而非文字表格(WPS特有),需用vision检查 word/media/ 中的图片
## tracked_replace + add_clause 编号顺延的执行顺序
**铁律:先做所有 add_clause/add_clause_before 插入,最后统一 tracked_replace 重编号。**
**错误顺序(会报 lxml ValueError):**
```python
editor.add_clause_before('第七条 转包', before_search='第七条不可抗力')
editor.tracked_replace('第七条不可抗力', '第八条不可抗力') # ✅ OK
editor.add_clause_before('第八条 侵权', before_search='第八条不可抗力')
editor.tracked_replace('第八条不可抗力', '第九条不可抗力') # ❌ FAIL - paragraph already modified
```
**正确顺序:**
```python
# 1. 先做所有插入(用原文文本定位)
editor.add_clause_before('第七条 转包\n...', before_search='第七条不可抗力')
editor.add_clause_before('第八条 侵权\n...', before_search='第七条不可抗力')
# 注意:两个都用原文"第七条不可抗力"定位,因为原文段落文本未变
# 2. 最后统一重编号(此时原文段落只被rename一次)
editor.tracked_replace('第七条不可抗力', '第九条不可抗力')
editor.tracked_replace('第八条争议解决', '第十条争议解决')
# ...
```
**根因:** `tracked_replace` 会把原文 run 删除并插入 `w:del` + `w:ins`,修改后的段落内部结构已变。再次对同一段落调用 `tracked_replace` 时,lxml 尝试 remove 已不在原位的 element,抛出 `ValueError: Element is not a child of this node`
## 2026-07-13 实证
练塘硬件购销合同(璞石医疗):新增第七条转包+第八条第三方侵权,原第七至十条顺延为第九至十二条。第一次尝试"插一个改一个"失败,改为"先全部插入再统一改编号"成功。
@@ -0,0 +1,95 @@
# x2t 视觉验证与 OOXML 文本提取
## 为什么用 x2t 而不是 libreoffice
Doro 使用 OnlyOffice 查看交付物。libreoffice 和 OnlyOffice 使用不同的渲染引擎,同一 docx 在两者中可能显示不同(如页数、换行位置)。x2t 是 OnlyOffice Document Server 的内置转换器,渲染结果与 Doro 看到的完全一致。
## x2t 转换命令
```bash
# 1. 复制文件到 OnlyOffice 容器
docker cp /path/to/contract.docx nextcloud-onlyoffice-1:/tmp/check.docx
# 2. 创建转换任务 XML
cat > /tmp/convert_task.xml << 'XMLEOF'
<?xml version="1.0" encoding="utf-8"?>
<TaskQueueDataConvert>
<m_sFileFrom>/tmp/check.docx</m_sFileFrom>
<m_sFileTo>/tmp/check.pdf</m_sFileTo>
<m_nFormatTo>513</m_nFormatTo>
<m_sFontDir>/usr/share/fonts</m_sFontDir>
<m_bIsNoBaseCss>false</m_bIsNoBaseCss>
</TaskQueueDataConvert>
XMLEOF
# 3. 执行转换
docker cp /tmp/convert_task.xml nextcloud-onlyoffice-1:/tmp/convert_task.xml
docker exec nextcloud-onlyoffice-1 /var/www/onlyoffice/documentserver/server/FileConverter/bin/x2t /tmp/convert_task.xml
# 4. 取回 PDF
docker cp nextcloud-onlyoffice-1:/tmp/check.pdf /tmp/contract-review/check.pdf
# 5. 转图片查看(可选)
pdftoppm -png -r 150 /tmp/contract-review/check.pdf /tmp/contract-review/page
```
## x2t 渲染模式说明
x2t 转换时**保留 track changes 标记**(即"标记模式"),不会自动接受修订。这意味着:
- 删除的文本显示为 strikethrough
- 新增的文本显示为 underline
- `pdftotext` 提取时会同时输出旧文本和新文本(因为它无法区分视觉标记)
**判断方法**:看到 `pdftotext` 输出中同时出现新旧编号(如"5.6.包装要求"),不代表合同有重复内容——这是 track changes 标记模式的正常显示。
## 接受修订后的文本提取(OOXML 级别)
当需要提取"接受所有修订后"的纯文本时,使用以下函数:
```python
import zipfile
from lxml import etree
def qn(tag):
return '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}' + tag
def accept_revisions_text(p):
"""接受所有修订,返回段落纯文本"""
result = []
for child in p:
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag == 'r':
# 跳过包含 delText 的 run(已删除文本)
if child.find(qn('delText')) is not None:
continue
t = child.find(qn('t'))
if t is not None:
result.append(t.text or '')
elif tag == 'del':
continue # 跳过整个删除元素
elif tag == 'ins':
# 包含插入元素中的文本
for r in child.findall(f'.//{qn("r")}'):
t = r.find(qn('t'))
if t is not None:
result.append(t.text or '')
return ''.join(result)
```
### 与标记模式文本提取的区别
| 函数 | 用途 | 输出示例 |
|------|------|----------|
| `accept_revisions_text` | 看最终效果 | "6.包装要求" |
| 含标记的提取 | 看修订过程 | "[DEL:5.][INS:6.]6.包装要求" |
| x2t pdftotext | 视觉验证 | "5.6.包装要求"(标记模式) |
**复核轮必用 `accept_revisions_text`**:验证 editor 的修改在最终文本中是否正确,不受 track changes 标记干扰。
## 常见误判
1. **pdftotext 显示"重复编号"**:如"5.6.包装要求"——这是 DEL "5." + INS "6." 的标记模式显示,不是合同错误。用 `accept_revisions_text` 验证最终文本。
2. **`get_text_from_element` 显示重复**:如果函数同时提取 INS 文本和原始文本,会导致看起来像重复。必须区分"标记模式提取"和"接受修订后提取"。
3. **x2t 转换需要容器内字体**:如果 x2t 输出字体异常,检查容器内是否有中文字体(`fc-list :lang=zh`)。
@@ -0,0 +1,66 @@
# YAML Frontmatter `issues_json` 嵌入陷阱(2026-06-26)
## 问题
Reviewer 输出 YAML frontmatter 时,`issues_json` 字段需要包含一个 JSON 数组字符串。如果直接写成:
```yaml
issues_json: [{"id":"R1-001",...}]
```
YAML 解析器会将 `[{...}]` 解析为 **YAML 原生列表**(因为 YAML 是 JSON 的超集),而不是字符串。下游 workflow 取到的 `issues_json` 类型是 `list` 而非 `str`,导致 `json.loads()` 失败。
## 根因
YAML 1.2 规范明确:YAML 是 JSON 的严格超集。任何合法的 JSON 也是合法的 YAML,且会被解析为对应的原生类型(对象→mapping,数组→sequence),而非字符串。
## 解决方案
### 方案一:YAML literal block scalar(`|`)语法
```yaml
issues_json: |
[{"id":"R1-001","severity":"critical",...}]
```
- `|` 告诉 YAML 解析器将后续缩进内容视为字面字符串
- 字符串末尾会自动去除尾随换行
- JSON 内容不受 YAML 类型推断影响
### 方案二:单行引号字符串(降级方案)
当 workflow 引擎对 literal block scalar 解析不兼容时,使用单行转义字符串:
```yaml
issues_json: "[{\"id\": \"R3-001\", \"severity\": \"major\", ...}]"
```
- 用 Python `json.dumps(issues, ensure_ascii=False)` 生成 JSON 字符串
- 直接拼接到 YAML 行中:`issues_json: "{json_str}"`
- YAML 双引号字符串内,JSON 的双引号 `\"` 会被正确解析
### 方案三:Python 生成(最可靠)
```python
import json
issues_json = json.dumps(issues, ensure_ascii=False)
yaml_line = f'issues_json: "{issues_json}"'
```
## 验证
写入后必须验证 YAML 往返:
```python
import yaml, json
parsed = yaml.safe_load(yaml_str)
assert isinstance(parsed['issues_json'], str), f"Expected str, got {type(parsed['issues_json'])}"
reparsed = json.loads(parsed['issues_json'])
assert len(reparsed) == expected_count
```
## 其他注意事项
- `converted_filename` 为空时写 `''`(两个单引号),不要省略
- 不要在 frontmatter 中添加 schema 未定义的字段
- 所有中文字段值无需转义,`allow_unicode=True` 即可
@@ -0,0 +1,59 @@
# $status YAML Schema 参考(CAS 节点 2C1TEMP0YAHN9)
## 权威来源
CAS 节点 `2C1TEMP0YAHN9`(CBOR 编码),可用以下命令查看:
```bash
pip3 install cbor2
python3 -c "
import cbor2, json
d = cbor2.loads(open('/home/maggie/.ocas/nodes/2C1TEMP0YAHN9.bin','rb').read())
print(json.dumps(d['payload']['oneOf'], indent=2, ensure_ascii=False))
"
```
## 字段名确认为 `$status`(带 `$` 前缀)
CAS schema 中 `properties.$status.const` 确认字段名就是 `$status`,不是 `status`
## `$status: pass` 变体(5 个必填字段)
| 字段 | 类型 | 必填 |
|------|------|------|
| `$status` | const: "pass" | ✅ |
| `contract_file` | string | ✅ |
| `original_filename` | string | ✅ |
| `special_deliverables` | string | ✅ |
| `review_round` | integer | ✅ |
| `notes` | string | 可选 |
## `$status: needs_revision` 变体(11 个必填字段)
| 字段 | 类型 | 必填 |
|------|------|------|
| `$status` | const: "needs_revision" | ✅ |
| `contract_file` | string | ✅ |
| `original_filename` | string | ✅ |
| `ruleset_type` | string | ✅ |
| `rules_paths` | string | ✅ |
| `our_party_name` | string | ✅ |
| `our_party_role` | string | ✅ |
| `special_deliverables` | string | ✅ |
| `review_round` | integer | ✅ |
| `issues_json` | string | ✅ |
| `issue_count` | integer | ✅ |
## `$status: failed` 变体(2 个必填字段)
| 字段 | 类型 | 必填 |
|------|------|------|
| `$status` | const: "failed" | ✅ |
| `error` | string | ✅ |
## 恢复命令
```bash
uwf thread resume <thread-id> -p "YAML frontmatter must use '\$status: pass' (with dollar sign). Fix and continue."
```
## 注意
- 旧版(2026-06-26 早期)的"使用 `status` 不用 `$status`"结论是错误的——那是基于 LLM 推测,不是 CAS schema 实证
- 此文件 2026-06-26 已通过 CBOR 解析 CAS 节点实证修正
@@ -0,0 +1,34 @@
# 朱家角设备采购合同:新增主标题加粗必须人工对照原文
## 触发场景
- workflow 已经产出【修】文件;
- 需要在原文已有主条款之间插入新增主标题(如 `8.争端的解决``9.合同生效``10.合同附件`);
- `wb-ins-font-verify.py` 输出类似:
`PASS: all N WB INS runs font-consistent, 0 title(s) bold-consistent`
## 本次验证出的稳定规则
对这类采购合同,**原文主条款标题加粗,子条款正文不加粗**。因此:
### 需要加粗的新增主标题
- `8.争端的解决`
- `9.合同生效`
- `10.合同附件`
### 不需要加粗的新增内容
- `7.4` 保密/数据条款正文
- `7.5` 转包/分包条款正文
- `9.1``9.2`
- `10.1``10.2`
- `8.争端的解决` 下方正文段(“双方如在履行合同中发生纠纷……”)
## 操作要点
1. 先以待审查原文为准,抽查同层级原文标题的 bold 状态;
2. 不因脚本 PASS 就跳过标题加粗核查;
3. 当脚本出现 `0 title(s) bold-consistent` 时,必须逐条人工核 WB INS 的主标题;
4. 修复时只补主标题 INS run 的 `<w:b/>` / `<w:bCs/>`,不要把正文段一起加粗;
5. 修复后再次核对编号链与标题/正文层级是否一致。
## 结论句式
可直接写:
- 需要加粗:8.争端的解决、9.合同生效、10.合同附件;
- 不需要加粗:7.4、7.5、9.1、9.2、10.1、10.2 及新增正文段。
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Fix PDF annotation issues found in contract review deliverables.
Fixes:
1. Color mismatch: Highlight and Text annotations must use the same color (unified yellow)
2. Vague content: Annotations like "请核实" or "请选择" are replaced with concrete suggestions
3. Unified color: All annotations set to yellow [1.0, 1.0, 0.0]
Usage:
python3 fix_pdf_annotations.py <input.pdf> <output.pdf> [--unify-color] [--dry-run]
Requires: PyMuPDF (fitz)
"""
import fitz
import sys
import argparse
UNIFIED_COLOR = [1.0, 1.0, 0.0] # Yellow
def find_annotation_pairs(page):
"""Group Highlight and Text annotations by y-position into pairs."""
annots = list(page.annots())
if not annots:
return []
highlights = [(a, a.rect.y0) for a in annots if a.type[0] == 8]
texts = [(a, a.rect.y0) for a in annots if a.type[0] == 0]
pairs = []
for h, hy in highlights:
best_text = None
best_dist = 999
for t, ty in texts:
dist = abs(hy - ty)
if dist < best_dist:
best_dist = dist
best_text = t
if best_text:
pairs.append((h, best_text))
return pairs
def check_color_mismatch(pairs):
"""Return list of (highlight, text, h_color, t_color) where colors differ."""
mismatches = []
for h, t in pairs:
h_color = h.colors['stroke']
t_color = t.colors['stroke']
if h_color != t_color:
mismatches.append((h, t, h_color, t_color))
return mismatches
def unify_colors(page, pairs, color=UNIFIED_COLOR):
"""Set all annotations to the unified color."""
for h, t in pairs:
if h.colors['stroke'] != color:
h.set_colors(stroke=color)
h.update()
if t.colors['stroke'] != color:
t.set_colors(stroke=color)
t.update()
def fix_vague_annotations(page, pairs):
"""Flag annotations with vague content like '请核实' or '请选择'."""
vague_keywords = ['请核实', '请选择', '请确认并统一', '请填写具体']
flagged = []
for h, t in pairs:
content = t.info.get("content", "")
for kw in vague_keywords:
if kw in content:
flagged.append((t, content, kw))
break
return flagged
def recreate_text_annotation(page, old_annot, new_content, color=UNIFIED_COLOR):
"""Delete old text annotation and create a new one with updated content."""
rect = old_annot.rect
page.delete_annot(old_annot)
new_annot = page.add_text_annot(rect.tl, new_content)
new_annot.set_colors(stroke=color)
new_annot.set_info(title="WB")
new_annot.update()
return new_annot
def process_pdf(input_path, output_path, unify_color=True, dry_run=False):
"""Main processing: fix color mismatches and flag vague annotations."""
doc = fitz.open(input_path)
report = {"color_mismatches": 0, "vague_annotations": 0, "total_pairs": 0}
for page_num in range(doc.page_count):
page = doc[page_num]
pairs = find_annotation_pairs(page)
report["total_pairs"] += len(pairs)
# Check and fix color mismatches
mismatches = check_color_mismatch(pairs)
if mismatches:
report["color_mismatches"] += len(mismatches)
if not dry_run and unify_color:
unify_colors(page, pairs)
# Flag vague annotations
vague = fix_vague_annotations(page, pairs)
if vague:
report["vague_annotations"] += len(vague)
for annot, content, kw in vague:
print(f" ⚠️ P{page_num+1}: Vague annotation found: '{kw}' in '{content[:80]}'")
if not dry_run:
doc.save(output_path)
print(f"\n✅ Fixed {report['color_mismatches']} color mismatches")
print(f"⚠️ {report['vague_annotations']} vague annotations flagged (require manual review)")
print(f" Saved to: {output_path}")
else:
print(f"\n[DRY RUN] Would fix {report['color_mismatches']} color mismatches")
print(f"[DRY RUN] {report['vague_annotations']} vague annotations flagged")
doc.close()
return report
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Fix PDF annotation issues")
parser.add_argument("input", help="Input PDF path")
parser.add_argument("output", help="Output PDF path")
parser.add_argument("--unify-color", action="store_true", default=True,
help="Unify all annotation colors to yellow (default: True)")
parser.add_argument("--dry-run", action="store_true",
help="Report issues without modifying the file")
args = parser.parse_args()
report = process_pdf(args.input, args.output, args.unify_color, args.dry_run)
sys.exit(0 if report["vague_annotations"] == 0 else 1)
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Verify WB INS font consistency against same-paragraph original runs.
Usage: python wb-ins-font-verify.py <docx_path>
Document-agnostic: doesn't hardcode font names — compares each WB INS run
against the nearest original (non-tracked) run in the same paragraph.
Checks: rFonts (eastAsia, ascii), w:sz, w:hint, and bold consistency.
Exit code 0 = pass, 1 = issues found.
"""
import sys, zipfile, io
from lxml import etree
def qn(tag):
return '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}' + tag
def get_rpr_info(rpr):
if rpr is None:
return {'ea': None, 'ascii': None, 'hint': None, 'sz': None, 'bold': False}
rfonts = rpr.find(qn('rFonts'))
sz = rpr.find(qn('sz'))
b = rpr.find(qn('b'))
return {
'ea': rfonts.get(qn('eastAsia')) if rfonts is not None else None,
'ascii': rfonts.get(qn('ascii')) if rfonts is not None else None,
'hint': rfonts.get(qn('hint')) if rfonts is not None else None,
'sz': sz.get(qn('val')) if sz is not None else None,
'bold': b is not None,
}
def main(docx_path):
with zipfile.ZipFile(io.BytesIO(open(docx_path, 'rb').read())) as z:
tree = etree.fromstring(z.read('word/document.xml'))
body = tree.find(qn('body'))
issues = []
total = 0
# Use recursive search to find ALL paragraphs, including those inside tables.
# Many Chinese contracts (esp. government templates) nest body text inside w:tbl.
# body.findall(qn('p')) only gets direct children and misses table content entirely.
for pi, p in enumerate(body.findall('.//' + qn('p'))):
# Collect WB INS runs
wb_runs = []
for ins in p.findall('.//' + qn('ins')):
if ins.get(qn('author')) != 'WB':
continue
for r in ins.findall(qn('r')):
t = r.find(qn('t'))
if t is not None and (t.text or '').strip():
wb_runs.append((t.text, r))
if not wb_runs:
continue
# Collect original (non-tracked) runs in same paragraph
orig_info = None
for r in p.findall(qn('r')):
parent = r.getparent()
if parent.tag in [qn('ins'), qn('del')]:
continue
t = r.find(qn('t'))
if t is not None and (t.text or '').strip():
orig_info = get_rpr_info(r.find(qn('rPr')))
break # first non-trivial original run
for text, r in wb_runs:
total += 1
wb_info = get_rpr_info(r.find(qn('rPr')))
short = text[:50]
# PRIMARY STANDARD: WB INS run must match the same-paragraph original run.
# Do NOT impose an absolute "must have explicit eastAsia/ascii" rule — many
# Chinese government templates (e.g. 教育部 GF-2021 校外培训合同) define CJK
# fonts via hint="eastAsia"+cs WITHOUT explicit eastAsia/ascii attrs. A correctly
# inherited single-char replacement in such a doc has ea=None/ascii=None and is
# CORRECT — flagging it "MISSING FONT" is a false positive (2026-06-17 教训).
if orig_info is not None:
# Compare against original: ea, ascii, hint, sz must all match the orig run.
for key, label in [('ea','eastAsia'),('ascii','ascii'),('hint','hint'),('sz','sz')]:
if wb_info[key] != orig_info[key]:
issues.append(f"P{pi} {label.upper()} MISMATCH vs同段原文: '{short}' wb={wb_info[key]} orig={orig_info[key]}")
else:
# No original run to compare (fully-new paragraph). Require hint present
# (CJK safety) but don't hard-require explicit ea/ascii.
if not wb_info['hint']:
issues.append(f"P{pi} MISSING HINT (无同段原文可比): '{short}'")
# Phase 2: Title bold consistency check
# Collect all "第X条" title patterns and verify bold consistency
import re
title_bolds = {} # paragraph_index -> bold status of "第X条" text
for pi, p in enumerate(body.findall(qn('p'))):
for elem in p.iter():
if elem.tag == qn('t') and elem.text:
if re.match(r'^第[一二三四五六七八九十百千\d]+条', elem.text.strip()):
# Find parent run's bold status
run = elem.getparent()
if run is not None and run.tag == qn('r'):
rpr = run.find(qn('rPr'))
bold = rpr.find(qn('b')) is not None if rpr is not None else False
title_bolds[pi] = bold
elif run is not None and run.tag == qn('ins'):
# Inside w:ins — check the r inside
pass
# Also check inside ins elements
if elem.tag == qn('ins') and elem.get(qn('author')) == 'WB':
for r in elem.findall(qn('r')):
t = r.find(qn('t'))
if t is not None and t.text and re.match(r'^第[一二三四五六七八九十百千\d]+条', t.text.strip()):
rpr = r.find(qn('rPr'))
bold = rpr.find(qn('b')) is not None if rpr is not None else False
title_bolds[pi] = bold
if title_bolds:
bold_values = list(title_bolds.values())
majority_bold = bold_values.count(True) > bold_values.count(False)
for pi, is_bold in title_bolds.items():
if is_bold != majority_bold:
issues.append(f"P{pi} TITLE BOLD INCONSISTENT: bold={is_bold}, majority={majority_bold}")
if issues:
print(f"FAIL: {len(issues)} issues in {total} WB INS runs")
for i in issues:
print(f" {i}")
return 1
else:
print(f"PASS: all {total} WB INS runs font-consistent, {len(title_bolds)} title(s) bold-consistent")
return 0
if __name__ == '__main__':
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} <docx_path>")
sys.exit(2)
sys.exit(main(sys.argv[1]))