Files
hermes-skills/skills/legal/contract-editor/references/standalone-charlevel-tracked-changes.md
T

4.1 KiB

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

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):

comments_xml = etree.Element(qn('w:comments'))
comments_xml.set(qn('xmlns:w'), WNS)  # ❌ double declaration

Right (build as plain string):

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).