Add Track Changes architecture for draft revisions (CMP + CMPA)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user