diff --git a/mcp-server/src/legal_mcp/config.py b/mcp-server/src/legal_mcp/config.py index bb2da9d..cf5e6ba 100644 --- a/mcp-server/src/legal_mcp/config.py +++ b/mcp-server/src/legal_mcp/config.py @@ -53,6 +53,7 @@ ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "") # Data directory DATA_DIR = Path(os.environ.get("DATA_DIR", str(Path.home() / "legal-ai" / "data"))) TRAINING_DIR = DATA_DIR / "training" +EXPORTS_DIR = DATA_DIR / "exports" # Cases directory — new structure: cases/{new,in-progress,completed}/{case_number}/ CASES_BASE = Path(os.environ.get("CASES_BASE", str(Path.home() / "legal-ai" / "cases"))) diff --git a/mcp-server/src/legal_mcp/services/docx_exporter.py b/mcp-server/src/legal_mcp/services/docx_exporter.py index 1fd3f89..c6cc2a7 100644 --- a/mcp-server/src/legal_mcp/services/docx_exporter.py +++ b/mcp-server/src/legal_mcp/services/docx_exporter.py @@ -169,11 +169,20 @@ async def export_decision(case_id: UUID, output_path: str | None = None) -> str: _write_block_to_docx(doc, block_id, block["title"], content) - # Determine output path + # Determine output path — versioned under data/exports/{case_number}/ if not output_path: - case_dir = config.find_case_dir(case["case_number"]) / "output" - case_dir.mkdir(parents=True, exist_ok=True) - output_path = str(case_dir / f"החלטה-{case['case_number']}.docx") + export_dir = config.EXPORTS_DIR / case["case_number"] + export_dir.mkdir(parents=True, exist_ok=True) + # Find next version number + existing = sorted(export_dir.glob("טיוטה-v*.docx")) + next_ver = 1 + for p in existing: + try: + ver = int(p.stem.split("-v")[1]) + next_ver = max(next_ver, ver + 1) + except (IndexError, ValueError): + pass + output_path = str(export_dir / f"טיוטה-v{next_ver}.docx") Path(output_path).parent.mkdir(parents=True, exist_ok=True) doc.save(output_path) diff --git a/web/app.py b/web/app.py index 06de31a..4c6b8ea 100644 --- a/web/app.py +++ b/web/app.py @@ -554,6 +554,131 @@ async def api_learn(case_number: str): return {"status": "final", "message": "לולאת למידה הופעלה — גרסה סופית נקלטה"} +# ── Exports API — drafts, versions, download, upload, mark-final ── + + +@app.get("/api/cases/{case_number}/exports") +async def api_list_exports(case_number: str): + """List all exported drafts and versions for a case.""" + export_dir = config.EXPORTS_DIR / case_number + if not export_dir.exists(): + return [] + files = [] + for f in sorted(export_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True): + if f.is_file() and f.suffix.lower() == ".docx": + stat = f.stat() + files.append({ + "filename": f.name, + "size": stat.st_size, + "created_at": stat.st_mtime, + "is_final": f.name.startswith("סופי-"), + }) + return files + + +@app.get("/api/cases/{case_number}/exports/{filename}/download") +async def api_download_export(case_number: str, filename: str): + """Download an exported file.""" + export_dir = config.EXPORTS_DIR / case_number + path = export_dir / filename + if not path.exists() or not path.parent.samefile(export_dir): + raise HTTPException(404, "קובץ לא נמצא") + return FileResponse( + path, + media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + filename=filename, + ) + + +@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.""" + case = await db.get_case_by_number(case_number) + if not case: + raise HTTPException(404, f"תיק {case_number} לא נמצא") + + if not file.filename: + raise HTTPException(400, "No filename provided") + + ext = Path(file.filename).suffix.lower() + if ext != ".docx": + raise HTTPException(400, "רק קבצי DOCX נתמכים") + + content = await file.read() + if len(content) > MAX_FILE_SIZE: + raise HTTPException(400, f"קובץ גדול מדי. מקסימום: {MAX_FILE_SIZE // (1024*1024)}MB") + + export_dir = config.EXPORTS_DIR / case_number + export_dir.mkdir(parents=True, exist_ok=True) + + # Version numbering for uploads + existing = sorted(export_dir.glob("עריכה-v*.docx")) + next_ver = 1 + for p in existing: + try: + ver = int(p.stem.split("-v")[1]) + next_ver = max(next_ver, ver + 1) + except (IndexError, ValueError): + pass + + dest = export_dir / f"עריכה-v{next_ver}.docx" + dest.write_bytes(content) + + return { + "filename": dest.name, + "size": len(content), + "version": next_ver, + } + + +@app.post("/api/cases/{case_number}/exports/{filename}/mark-final") +async def api_mark_final(case_number: str, filename: str): + """Mark an export as the final version — copies to training corpus.""" + case = await db.get_case_by_number(case_number) + if not case: + raise HTTPException(404, f"תיק {case_number} לא נמצא") + + export_dir = config.EXPORTS_DIR / case_number + source = export_dir / filename + if not source.exists() or not source.parent.samefile(export_dir): + raise HTTPException(404, "קובץ לא נמצא") + + # Rename/copy to final + final_name = f"סופי-{case_number}.docx" + final_path = export_dir / final_name + shutil.copy2(str(source), str(final_path)) + + # Also copy to training directory for future style learning + config.TRAINING_DIR.mkdir(parents=True, exist_ok=True) + training_dest = config.TRAINING_DIR / f"החלטה-{case_number}.docx" + shutil.copy2(str(source), str(training_dest)) + + # Update case status to final + pool = await db.get_pool() + async with pool.acquire() as conn: + await conn.execute( + "UPDATE cases SET status = 'final', updated_at = now() WHERE id = $1", + UUID(case["id"]), + ) + + return { + "final_filename": final_name, + "training_copy": str(training_dest), + "status": "final", + } + + +@app.post("/api/cases/{case_number}/export-docx") +async def api_export_docx(case_number: str): + """Trigger DOCX export for a case.""" + result = await drafting_tools.export_docx(case_number) + try: + data = json.loads(result) + return data + except json.JSONDecodeError: + raise HTTPException(500, result) + + @app.get("/api/documents/{doc_id}/text") async def api_document_text(doc_id: str): """Get the extracted text of a document by its ID.""" diff --git a/web/static/index.html b/web/static/index.html index 4f6d071..03d4f22 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -217,6 +217,28 @@ header nav a:hover, header nav a.active { color: #fff; background: rgba(255,255, .toast.error { background: #e94560; } .toast.success { background: #27ae60; } +/* ── Exports / Drafts ─────────────────────────────────── */ +.export-item { + display: flex; align-items: center; gap: 12px; padding: 10px 14px; + border: 1px solid #eee; border-radius: 8px; margin-bottom: 8px; background: #fafafa; + transition: background 0.15s; +} +.export-item:hover { background: #f0f0f0; } +.export-item .export-icon { font-size: 1.3em; flex-shrink: 0; } +.export-item .export-info { flex: 1; min-width: 0; } +.export-item .export-name { font-size: 0.88em; font-weight: 600; } +.export-item .export-meta { font-size: 0.75em; color: #999; margin-top: 2px; } +.export-item .export-actions { display: flex; gap: 6px; flex-shrink: 0; } +.export-item.final { border-color: #27ae60; background: #f0faf3; } +.export-item.final .export-icon { color: #27ae60; } +.export-upload-zone { + border: 2px dashed #ccc; border-radius: 8px; padding: 16px; + text-align: center; cursor: pointer; transition: border-color 0.2s, background 0.2s; + background: #fafafa; font-size: 0.85em; color: #888; margin-top: 8px; +} +.export-upload-zone:hover { border-color: #e94560; background: #fff5f7; } +.export-upload-zone input[type="file"] { display: none; } + .empty { text-align: center; color: #bbb; padding: 40px 20px; font-size: 0.88em; line-height: 1.6; } @media (max-width: 800px) { @@ -403,6 +425,21 @@ header nav a:hover, header nav a.active { color: #fff; background: rgba(255,255,
אין מסמכים
+ + +
+
+ טיוטות וגרסאות + +
+
+
אין טיוטות עדיין
+
+ העלאת גרסה מעודכנת (DOCX) + +
+
+
@@ -753,6 +790,8 @@ async function loadCaseView(caseNumber) { } setupCaseUpload(caseNumber); + loadExports(caseNumber); + setupExportUpload(caseNumber); } async function openPaperclip(caseNumber) { @@ -912,6 +951,121 @@ function trackCaseTask(taskId, displayName, container, caseNumber) { es.onerror = () => es.close(); } +// ── Exports / Drafts ──────────────────────────────────── + +async function loadExports(caseNumber) { + const container = document.getElementById('exportsList'); + try { + const res = await fetch(API + '/cases/' + encodeURIComponent(caseNumber) + '/exports'); + const files = await res.json(); + if (!files.length) { + container.innerHTML = '
אין טיוטות עדיין — לחץ "ייצא טיוטה חדשה" כדי ליצור
'; + return; + } + container.innerHTML = files.map(f => { + const date = new Date(f.created_at * 1000); + const dateStr = date.toLocaleDateString('he-IL') + ' ' + date.toLocaleTimeString('he-IL', {hour:'2-digit',minute:'2-digit'}); + const isFinal = f.is_final; + return ` +
+ ${isFinal ? '✅' : '📄'} +
+
${esc(f.filename)}
+
${dateStr} · ${formatSize(f.size)}${isFinal ? ' · גרסה סופית' : ''}
+
+
+ הורד + ${!isFinal ? `` : ''} +
+
`; + }).join(''); + } catch (e) { + container.innerHTML = '
שגיאה בטעינת טיוטות
'; + } +} + +async function triggerExport() { + if (!currentCaseNumber) return; + const btn = document.getElementById('exportDocxBtn'); + btn.disabled = true; + btn.textContent = 'מייצא...'; + try { + const res = await fetch(API + '/cases/' + encodeURIComponent(currentCaseNumber) + '/export-docx', { method: 'POST' }); + if (!res.ok) { + const err = await res.json(); + toast(err.detail || err.message || 'שגיאה בייצוא', 'error'); + return; + } + const data = await res.json(); + toast('טיוטה יוצאה: ' + (data.path || '').split('/').pop(), 'success'); + loadExports(currentCaseNumber); + } catch (e) { + toast('שגיאת רשת', 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'ייצא טיוטה חדשה'; + } +} + +async function markFinal(caseNumber, filename) { + if (!confirm('לסמן את הגרסה כסופית? הקובץ יועתק גם לקורפוס האימון.')) return; + try { + const res = await fetch(API + '/cases/' + encodeURIComponent(caseNumber) + '/exports/' + encodeURIComponent(filename) + '/mark-final', { method: 'POST' }); + if (!res.ok) { + const err = await res.json(); + toast(err.detail || 'שגיאה', 'error'); + return; + } + toast('הגרסה סומנה כסופית', 'success'); + loadExports(caseNumber); + loadCaseView(caseNumber); + } catch (e) { + toast('שגיאת רשת', 'error'); + } +} + +function setupExportUpload(caseNumber) { + const zone = document.getElementById('exportUploadZone'); + const fileInput = document.getElementById('exportFileInput'); + + const newZone = zone.cloneNode(true); + zone.parentNode.replaceChild(newZone, zone); + const newInput = newZone.querySelector('input[type="file"]'); + + newZone.addEventListener('click', () => newInput.click()); + newZone.addEventListener('dragover', e => { e.preventDefault(); newZone.style.borderColor = '#e94560'; }); + newZone.addEventListener('dragleave', () => { newZone.style.borderColor = '#ccc'; }); + newZone.addEventListener('drop', e => { + e.preventDefault(); + newZone.style.borderColor = '#ccc'; + if (e.dataTransfer.files.length) uploadExportFile(e.dataTransfer.files[0], caseNumber); + }); + newInput.addEventListener('change', () => { + if (newInput.files.length) uploadExportFile(newInput.files[0], caseNumber); + newInput.value = ''; + }); +} + +async function uploadExportFile(file, caseNumber) { + const formData = new FormData(); + formData.append('file', file); + try { + const res = await fetch(API + '/cases/' + encodeURIComponent(caseNumber) + '/exports/upload', { + method: 'POST', body: formData, + }); + if (!res.ok) { + const err = await res.json(); + toast(err.detail || 'שגיאה', 'error'); + return; + } + const data = await res.json(); + toast('גרסה הועלתה: ' + data.filename, 'success'); + loadExports(caseNumber); + } catch (e) { + toast('שגיאת רשת', 'error'); + } +} + // ── Legacy Upload Page ─────────────────────────────────── // (Simplified version of original upload functionality) let legacyCases = [];