Add Track Changes architecture for draft revisions (CMP + CMPA)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s

Fixes critical bug in 1033-25: user-uploaded עריכה-*.docx files were
orphaned on disk while exports kept rebuilding from stale DB blocks.

New architecture:
- User-uploaded DOCX becomes the source of truth (cases.active_draft_path)
- System edits via XML surgery with real Word <w:ins>/<w:del> revisions
- User can Accept/Reject each change from within Word

Components:
- docx_reviser.py: XML surgery for Track Changes (15 tests)
- docx_retrofit.py: retroactive bookmark injection with Hebrew marker
  detection + heading heuristic (9 tests)
- docx_exporter.py: emits bookmarks around each of the 12 blocks
- 3 new MCP tools: apply_user_edit, list_bookmarks, revise_draft
- 4 new/updated endpoints: upload (auto-registers active draft),
  /exports/revise, /exports/bookmarks, /exports/{filename}/retrofit,
  /active-draft
- DB migration: cases.active_draft_path column
- UI: correct banner using real v-numbers, "מקור האמת" badge,
  detailed upload toast with bookmarks_added/missing_blocks
- agents: legal-exporter (3 export modes), legal-ceo (stage G for
  revision handling), legal-writer (revision mode)

Multi-tenancy:
- Works for both CMP (1xxx cases) and CMPA (8xxx/9xxx cases)
- New revise-draft skill added to both companies
- deploy-track-changes.sh syncs skills CMP ↔ CMPA
- retrofit_case.py: one-off retrofit of existing files

Tests: 34 passing (15 reviser + 9 retrofit + 4 exporter bookmarks + 6 e2e)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 18:49:30 +00:00
parent 28daff58be
commit 726498126d
20 changed files with 2419 additions and 23 deletions

View File

@@ -0,0 +1,103 @@
"""בדיקות ל-bookmark helpers ב-docx_exporter.
הבדיקות מתרכזות ב-helper functions בלבד (לא בכל ה-export flow שדורש DB).
"""
from __future__ import annotations
import zipfile
from pathlib import Path
from docx import Document
from lxml import etree
from legal_mcp.services.docx_exporter import (
_BOOKMARK_ID_START,
_insert_bookmark_end,
_insert_bookmark_start,
_wrap_block_with_bookmarks,
)
from legal_mcp.services.docx_reviser import NSMAP, _w, list_bookmarks
def test_insert_bookmark_helpers_create_valid_xml(tmp_path: Path) -> None:
doc = Document()
p = doc.add_paragraph("תוכן בלוק י")
_insert_bookmark_start(p, "block-yod", 10001)
_insert_bookmark_end(p, 10001)
out = tmp_path / "out.docx"
doc.save(str(out))
# Verify via list_bookmarks (uses the same XML)
assert list_bookmarks(out) == ["block-yod"]
def test_wrap_block_with_bookmarks_wraps_multiple_paragraphs(tmp_path: Path) -> None:
doc = Document()
doc.add_paragraph("ראשון — לפני") # noise before
bm_counter = [_BOOKMARK_ID_START]
def writer() -> None:
doc.add_paragraph("בלוק — פסקה 1")
doc.add_paragraph("בלוק — פסקה 2")
doc.add_paragraph("בלוק — פסקה 3")
_wrap_block_with_bookmarks(doc, "block-yod", writer, bm_counter)
doc.add_paragraph("אחרי — אחרון") # noise after
out = tmp_path / "out.docx"
doc.save(str(out))
# The bookmark should wrap exactly the 3 middle paragraphs
with zipfile.ZipFile(out, "r") as zf:
tree = etree.fromstring(zf.read("word/document.xml"))
paragraphs = tree.findall(".//w:p", NSMAP)
# Find para index of bookmarkStart and bookmarkEnd
start_idx = end_idx = None
for i, p in enumerate(paragraphs):
if p.find(".//w:bookmarkStart", NSMAP) is not None:
start_idx = i
if p.find(".//w:bookmarkEnd", NSMAP) is not None:
end_idx = i
assert start_idx is not None
assert end_idx is not None
# The paragraph containing start must be the first new one ("פסקה 1")
start_text = "".join(paragraphs[start_idx].itertext())
end_text = "".join(paragraphs[end_idx].itertext())
assert "פסקה 1" in start_text
assert "פסקה 3" in end_text
def test_wrap_block_skipped_when_writer_adds_nothing(tmp_path: Path) -> None:
doc = Document()
bm_counter = [_BOOKMARK_ID_START]
_wrap_block_with_bookmarks(doc, "block-empty", lambda: None, bm_counter)
out = tmp_path / "out.docx"
doc.save(str(out))
assert list_bookmarks(out) == []
def test_multiple_blocks_get_unique_bookmark_ids(tmp_path: Path) -> None:
doc = Document()
bm_counter = [_BOOKMARK_ID_START]
for name in ("block-alef", "block-bet", "block-gimel"):
_wrap_block_with_bookmarks(
doc, name,
lambda n=name: doc.add_paragraph(f"תוכן של {n}"),
bm_counter,
)
out = tmp_path / "out.docx"
doc.save(str(out))
with zipfile.ZipFile(out, "r") as zf:
tree = etree.fromstring(zf.read("word/document.xml"))
ids = [el.get(_w("id")) for el in tree.iterfind(".//w:bookmarkStart", NSMAP)]
assert len(ids) == 3
assert len(set(ids)) == 3
names = list_bookmarks(out)
assert set(names) == {"block-alef", "block-bet", "block-gimel"}