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

@@ -58,6 +58,57 @@ def _set_rtl_section(section) -> None:
sectPr.append(bidi)
# ── Bookmark helpers ──────────────────────────────────────────────
# Keep a per-document bookmark id counter. Bookmarks must have unique ids
# across the whole document; we start from a high value to avoid collisions
# with whatever Word's default template already assigned.
_BOOKMARK_ID_START = 10000
def _insert_bookmark_start(paragraph, name: str, bm_id: int) -> None:
"""Insert a <w:bookmarkStart> at the beginning of a paragraph."""
el = OxmlElement("w:bookmarkStart")
el.set(qn("w:id"), str(bm_id))
el.set(qn("w:name"), name)
paragraph._p.insert(0, el)
def _insert_bookmark_end(paragraph, bm_id: int) -> None:
"""Insert a <w:bookmarkEnd> at the end of a paragraph."""
el = OxmlElement("w:bookmarkEnd")
el.set(qn("w:id"), str(bm_id))
paragraph._p.append(el)
def _wrap_block_with_bookmarks(doc, block_name: str,
write_block_fn, bm_counter: list[int]) -> None:
"""Write a block with bookmarkStart before and bookmarkEnd after.
Uses a mutable counter (list of one int) so the caller keeps state
across multiple blocks.
"""
# Record paragraph count before writing
body = doc.element.body
before_count = len([c for c in body if c.tag == qn("w:p")])
write_block_fn()
after_count = len([c for c in body if c.tag == qn("w:p")])
if after_count == before_count:
# Block produced no paragraphs — nothing to wrap
return
# Use python-docx's paragraph indexing
first_new = doc.paragraphs[before_count]
last_new = doc.paragraphs[after_count - 1]
bm_counter[0] += 1
bm_id = bm_counter[0]
_insert_bookmark_start(first_new, block_name, bm_id)
_insert_bookmark_end(last_new, bm_id)
def _add_paragraph(doc, text: str, style: str = "Normal",
bold: bool = False, font_size=None,
alignment=None, space_after: Pt | None = None) -> None:
@@ -160,14 +211,22 @@ async def export_decision(case_id: UUID, output_path: str | None = None) -> str:
section.right_margin = PAGE_MARGIN
_set_rtl_section(section)
# Write blocks
# Write blocks with bookmarks wrapping each block (anchors for revisions)
bm_counter = [_BOOKMARK_ID_START]
for block in blocks:
block_id = block["block_id"]
content = block["content"] or ""
if not content.strip():
continue
_write_block_to_docx(doc, block_id, block["title"], content)
_wrap_block_with_bookmarks(
doc,
f"block-{block_id}",
lambda b=block, bid=block_id, c=content: _write_block_to_docx(
doc, bid, b["title"], c,
),
bm_counter,
)
# Determine output path — versioned under cases/{case_number}/exports/
if not output_path: