Files

10 KiB

Multi-Party Revision Reconciliation (多方修订对账)

场景

合同经多方修订(如WB、华诚-Z、Adon hase等不同修订人),需要:

  1. 核对叠加修订后的最终效果是否符合协商一致的商业条件
  2. 对比最终版与模板的差异
  3. 评估差异对特定方权利义务的影响
  4. 统一修订人署名

技术方法

1. 提取带修订标记的全文(含作者归属)

from docx import Document
from lxml import etree

ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
doc = Document('contract.docx')
body = doc.element.body

# 统计修订作者
authors = set()
for elem in body.iter():
    author = elem.get(f'{W}author')
    if author:
        authors.add(author)

# 分作者统计插入/删除
ins_by_author = {}
del_by_author = {}
for ins in body.findall(f'.//{W}ins'):
    a = ins.get(f'{W}author', 'unknown')
    ins_by_author[a] = ins_by_author.get(a, 0) + 1
for d in body.findall(f'.//{W}del'):
    a = d.get(f'{W}author', 'unknown')
    del_by_author[a] = del_by_author.get(a, 0) + 1

2. 带修订标记的文本提取

格式:[+作者: 插入文本] / [-作者: 删除文本] / 普通文本

def get_text_with_revisions(body):
    result = []
    for para in body.findall(f'.//{W}p'):
        para_text = []
        for elem in para.iter():
            if elem.tag == f'{W}ins':
                author = elem.get(f'{W}author', '?')
                texts = [t.text for t in elem.findall(f'.//{W}t') if t.text]
                if texts:
                    para_text.append(f"[+{author}: {''.join(texts)}]")
            elif elem.tag == f'{W}del':
                author = elem.get(f'{W}author', '?')
                texts = [t.text for t in elem.findall(f'.//{W}delText') if t.text]
                if texts:
                    para_text.append(f"[-{author}: {''.join(texts)}]")
            elif elem.tag == f'{W}t':
                in_revision = False
                p = elem
                while p is not None:
                    if p.tag in [f'{W}ins', f'{W}del']:
                        in_revision = True
                        break
                    p = p.getparent()
                if not in_revision and elem.text:
                    para_text.append(elem.text)
        if para_text:
            result.append(''.join(para_text))
    return result

3. 三版对比分析

对比维度:

  • 模板 → 我方标准条款(基准线)
  • 对方修订版 → 对方修改后接受所有修订的版本(ins=0, del=0 说明已全部接受)
  • 当前叠加版 → 在WB修订基础上加入另一方修改

4. 协商一致核对表

按商业条件逐项核对:

协商条件 条款位置 当前版本内容 是否符合
费用40/60/60/80/80 3.3条 具体金额 /
违约责任按我方 第4条 ... /⚠️

5. 模板偏差影响评估

对每处偏差评级:

  • 对己方有利(扩大了己方权利/对方义务)
  • ⚠️ 中等影响(条件调整但不改变核心权利义务)
  • 重要(实质性削弱己方权利/扩大己方义务/给对方逃出合同的通道)

修订人统一规则

当需要将多个修订人统一为一个(如统一为WB):

处理优先级

  1. A修订了B的修订 → 以A为准(接受A对B的修改)
  2. A和B各自独立修订 → 两者都保留,统一署名

核心技术挑战:嵌套修订

关键结构:B(华诚-Z)在A(WB)的 w:ins 内部做了 w:del

XML表现为:

<w:ins w:author="WB">
  <w:r><w:t>个月未给乙方安排工作的,</w:t></w:r>
  <w:del w:author="华诚-Z">
    <w:r><w:delText>拍摄</w:delText></w:r>
  </w:del>
</w:ins>

含义:WB插入了"个月未给乙方安排拍摄工作的,",华诚-Z在WB的插入中删除了"拍摄",最终效果="个月未给乙方安排工作的,"。

三步统一流程(2026-07-03 MCN模特合同实证)

Step 1: 接受嵌套删除(B对A修订的修改)

def accept_nested_deletions(body, inner_author='华诚-Z', outer_author='WB'):
    """接受inner_author对outer_author修订的修改(删除嵌套del元素)"""
    for ins_elem in body.findall(f'.//{W}ins'):
        if ins_elem.get(f'{W}author') != outer_author:
            continue
        # 找到outer_author的ins内部,inner_author做的del
        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)

accept_nested_deletions(body)

Step 2: 清除空壳元素

接受嵌套删除后,某些WB的ins可能变空(内容全被华诚-Z删了,华诚-Z在旁边插入了替代文本):

