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:
100
web/app.py
100
web/app.py
@@ -1719,6 +1719,24 @@ async def api_research_analysis_download(case_number: str):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/cases/{case_number}/research/analysis/export-docx")
|
||||
async def api_research_analysis_export_docx(case_number: str):
|
||||
"""Export the legal analysis as a DOCX using דפנה's decision template styles."""
|
||||
from legal_mcp.services.analysis_docx_exporter import build_analysis_docx
|
||||
try:
|
||||
path = await build_analysis_docx(case_number)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except Exception as e:
|
||||
logger.exception("Failed to export analysis DOCX for %s", case_number)
|
||||
raise HTTPException(500, f"שגיאה בייצוא: {e}")
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
filename=path.name,
|
||||
)
|
||||
|
||||
|
||||
@app.put("/api/cases/{case_number}/research/analysis/upload")
|
||||
async def api_research_analysis_upload(
|
||||
case_number: str,
|
||||
@@ -1990,7 +2008,12 @@ async def api_delete_export(case_number: str, filename: str):
|
||||
|
||||
@app.post("/api/cases/{case_number}/exports/upload")
|
||||
async def api_upload_export(case_number: str, file: UploadFile = File(...)):
|
||||
"""Upload a revised version of a draft."""
|
||||
"""Upload a revised version of a draft.
|
||||
|
||||
After saving, the file is automatically registered as the case's
|
||||
active_draft (source of truth) and bookmarks are retrofitted so that
|
||||
future revise_draft calls can anchor Track Changes to the 12 blocks.
|
||||
"""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||
@@ -2022,10 +2045,85 @@ async def api_upload_export(case_number: str, file: UploadFile = File(...)):
|
||||
dest = export_dir / f"עריכה-v{next_ver}.docx"
|
||||
dest.write_bytes(content)
|
||||
|
||||
# Auto-register as active_draft + retrofit bookmarks
|
||||
auto_result: dict = {"status": "ok"}
|
||||
try:
|
||||
raw = await drafting_tools.apply_user_edit(case_number, dest.name)
|
||||
auto_result = json.loads(raw)
|
||||
except Exception as e:
|
||||
auto_result = {"status": "error", "message": str(e)}
|
||||
|
||||
return {
|
||||
"filename": dest.name,
|
||||
"size": len(content),
|
||||
"version": next_ver,
|
||||
"active_draft": auto_result.get("active_draft_path"),
|
||||
"bookmarks_added": auto_result.get("bookmarks_added", []),
|
||||
"missing_blocks": auto_result.get("missing_blocks", []),
|
||||
"apply_status": auto_result.get("status", "error"),
|
||||
}
|
||||
|
||||
|
||||
class ReviseRequest(BaseModel):
|
||||
revisions: list[dict]
|
||||
author: str = "מערכת AI"
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/exports/revise")
|
||||
async def api_revise_draft(case_number: str, req: ReviseRequest):
|
||||
"""Apply a batch of Track Changes revisions to the active draft."""
|
||||
raw = await drafting_tools.revise_draft(
|
||||
case_number,
|
||||
json.dumps(req.revisions, ensure_ascii=False),
|
||||
req.author,
|
||||
)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(500, raw)
|
||||
if data.get("status") == "error":
|
||||
raise HTTPException(400, data.get("message", "revise failed"))
|
||||
return data
|
||||
|
||||
|
||||
@app.get("/api/cases/{case_number}/exports/bookmarks")
|
||||
async def api_list_bookmarks(case_number: str):
|
||||
"""List bookmarks in the case's active draft (anchors for revisions)."""
|
||||
raw = await drafting_tools.list_bookmarks(case_number)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(500, raw)
|
||||
return data
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/exports/{filename}/retrofit")
|
||||
async def api_retrofit_bookmarks(case_number: str, filename: str):
|
||||
"""Manually trigger retrofit of bookmarks on an existing file."""
|
||||
raw = await drafting_tools.apply_user_edit(case_number, filename)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(500, raw)
|
||||
if data.get("status") == "error":
|
||||
raise HTTPException(400, data.get("message", "retrofit failed"))
|
||||
return data
|
||||
|
||||
|
||||
@app.get("/api/cases/{case_number}/active-draft")
|
||||
async def api_get_active_draft(case_number: str):
|
||||
"""Get the current active_draft_path for a case."""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||
path = await db.get_active_draft_path(UUID(case["id"]))
|
||||
if not path:
|
||||
return {"active_draft_path": None, "filename": None, "exists": False}
|
||||
filename = Path(path).name
|
||||
return {
|
||||
"active_draft_path": path,
|
||||
"filename": filename,
|
||||
"exists": Path(path).exists(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user