Add exports panel: versioned drafts, download, upload revisions, mark final
Export DOCX now saves to data/exports/{case_number}/ with auto-versioning
(טיוטה-v1, v2...). The case view UI shows all drafts with download buttons,
allows uploading revised versions (עריכה-v1...), and marking a version as
final (copies to training corpus for style learning).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,7 @@ ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
|
|||||||
# Data directory
|
# Data directory
|
||||||
DATA_DIR = Path(os.environ.get("DATA_DIR", str(Path.home() / "legal-ai" / "data")))
|
DATA_DIR = Path(os.environ.get("DATA_DIR", str(Path.home() / "legal-ai" / "data")))
|
||||||
TRAINING_DIR = DATA_DIR / "training"
|
TRAINING_DIR = DATA_DIR / "training"
|
||||||
|
EXPORTS_DIR = DATA_DIR / "exports"
|
||||||
|
|
||||||
# Cases directory — new structure: cases/{new,in-progress,completed}/{case_number}/
|
# 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")))
|
CASES_BASE = Path(os.environ.get("CASES_BASE", str(Path.home() / "legal-ai" / "cases")))
|
||||||
|
|||||||
@@ -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)
|
_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:
|
if not output_path:
|
||||||
case_dir = config.find_case_dir(case["case_number"]) / "output"
|
export_dir = config.EXPORTS_DIR / case["case_number"]
|
||||||
case_dir.mkdir(parents=True, exist_ok=True)
|
export_dir.mkdir(parents=True, exist_ok=True)
|
||||||
output_path = str(case_dir / f"החלטה-{case['case_number']}.docx")
|
# 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)
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
doc.save(output_path)
|
doc.save(output_path)
|
||||||
|
|||||||
125
web/app.py
125
web/app.py
@@ -554,6 +554,131 @@ async def api_learn(case_number: str):
|
|||||||
return {"status": "final", "message": "לולאת למידה הופעלה — גרסה סופית נקלטה"}
|
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")
|
@app.get("/api/documents/{doc_id}/text")
|
||||||
async def api_document_text(doc_id: str):
|
async def api_document_text(doc_id: str):
|
||||||
"""Get the extracted text of a document by its ID."""
|
"""Get the extracted text of a document by its ID."""
|
||||||
|
|||||||
@@ -217,6 +217,28 @@ header nav a:hover, header nav a.active { color: #fff; background: rgba(255,255,
|
|||||||
.toast.error { background: #e94560; }
|
.toast.error { background: #e94560; }
|
||||||
.toast.success { background: #27ae60; }
|
.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; }
|
.empty { text-align: center; color: #bbb; padding: 40px 20px; font-size: 0.88em; line-height: 1.6; }
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
@@ -403,6 +425,21 @@ header nav a:hover, header nav a.active { color: #fff; background: rgba(255,255,
|
|||||||
<div class="empty">אין מסמכים</div>
|
<div class="empty">אין מסמכים</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Exports / Drafts -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span>טיוטות וגרסאות</span>
|
||||||
|
<button class="btn btn-sm btn-primary" id="exportDocxBtn" style="margin-right:auto" onclick="triggerExport()">ייצא טיוטה חדשה</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="exportsList"><div class="empty">אין טיוטות עדיין</div></div>
|
||||||
|
<div class="export-upload-zone" id="exportUploadZone">
|
||||||
|
העלאת גרסה מעודכנת (DOCX)
|
||||||
|
<input type="file" id="exportFileInput" accept=".docx">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ══ Page: Legacy Upload ══ -->
|
<!-- ══ Page: Legacy Upload ══ -->
|
||||||
@@ -753,6 +790,8 @@ async function loadCaseView(caseNumber) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupCaseUpload(caseNumber);
|
setupCaseUpload(caseNumber);
|
||||||
|
loadExports(caseNumber);
|
||||||
|
setupExportUpload(caseNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openPaperclip(caseNumber) {
|
async function openPaperclip(caseNumber) {
|
||||||
@@ -912,6 +951,121 @@ function trackCaseTask(taskId, displayName, container, caseNumber) {
|
|||||||
es.onerror = () => es.close();
|
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 = '<div class="empty">אין טיוטות עדיין — לחץ "ייצא טיוטה חדשה" כדי ליצור</div>';
|
||||||
|
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 `
|
||||||
|
<div class="export-item ${isFinal ? 'final' : ''}">
|
||||||
|
<span class="export-icon">${isFinal ? '✅' : '📄'}</span>
|
||||||
|
<div class="export-info">
|
||||||
|
<div class="export-name">${esc(f.filename)}</div>
|
||||||
|
<div class="export-meta">${dateStr} · ${formatSize(f.size)}${isFinal ? ' · <b>גרסה סופית</b>' : ''}</div>
|
||||||
|
</div>
|
||||||
|
<div class="export-actions">
|
||||||
|
<a class="btn btn-sm btn-secondary" href="${API}/cases/${encodeURIComponent(caseNumber)}/exports/${encodeURIComponent(f.filename)}/download" download="${esc(f.filename)}">הורד</a>
|
||||||
|
${!isFinal ? `<button class="btn btn-sm btn-success" onclick="markFinal('${esc(caseNumber)}','${esc(f.filename)}')">סמן סופי</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<div class="empty">שגיאה בטעינת טיוטות</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ───────────────────────────────────
|
// ── Legacy Upload Page ───────────────────────────────────
|
||||||
// (Simplified version of original upload functionality)
|
// (Simplified version of original upload functionality)
|
||||||
let legacyCases = [];
|
let legacyCases = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user