def remove_empty_ins(body):
    """删除没有任何文本内容的ins元素"""
    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)

remove_empty_ins(body)

Step 3: 统一作者名

def rename_author(body, old_author, new_author):
    """修改所有修订元素的author属性"""
    count = 0
    for elem in body.iter():
        if elem.get(f'{W}author') == old_author:
            elem.set(f'{W}author', new_author)
            count += 1
    return count

count = rename_author(body, '华诚-Z', 'WB')

在统一后的文件上追加模板修改

如果发现与模板有偏差需要修正(如恢复模板中的固定金额违约金选项、自动续约条件等),在已统一的文件上新增tracked changes:

from copy import deepcopy

def get_rPr_from_run(run):
    """获取run的格式属性用于新增修订"""
    rpr = run.find(f'{W}rPr')
    return deepcopy(rpr) if rpr is not None else None

def make_ins(text, rPr=None, author='WB', date='2026-07-03T06:00:00Z'):
    """创建tracked insertion"""
    ins = etree.Element(f'{W}ins')
    ins.set(f'{W}id', str(abs(hash(text)) % 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

def make_del(text, rPr=None, author='WB', date='2026-07-03T06:00:00Z'):
    """创建tracked deletion"""
    d = etree.Element(f'{W}del')
    d.set(f'{W}id', str(abs(hash(text + 'del')) % 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

# 修改模式:找到目标run → 替换为del(旧) + ins(新)
target_run = ...  # 找到包含目标文本的w:r元素
rPr = get_rPr_from_run(target_run)
old_text = target_run.find(f'.//{W}t').text

del_elem = make_del(old_text, rPr)
ins_elem = make_ins(new_text, rPr)

idx = list(para).index(target_run)
para.remove(target_run)
para.insert(idx, del_elem)
para.insert(idx + 1, ins_elem)

恢复已被删除的内容(撤回WB之前的del)

当需要把WB之前删除的内容恢复回来(因为模板中有这个内容):

# 找到WB的del元素
for child in para:
    if child.tag == f'{W}del' and child.get(f'{W}author') == 'WB':
        del_texts = ''.join(dt.text for dt in child.findall(f'.//{W}delText') if dt.text)
        if '目标文本' in del_texts:
            # 策略:删除这个del,插入一个ins代替(因为原文已经"被删"了)
            inner_r = child.find(f'.//{W}r')
            inner_rPr = get_rPr_from_run(inner_r)
            ins_restore = make_ins(del_texts, inner_rPr)
            idx = list(para).index(child)
            para.remove(child)
            para.insert(idx, ins_restore)
            break

验证步骤

统一修订人后必须验证:

# 1. 确认只剩一个作者
authors = set()
for elem in body.iter():
    a = elem.get(f'{W}author')
    if a:
        authors.add(a)
assert authors == {'WB'}, f"Unexpected authors: {authors}"

# 2. 获取接受所有修订后的最终文本
def get_accepted_text(para):
    texts = []
    for elem in para.iter():
        if elem.tag == f'{W}t':
            in_del = False
            p = elem
            while p is not None:
                if p.tag == f'{W}del':
                    in_del = True
                    break
                p = p.getparent()
            if not in_del and elem.text:
                texts.append(elem.text)
    return ''.join(texts)

# 3. 逐条核对关键条款的最终文本

输出格式

报告分三部分:

  1. 协商一致核对 — 逐项确认商业条件是否落实(/⚠️/
  2. 模板差异表 — 与标准模板的偏差点 + 对己方权利义务的影响评估
  3. 结论与建议 — 哪些已符合、哪些需关注、是否需要进一步协商

注意事项

  • 对方修订版如果修订已全部接受(ins=0, del=0),说明是"接受所有修订后"的干净版本
  • 比较时需要分别提取:(1)当前版本接受所有修订后的最终文本;(2)带修订标记的过程文本
  • 对方修改了措辞但实质不变的情况(如"整容"→"形象变化"),需评估措辞变化是否改变法律效果——措辞更宽泛时分析对哪方有利
  • 费用条款的结构性改写(如合并年度/拆分年度)要验证数学正确性(年总额×期数=合同总额)
  • 处理段落29-31连读场景:华诚-Z可能将原来的多个年度段落(第三、四年/第五年分开写)合并为一个(第四、第五年),导致中间段落被del清空,需要把前后段落连起来读才能看到完整的费用结构
  • 修订人统一后的检查清单:作者唯一性、ins/del总数合理、关键条款最终文本正确、文件可正常打开