Initial commit: MCP server + web upload interface

Ezer Mishpati - AI legal decision drafting system with:
- MCP server (FastMCP) with document processing pipeline
- Web upload interface (FastAPI) for file upload and classification
- pgvector-based semantic search
- Hebrew legal document chunking and embedding
This commit is contained in:
2026-03-23 12:33:07 +00:00
commit 6f515dc2cb
33 changed files with 3297 additions and 0 deletions

342
web/app.py Normal file
View File

@@ -0,0 +1,342 @@
"""Ezer Mishpati — Web upload interface for legal documents."""
from __future__ import annotations
import asyncio
import json
import logging
import re
import shutil
import subprocess
import sys
import time
from contextlib import asynccontextmanager
from pathlib import Path
from uuid import UUID, uuid4
# Allow importing legal_mcp from the MCP server source
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "src"))
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from legal_mcp import config
from legal_mcp.services import chunker, db, embeddings, extractor, processor
logger = logging.getLogger(__name__)
UPLOAD_DIR = config.DATA_DIR / "uploads"
ALLOWED_EXTENSIONS = {".pdf", ".docx", ".rtf", ".txt"}
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
# In-memory progress tracking
_progress: dict[str, dict] = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
await db.init_schema()
yield
await db.close_pool()
app = FastAPI(title="Ezer Mishpati — Upload", lifespan=lifespan)
STATIC_DIR = Path(__file__).parent / "static"
# ── API Endpoints ──────────────────────────────────────────────────
@app.get("/")
async def index():
return FileResponse(STATIC_DIR / "index.html")
@app.post("/api/upload")
async def upload_file(file: UploadFile = File(...)):
"""Upload a file to the temporary uploads directory."""
if not file.filename:
raise HTTPException(400, "No filename provided")
# Validate extension
ext = Path(file.filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(400, f"Unsupported file type: {ext}. Allowed: {', '.join(ALLOWED_EXTENSIONS)}")
# Sanitize filename
safe_name = re.sub(r"[^\w\u0590-\u05FF\s.\-()]", "", Path(file.filename).stem)
if not safe_name:
safe_name = "document"
timestamp = int(time.time())
filename = f"{timestamp}_{safe_name}{ext}"
# Read and validate size
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(400, f"File too large. Max: {MAX_FILE_SIZE // (1024*1024)}MB")
dest = UPLOAD_DIR / filename
dest.write_bytes(content)
return {
"filename": filename,
"original_name": file.filename,
"size": len(content),
}
@app.get("/api/uploads")
async def list_uploads():
"""List files in the uploads (pending) directory."""
if not UPLOAD_DIR.exists():
return []
files = []
for f in sorted(UPLOAD_DIR.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
if f.is_file() and f.suffix.lower() in ALLOWED_EXTENSIONS:
stat = f.stat()
files.append({
"filename": f.name,
"size": stat.st_size,
"uploaded_at": stat.st_mtime,
})
return files
@app.delete("/api/uploads/{filename}")
async def delete_upload(filename: str):
"""Remove a file from the uploads directory."""
path = UPLOAD_DIR / filename
if not path.exists() or not path.parent.samefile(UPLOAD_DIR):
raise HTTPException(404, "File not found")
path.unlink()
return {"deleted": filename}
class ClassifyRequest(BaseModel):
filename: str
category: str # "training" or "case"
# For case documents
case_number: str = ""
doc_type: str = "appeal"
title: str = ""
# For training documents
decision_number: str = ""
decision_date: str = ""
subject_categories: list[str] = []
@app.post("/api/classify")
async def classify_file(req: ClassifyRequest):
"""Classify a pending file and start processing."""
source = UPLOAD_DIR / req.filename
if not source.exists() or not source.parent.samefile(UPLOAD_DIR):
raise HTTPException(404, "File not found in uploads")
if req.category not in ("training", "case"):
raise HTTPException(400, "Category must be 'training' or 'case'")
if req.category == "case" and not req.case_number:
raise HTTPException(400, "case_number required for case documents")
task_id = str(uuid4())
_progress[task_id] = {"status": "queued", "filename": req.filename}
asyncio.create_task(_process_file(task_id, source, req))
return {"task_id": task_id}
@app.get("/api/progress/{task_id}")
async def progress_stream(task_id: str):
"""SSE stream of processing progress."""
if task_id not in _progress:
raise HTTPException(404, "Task not found")
async def event_stream():
while True:
data = _progress.get(task_id, {})
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
if data.get("status") in ("completed", "failed"):
break
await asyncio.sleep(1)
# Clean up after a delay
await asyncio.sleep(30)
_progress.pop(task_id, None)
return StreamingResponse(event_stream(), media_type="text/event-stream")
@app.get("/api/cases")
async def list_cases():
"""List existing cases for the dropdown."""
cases = await db.list_cases()
return [
{
"case_number": c["case_number"],
"title": c["title"],
"status": c["status"],
}
for c in cases
]
# ── Background Processing ─────────────────────────────────────────
async def _process_file(task_id: str, source: Path, req: ClassifyRequest):
"""Process a classified file in the background."""
try:
if req.category == "case":
await _process_case_document(task_id, source, req)
else:
await _process_training_document(task_id, source, req)
except Exception as e:
logger.exception("Processing failed for %s", req.filename)
_progress[task_id] = {"status": "failed", "error": str(e), "filename": req.filename}
async def _process_case_document(task_id: str, source: Path, req: ClassifyRequest):
"""Process a case document (mirrors documents.document_upload logic)."""
_progress[task_id] = {"status": "validating", "filename": req.filename}
case = await db.get_case_by_number(req.case_number)
if not case:
_progress[task_id] = {"status": "failed", "error": f"Case {req.case_number} not found"}
return
case_id = UUID(case["id"])
title = req.title or source.stem.split("_", 1)[-1] # Remove timestamp prefix
# Copy to case directory
_progress[task_id] = {"status": "copying", "filename": req.filename}
case_dir = config.CASES_DIR / req.case_number / "documents"
case_dir.mkdir(parents=True, exist_ok=True)
# Use original name without timestamp prefix
original_name = re.sub(r"^\d+_", "", source.name)
dest = case_dir / original_name
shutil.copy2(str(source), str(dest))
# Create document record
_progress[task_id] = {"status": "registering", "filename": req.filename}
doc = await db.create_document(
case_id=case_id,
doc_type=req.doc_type,
title=title,
file_path=str(dest),
)
# Process (extract → chunk → embed → store)
_progress[task_id] = {"status": "processing", "filename": req.filename, "step": "extracting"}
result = await processor.process_document(UUID(doc["id"]), case_id)
# Git commit
repo_dir = config.CASES_DIR / req.case_number
if repo_dir.exists():
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
doc_type_hebrew = {
"appeal": "כתב ערר", "response": "תשובה", "decision": "החלטה",
"reference": "מסמך עזר", "exhibit": "נספח",
}.get(req.doc_type, req.doc_type)
subprocess.run(
["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {title}"],
cwd=repo_dir, capture_output=True,
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
"PATH": "/usr/bin:/bin"},
)
# Remove from uploads
source.unlink(missing_ok=True)
_progress[task_id] = {
"status": "completed",
"filename": req.filename,
"result": result,
"case_number": req.case_number,
"doc_type": req.doc_type,
}
async def _process_training_document(task_id: str, source: Path, req: ClassifyRequest):
"""Process a training document (mirrors documents.document_upload_training logic)."""
from datetime import date as date_type
title = req.title or source.stem.split("_", 1)[-1]
# Copy to training directory
_progress[task_id] = {"status": "copying", "filename": req.filename}
config.TRAINING_DIR.mkdir(parents=True, exist_ok=True)
original_name = re.sub(r"^\d+_", "", source.name)
dest = config.TRAINING_DIR / original_name
shutil.copy2(str(source), str(dest))
# Extract text
_progress[task_id] = {"status": "processing", "filename": req.filename, "step": "extracting"}
text, page_count = await extractor.extract_text(str(dest))
# Parse date
d_date = None
if req.decision_date:
d_date = date_type.fromisoformat(req.decision_date)
# Add to style corpus
_progress[task_id] = {"status": "processing", "filename": req.filename, "step": "corpus"}
corpus_id = await db.add_to_style_corpus(
document_id=None,
decision_number=req.decision_number,
decision_date=d_date,
subject_categories=req.subject_categories,
full_text=text,
)
# Chunk and embed
_progress[task_id] = {"status": "processing", "filename": req.filename, "step": "chunking"}
chunks = chunker.chunk_document(text)
chunk_count = 0
if chunks:
doc = await db.create_document(
case_id=None,
doc_type="decision",
title=f"[קורפוס] {title}",
file_path=str(dest),
page_count=page_count,
)
doc_id = UUID(doc["id"])
await db.update_document(doc_id, extracted_text=text, extraction_status="completed")
_progress[task_id] = {"status": "processing", "filename": req.filename, "step": "embedding"}
texts = [c.content for c in chunks]
embs = await embeddings.embed_texts(texts, input_type="document")
chunk_dicts = [
{
"content": c.content,
"section_type": c.section_type,
"embedding": emb,
"page_number": c.page_number,
"chunk_index": c.chunk_index,
}
for c, emb in zip(chunks, embs)
]
await db.store_chunks(doc_id, None, chunk_dicts)
chunk_count = len(chunks)
# Remove from uploads
source.unlink(missing_ok=True)
_progress[task_id] = {
"status": "completed",
"filename": req.filename,
"result": {
"corpus_id": str(corpus_id),
"title": title,
"pages": page_count,
"text_length": len(text),
"chunks": chunk_count,
},
}

571
web/static/index.html Normal file
View File

@@ -0,0 +1,571 @@
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>עוזר משפטי — העלאת מסמכים</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f6fa;
color: #2d3436;
direction: rtl;
min-height: 100vh;
}
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
header {
background: #2d3436;
color: white;
padding: 20px;
text-align: center;
margin-bottom: 24px;
border-radius: 8px;
}
header h1 { font-size: 1.5em; font-weight: 600; }
header p { opacity: 0.7; margin-top: 4px; font-size: 0.9em; }
/* Upload Zone */
.upload-zone {
border: 2px dashed #b2bec3;
border-radius: 12px;
padding: 40px;
text-align: center;
background: white;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 24px;
}
.upload-zone:hover, .upload-zone.dragover {
border-color: #0984e3;
background: #f0f7ff;
}
.upload-zone h2 { color: #636e72; font-size: 1.1em; margin-bottom: 8px; }
.upload-zone p { color: #b2bec3; font-size: 0.85em; }
.upload-zone input[type="file"] { display: none; }
.upload-progress {
margin-top: 12px;
display: none;
}
.upload-progress .bar {
height: 4px;
background: #dfe6e9;
border-radius: 2px;
overflow: hidden;
}
.upload-progress .bar-fill {
height: 100%;
background: #0984e3;
width: 0;
transition: width 0.3s;
}
/* Cards */
.card {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
padding: 20px;
margin-bottom: 16px;
}
.card h3 {
font-size: 1em;
margin-bottom: 12px;
color: #2d3436;
}
/* Pending Files */
.pending-file {
border: 1px solid #dfe6e9;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
background: #fafafa;
}
.pending-file .file-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.pending-file .file-name {
font-weight: 600;
font-size: 0.95em;
word-break: break-all;
}
.pending-file .file-size {
color: #636e72;
font-size: 0.8em;
white-space: nowrap;
margin-right: 12px;
}
.pending-file .file-actions {
display: flex;
gap: 8px;
align-items: center;
}
/* Form Elements */
.form-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: end;
margin-bottom: 10px;
}
.form-group { flex: 1; min-width: 150px; }
.form-group label {
display: block;
font-size: 0.8em;
color: #636e72;
margin-bottom: 4px;
}
.form-group select, .form-group input {
width: 100%;
padding: 8px 10px;
border: 1px solid #dfe6e9;
border-radius: 6px;
font-size: 0.9em;
font-family: inherit;
direction: rtl;
}
.radio-group {
display: flex;
gap: 16px;
margin-bottom: 10px;
}
.radio-group label {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.9em;
cursor: pointer;
}
.conditional { display: none; }
.conditional.active { display: block; }
/* Buttons */
.btn {
padding: 8px 20px;
border: none;
border-radius: 6px;
font-size: 0.9em;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
}
.btn-primary { background: #0984e3; color: white; }
.btn-primary:hover { background: #0770c2; }
.btn-danger { background: #d63031; color: white; }
.btn-danger:hover { background: #b71c1c; }
.btn-sm { padding: 5px 12px; font-size: 0.8em; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Processing Tasks */
.task-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #dfe6e9;
border-radius: 8px;
margin-bottom: 8px;
}
.task-item .spinner {
width: 20px;
height: 20px;
border: 2px solid #dfe6e9;
border-top-color: #0984e3;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.task-item.done .spinner {
border-color: #00b894;
border-top-color: #00b894;
animation: none;
}
.task-item.failed .spinner {
border-color: #d63031;
border-top-color: #d63031;
animation: none;
}
@keyframes spin { to { transform: rotate(360deg); } }
.task-info { flex: 1; }
.task-info .task-name { font-size: 0.9em; font-weight: 500; }
.task-info .task-status { font-size: 0.8em; color: #636e72; }
/* Subject checkboxes */
.subject-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.subject-grid label {
font-size: 0.8em;
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
/* Empty state */
.empty { text-align: center; color: #b2bec3; padding: 24px; font-size: 0.9em; }
/* Toast */
.toast {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: #2d3436;
color: white;
padding: 12px 24px;
border-radius: 8px;
font-size: 0.9em;
z-index: 1000;
opacity: 0;
transition: opacity 0.3s;
}
.toast.show { opacity: 1; }
.toast.error { background: #d63031; }
.toast.success { background: #00b894; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>עוזר משפטי — העלאת מסמכים</h1>
<p>העלאה, סיווג ועיבוד מסמכים משפטיים</p>
</header>
<!-- Upload Zone -->
<div class="upload-zone" id="dropZone">
<h2>גרור קבצים לכאן או לחץ לבחירה</h2>
<p>PDF, DOCX, RTF, TXT — עד 50MB</p>
<input type="file" id="fileInput" multiple accept=".pdf,.docx,.rtf,.txt">
<div class="upload-progress" id="uploadProgress">
<div class="bar"><div class="bar-fill" id="uploadBar"></div></div>
</div>
</div>
<!-- Pending Files -->
<div class="card" id="pendingCard" style="display:none">
<h3>קבצים ממתינים לסיווג</h3>
<div id="pendingList"></div>
</div>
<!-- Processing Tasks -->
<div class="card" id="tasksCard" style="display:none">
<h3>עיבוד</h3>
<div id="tasksList"></div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const API = '/api';
// ── Upload Zone ───────────────────────────────────────────────
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const uploadProgress = document.getElementById('uploadProgress');
const uploadBar = document.getElementById('uploadBar');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('dragover');
handleFiles(e.dataTransfer.files);
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length) handleFiles(fileInput.files);
fileInput.value = '';
});
async function handleFiles(files) {
for (const file of files) {
await uploadFile(file);
}
loadPending();
}
function uploadFile(file) {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
uploadProgress.style.display = 'block';
xhr.upload.onprogress = e => {
if (e.lengthComputable) {
uploadBar.style.width = (e.loaded / e.total * 100) + '%';
}
};
xhr.onload = () => {
uploadProgress.style.display = 'none';
uploadBar.style.width = '0';
if (xhr.status === 200) {
toast('הקובץ הועלה בהצלחה', 'success');
resolve();
} else {
const err = JSON.parse(xhr.responseText);
toast(err.detail || 'שגיאה בהעלאה', 'error');
reject();
}
};
xhr.onerror = () => {
uploadProgress.style.display = 'none';
toast('שגיאת רשת', 'error');
reject();
};
xhr.open('POST', API + '/upload');
xhr.send(formData);
});
}
// ── Pending Files ─────────────────────────────────────────────
let cases = [];
async function loadCases() {
try {
const res = await fetch(API + '/cases');
cases = await res.json();
} catch (e) {
cases = [];
}
}
async function loadPending() {
const res = await fetch(API + '/uploads');
const files = await res.json();
const card = document.getElementById('pendingCard');
const list = document.getElementById('pendingList');
if (!files.length) {
card.style.display = 'none';
return;
}
card.style.display = 'block';
await loadCases();
list.innerHTML = files.map(f => `
<div class="pending-file" data-filename="${esc(f.filename)}">
<div class="file-info">
<span class="file-name">${esc(f.filename.replace(/^\d+_/, ''))}</span>
<div class="file-actions">
<span class="file-size">${formatSize(f.size)}</span>
<button class="btn btn-danger btn-sm" onclick="deleteFile('${esc(f.filename)}')">מחק</button>
</div>
</div>
<div class="radio-group">
<label><input type="radio" name="cat_${esc(f.filename)}" value="training" onchange="showFields(this)"> החלטה קודמת (אימון)</label>
<label><input type="radio" name="cat_${esc(f.filename)}" value="case" onchange="showFields(this)"> מסמך תיק</label>
</div>
<div class="conditional case-fields" id="case_${esc(f.filename)}">
<div class="form-row">
<div class="form-group">
<label>תיק</label>
<select class="case-select">
<option value="">בחר תיק...</option>
${cases.map(c => `<option value="${esc(c.case_number)}">${esc(c.case_number)}${esc(c.title)}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label>סוג מסמך</label>
<select class="doctype-select">
<option value="appeal">כתב ערר</option>
<option value="response">תשובה</option>
<option value="decision">החלטה</option>
<option value="exhibit">נספח</option>
<option value="reference">מסמך עזר</option>
</select>
</div>
</div>
</div>
<div class="conditional training-fields" id="train_${esc(f.filename)}">
<div class="form-row">
<div class="form-group">
<label>מספר החלטה</label>
<input type="text" class="decision-number" placeholder="לדוגמה: 1180+1181">
</div>
<div class="form-group">
<label>תאריך החלטה</label>
<input type="date" class="decision-date">
</div>
</div>
<div class="form-group" style="margin-top:8px">
<label>קטגוריות</label>
<div class="subject-grid">
${['בנייה','שימוש חורג','תכנית','היתר','הקלה','חלוקה','תמ"א 38','היטל השבחה','פיצויים 197'].map(s =>
`<label><input type="checkbox" value="${s}"> ${s}</label>`
).join('')}
</div>
</div>
</div>
<div style="margin-top:12px">
<div class="form-group" style="max-width:300px;margin-bottom:8px">
<label>כותרת (אופציונלי)</label>
<input type="text" class="doc-title" placeholder="שם המסמך">
</div>
<button class="btn btn-primary process-btn" onclick="classifyFile('${esc(f.filename)}')" disabled>עבד</button>
</div>
</div>
`).join('');
}
function showFields(radio) {
const container = radio.closest('.pending-file');
const filename = container.dataset.filename;
const val = radio.value;
container.querySelector('.case-fields').classList.toggle('active', val === 'case');
container.querySelector('.training-fields').classList.toggle('active', val === 'training');
container.querySelector('.process-btn').disabled = false;
}
async function deleteFile(filename) {
await fetch(API + '/uploads/' + encodeURIComponent(filename), { method: 'DELETE' });
loadPending();
toast('הקובץ נמחק');
}
async function classifyFile(filename) {
const container = document.querySelector(`.pending-file[data-filename="${filename}"]`);
const category = container.querySelector('input[type="radio"]:checked')?.value;
if (!category) return toast('יש לבחור סיווג', 'error');
const body = {
filename,
category,
title: container.querySelector('.doc-title').value,
};
if (category === 'case') {
body.case_number = container.querySelector('.case-select').value;
body.doc_type = container.querySelector('.doctype-select').value;
if (!body.case_number) return toast('יש לבחור תיק', 'error');
} else {
body.decision_number = container.querySelector('.decision-number').value;
body.decision_date = container.querySelector('.decision-date').value;
body.subject_categories = Array.from(container.querySelectorAll('.subject-grid input:checked')).map(cb => cb.value);
}
// Disable button
container.querySelector('.process-btn').disabled = true;
container.querySelector('.process-btn').textContent = 'מעבד...';
try {
const res = await fetch(API + '/classify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json();
toast(err.detail || 'שגיאה', 'error');
container.querySelector('.process-btn').disabled = false;
container.querySelector('.process-btn').textContent = 'עבד';
return;
}
const data = await res.json();
trackTask(data.task_id, filename.replace(/^\d+_/, ''));
// Remove from pending after a short delay
setTimeout(loadPending, 500);
} catch (e) {
toast('שגיאת רשת', 'error');
}
}
// ── Task Tracking ─────────────────────────────────────────────
const activeTasks = new Map();
function trackTask(taskId, displayName) {
const card = document.getElementById('tasksCard');
const list = document.getElementById('tasksList');
card.style.display = 'block';
const STATUS_LABELS = {
queued: 'בתור...',
validating: 'מאמת...',
copying: 'מעתיק...',
registering: 'רושם...',
processing: 'מעבד...',
completed: 'הושלם',
failed: 'נכשל',
};
const STEP_LABELS = {
extracting: 'מחלץ טקסט',
corpus: 'מוסיף לקורפוס',
chunking: 'מפצל לקטעים',
embedding: 'יוצר embeddings',
};
const div = document.createElement('div');
div.className = 'task-item';
div.id = 'task_' + taskId;
div.innerHTML = `
<div class="spinner"></div>
<div class="task-info">
<div class="task-name">${esc(displayName)}</div>
<div class="task-status">בתור...</div>
</div>
`;
list.prepend(div);
const es = new EventSource(API + '/progress/' + taskId);
es.onmessage = e => {
const data = JSON.parse(e.data);
const statusEl = div.querySelector('.task-status');
let label = STATUS_LABELS[data.status] || data.status;
if (data.step) label += ' — ' + (STEP_LABELS[data.step] || data.step);
statusEl.textContent = label;
if (data.status === 'completed') {
div.classList.add('done');
es.close();
const r = data.result || {};
if (r.chunks !== undefined) {
statusEl.textContent = `הושלם — ${r.chunks} קטעים, ${r.pages || '?'} עמודים`;
}
toast('העיבוד הושלם: ' + esc(displayName), 'success');
} else if (data.status === 'failed') {
div.classList.add('failed');
es.close();
statusEl.textContent = 'נכשל: ' + (data.error || 'שגיאה לא ידועה');
toast('העיבוד נכשל', 'error');
}
};
es.onerror = () => { es.close(); };
}
// ── Helpers ───────────────────────────────────────────────────
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function toast(msg, type = '') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast show ' + type;
setTimeout(() => el.className = 'toast', 3000);
}
// Initial load
loadPending();
</script>
</body>
</html>