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,