Initial commit: din-leumi MCP server + web app

MCP server with 7 tools for cataloging and searching
National Insurance court decisions with pgvector semantic search.
Web interface for upload, search, and browse.
This commit is contained in:
Chaim
2026-03-25 15:49:03 +00:00
commit 5c1fdd643f
20 changed files with 2190 additions and 0 deletions

289
web/app.py Normal file
View File

@@ -0,0 +1,289 @@
"""Din Leumi — Web interface for uploading and searching court decisions."""
from __future__ import annotations
import asyncio
import json
import logging
import re
import shutil
import sys
import time
from contextlib import asynccontextmanager
from datetime import date
from pathlib import Path
from uuid import UUID, uuid4
# Allow importing din_leumi 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, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from din_leumi import config
from din_leumi.services import db, 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)
config.DECISIONS_DIR.mkdir(parents=True, exist_ok=True)
await db.init_schema()
yield
await db.close_pool()
app = FastAPI(title="Din Leumi — דין לאומי", lifespan=lifespan)
STATIC_DIR = Path(__file__).parent / "static"
# ── Health ─────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
# ── Pages ──────────────────────────────────────────────────────────
@app.get("/")
async def index():
return FileResponse(STATIC_DIR / "index.html")
# ── Upload API ─────────────────────────────────────────────────────
@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")
ext = Path(file.filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(400, f"סוג קובץ לא נתמך: {ext}")
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}"
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(400, f"הקובץ גדול מדי. מקסימום: {MAX_FILE_SIZE // (1024*1024)}MB")
dest = UPLOAD_DIR / filename
dest.write_bytes(content)
return {
"filename": filename,
"original_name": file.filename,
"size": len(content),
}
# ── Decision API ───────────────────────────────────────────────────
class DecisionCreateRequest(BaseModel):
filename: str
title: str = ""
court: str = ""
decision_date: str = ""
case_number: str = ""
judge: str = ""
parties_appellant: str = ""
parties_respondent: str = "המוסד לביטוח לאומי"
topics: list[str] = []
outcome: str = ""
@app.post("/api/decisions")
async def create_decision(req: DecisionCreateRequest):
"""Create a decision record and start processing."""
source = UPLOAD_DIR / req.filename
if not source.exists() or not source.parent.samefile(UPLOAD_DIR):
raise HTTPException(404, "קובץ לא נמצא")
# Copy to decisions directory
original_name = re.sub(r"^\d+_", "", source.name)
dest = config.DECISIONS_DIR / original_name
if dest.exists():
stem = dest.stem
dest = config.DECISIONS_DIR / f"{stem}_{int(time.time())}{dest.suffix}"
shutil.copy2(str(source), str(dest))
# Parse date
d_date = None
if req.decision_date:
try:
d_date = date.fromisoformat(req.decision_date)
except ValueError:
raise HTTPException(400, f"פורמט תאריך לא תקין: {req.decision_date}")
title = req.title or original_name.rsplit(".", 1)[0]
# Create DB record
decision = await db.create_decision(
title=title,
file_path=str(dest),
court=req.court,
decision_date=d_date,
case_number=req.case_number,
judge=req.judge,
parties_appellant=req.parties_appellant,
parties_respondent=req.parties_respondent,
topics=req.topics if req.topics else None,
outcome=req.outcome,
)
task_id = str(uuid4())
_progress[task_id] = {"status": "queued", "filename": req.filename}
# Process in background
asyncio.create_task(_process_decision(task_id, decision, source))
return {"task_id": task_id, "decision_id": decision["id"]}
@app.get("/api/decisions")
async def list_decisions(
court: str = "",
topic: str = "",
judge: str = "",
date_from: str = "",
date_to: str = "",
outcome: str = "",
limit: int = 50,
):
"""List decisions with optional filters."""
d_from = date.fromisoformat(date_from) if date_from else None
d_to = date.fromisoformat(date_to) if date_to else None
decisions = await db.list_decisions(
court=court, topic=topic, judge=judge,
date_from=d_from, date_to=d_to,
outcome=outcome, limit=limit,
)
return decisions
@app.get("/api/decisions/{decision_id}")
async def get_decision(decision_id: str):
"""Get a single decision."""
decision = await db.get_decision(UUID(decision_id))
if not decision:
raise HTTPException(404, "פסק דין לא נמצא")
return decision
@app.delete("/api/decisions/{decision_id}")
async def delete_decision(decision_id: str):
"""Delete a decision."""
deleted = await db.delete_decision(UUID(decision_id))
if not deleted:
raise HTTPException(404, "פסק דין לא נמצא")
return {"deleted": decision_id}
# ── Search API ─────────────────────────────────────────────────────
@app.get("/api/search")
async def search_decisions(
q: str = "",
court: str = "",
topic: str = "",
date_from: str = "",
date_to: str = "",
outcome: str = "",
limit: int = 10,
):
"""Semantic search across decisions."""
if not q.strip():
raise HTTPException(400, "נדרש טקסט לחיפוש")
from din_leumi.services import embeddings
query_emb = await embeddings.embed_query(q)
d_from = date.fromisoformat(date_from) if date_from else None
d_to = date.fromisoformat(date_to) if date_to else None
results = await db.search_similar(
query_embedding=query_emb,
limit=limit,
court=court,
topic=topic,
date_from=d_from,
date_to=d_to,
outcome=outcome,
)
# Serialize dates
for r in results:
if r.get("decision_date"):
r["decision_date"] = str(r["decision_date"])
return results
# ── Progress SSE ───────────────────────────────────────────────────
@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)
await asyncio.sleep(30)
_progress.pop(task_id, None)
return StreamingResponse(event_stream(), media_type="text/event-stream")
@app.get("/api/stats")
async def stats():
"""System statistics."""
return await db.get_stats()
# ── Background Processing ─────────────────────────────────────────
async def _process_decision(task_id: str, decision: dict, source: Path):
"""Process a decision in the background."""
try:
_progress[task_id] = {"status": "processing", "filename": source.name}
result = await processor.process_decision(UUID(decision["id"]))
source.unlink(missing_ok=True)
_progress[task_id] = {
"status": result.get("status", "completed"),
"filename": source.name,
"decision_id": decision["id"],
"result": result,
}
except Exception as e:
logger.exception("Processing failed for %s", source.name)
_progress[task_id] = {
"status": "failed",
"error": str(e),
"filename": source.name,
}

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

@@ -0,0 +1,492 @@
<!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', system-ui, sans-serif;
background: #f5f7fa; color: #1a1a2e; line-height: 1.6;
}
.container { max-width: 1100px; margin: 0 auto; padding: 20px; }
header {
background: linear-gradient(135deg, #1a365d, #2d5086);
color: white; padding: 24px 0; margin-bottom: 24px;
}
header h1 { font-size: 1.8em; }
header p { opacity: 0.8; margin-top: 4px; }
.tabs {
display: flex; gap: 0; margin-bottom: 24px;
border-bottom: 2px solid #e2e8f0;
}
.tab {
padding: 10px 24px; cursor: pointer; border: none;
background: none; font-size: 1em; color: #64748b;
border-bottom: 2px solid transparent; margin-bottom: -2px;
}
.tab.active { color: #1a365d; border-bottom-color: #1a365d; font-weight: 600; }
.tab:hover { color: #1a365d; }
.panel { display: none; }
.panel.active { display: block; }
/* Cards */
.card {
background: white; border-radius: 8px; padding: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 16px;
}
.card h3 { margin-bottom: 12px; color: #1a365d; }
/* Forms */
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form-group { display: flex; flex-direction: column; gap: 4px; }
.form-group.full { grid-column: 1 / -1; }
label { font-size: 0.85em; font-weight: 600; color: #475569; }
input, select, textarea {
padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px;
font-size: 0.95em; font-family: inherit;
}
input:focus, select:focus, textarea:focus {
outline: none; border-color: #2d5086; box-shadow: 0 0 0 2px rgba(45,80,134,0.15);
}
/* Buttons */
.btn {
padding: 10px 20px; border: none; border-radius: 6px;
font-size: 0.95em; cursor: pointer; font-weight: 600;
}
.btn-primary { background: #1a365d; color: white; }
.btn-primary:hover { background: #2d5086; }
.btn-danger { background: #dc2626; color: white; }
.btn-danger:hover { background: #b91c1c; }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
/* Upload area */
.upload-area {
border: 2px dashed #d1d5db; border-radius: 8px; padding: 40px;
text-align: center; cursor: pointer; transition: all 0.2s;
}
.upload-area:hover, .upload-area.dragover {
border-color: #2d5086; background: #f0f4ff;
}
.upload-area input { display: none; }
/* Results */
.result-item {
border: 1px solid #e2e8f0; border-radius: 6px; padding: 12px;
margin-bottom: 8px;
}
.result-item:hover { border-color: #2d5086; }
.result-score {
display: inline-block; background: #e0e7ff; color: #3730a3;
padding: 2px 8px; border-radius: 4px; font-size: 0.8em; font-weight: 600;
}
.result-meta { font-size: 0.85em; color: #64748b; margin: 4px 0; }
.result-content {
font-size: 0.9em; color: #374151; margin-top: 8px;
background: #f9fafb; padding: 8px; border-radius: 4px;
max-height: 150px; overflow-y: auto;
}
/* Decision list */
.decision-row {
display: grid; grid-template-columns: 2fr 1.5fr 1fr 1fr 80px;
gap: 8px; padding: 10px 12px; align-items: center;
border-bottom: 1px solid #f1f5f9; font-size: 0.9em;
}
.decision-row:hover { background: #f8fafc; }
.decision-header { font-weight: 600; color: #475569; background: #f1f5f9; border-radius: 6px 6px 0 0; }
/* Tags */
.tag {
display: inline-block; background: #e0f2fe; color: #0369a1;
padding: 2px 8px; border-radius: 12px; font-size: 0.8em; margin: 2px;
}
/* Progress */
.progress-bar {
height: 4px; background: #e2e8f0; border-radius: 2px; overflow: hidden;
}
.progress-bar .fill {
height: 100%; background: #2d5086; transition: width 0.3s;
animation: pulse 1.5s infinite;
}
@keyframes pulse { 50% { opacity: 0.6; } }
.status-badge {
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 0.8em; font-weight: 600;
}
.status-completed { background: #d1fae5; color: #065f46; }
.status-processing { background: #fef3c7; color: #92400e; }
.status-failed { background: #fee2e2; color: #991b1b; }
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px; }
.stat-box {
background: white; border-radius: 8px; padding: 16px;
text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.stat-number { font-size: 2em; font-weight: 700; color: #1a365d; }
.stat-label { font-size: 0.85em; color: #64748b; }
.empty-state { text-align: center; padding: 40px; color: #94a3b8; }
</style>
</head>
<body>
<header>
<div class="container">
<h1>דין לאומי</h1>
<p>קטלוג וחיפוש סמנטי של פסקי דין בתחום ביטוח לאומי</p>
</div>
</header>
<div class="container">
<!-- Stats -->
<div class="stats-grid" id="stats-grid">
<div class="stat-box"><div class="stat-number" id="stat-decisions">-</div><div class="stat-label">פסקי דין</div></div>
<div class="stat-box"><div class="stat-number" id="stat-chunks">-</div><div class="stat-label">chunks</div></div>
<div class="stat-box"><div class="stat-number" id="stat-completed">-</div><div class="stat-label">עובדו בהצלחה</div></div>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" data-tab="upload">העלאה</button>
<button class="tab" data-tab="search">חיפוש</button>
<button class="tab" data-tab="browse">פסקי דין</button>
</div>
<!-- Upload Panel -->
<div class="panel active" id="panel-upload">
<div class="card">
<h3>העלאת פסק דין</h3>
<div class="upload-area" id="drop-zone">
<p>גרור קובץ לכאן או לחץ לבחירה</p>
<p style="font-size:0.85em;color:#94a3b8;margin-top:8px">PDF, DOCX, RTF, TXT (עד 50MB)</p>
<input type="file" id="file-input" accept=".pdf,.docx,.rtf,.txt">
</div>
<div id="upload-form" style="display:none; margin-top:16px">
<div id="uploaded-file-info" style="margin-bottom:12px;padding:8px;background:#f0fdf4;border-radius:6px"></div>
<div class="form-grid">
<div class="form-group full">
<label>כותרת</label>
<input type="text" id="inp-title" placeholder="כותרת תיאורית לפסק הדין">
</div>
<div class="form-group">
<label>מספר תיק</label>
<input type="text" id="inp-case-number" placeholder="בל 12345-06-20">
</div>
<div class="form-group">
<label>בית משפט</label>
<input type="text" id="inp-court" placeholder="בית הדין האזורי לעבודה ת"א">
</div>
<div class="form-group">
<label>תאריך פסק דין</label>
<input type="date" id="inp-date">
</div>
<div class="form-group">
<label>שופט/ת</label>
<input type="text" id="inp-judge">
</div>
<div class="form-group">
<label>תובע/מערער</label>
<input type="text" id="inp-appellant">
</div>
<div class="form-group">
<label>נתבע/משיב</label>
<input type="text" id="inp-respondent" value="המוסד לביטוח לאומי">
</div>
<div class="form-group">
<label>נושאים (מופרדים בפסיקים)</label>
<input type="text" id="inp-topics" placeholder="נכות כללית, תאונת עבודה">
</div>
<div class="form-group">
<label>תוצאה</label>
<select id="inp-outcome">
<option value="">לא צוין</option>
<option value="accepted">התקבלה</option>
<option value="rejected">נדחתה</option>
<option value="partial">התקבלה חלקית</option>
<option value="remanded">הוחזרה לדיון</option>
</select>
</div>
</div>
<div style="margin-top:16px;display:flex;gap:8px">
<button class="btn btn-primary" id="btn-process">העלה ועבד</button>
<button class="btn" id="btn-cancel" style="background:#e2e8f0">ביטול</button>
</div>
<div id="progress-area" style="display:none;margin-top:12px">
<div class="progress-bar"><div class="fill" style="width:100%"></div></div>
<p id="progress-text" style="font-size:0.85em;color:#64748b;margin-top:4px">מעבד...</p>
</div>
</div>
</div>
</div>
<!-- Search Panel -->
<div class="panel" id="panel-search">
<div class="card">
<h3>חיפוש סמנטי</h3>
<div style="display:flex;gap:8px;margin-bottom:12px">
<input type="text" id="search-query" placeholder="הזן שאילתת חיפוש..." style="flex:1">
<button class="btn btn-primary" id="btn-search">חפש</button>
</div>
<div class="form-grid" style="margin-bottom:12px">
<div class="form-group">
<label>בית משפט</label>
<input type="text" id="search-court" placeholder="סינון לפי בית משפט">
</div>
<div class="form-group">
<label>נושא</label>
<input type="text" id="search-topic" placeholder="סינון לפי נושא">
</div>
<div class="form-group">
<label>תוצאה</label>
<select id="search-outcome">
<option value="">הכל</option>
<option value="accepted">התקבלה</option>
<option value="rejected">נדחתה</option>
<option value="partial">חלקית</option>
<option value="remanded">הוחזרה</option>
</select>
</div>
</div>
<div id="search-results">
<div class="empty-state">הזן שאילתה לחיפוש בפסקי הדין</div>
</div>
</div>
</div>
<!-- Browse Panel -->
<div class="panel" id="panel-browse">
<div class="card">
<h3>רשימת פסקי דין</h3>
<div id="decisions-list">
<div class="empty-state">טוען...</div>
</div>
</div>
</div>
</div>
<script>
// ── State ──
let uploadedFilename = null;
// ── Tabs ──
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
document.getElementById('panel-' + tab.dataset.tab).classList.add('active');
if (tab.dataset.tab === 'browse') loadDecisions();
});
});
// ── Stats ──
async function loadStats() {
try {
const res = await fetch('/api/stats');
const data = await res.json();
document.getElementById('stat-decisions').textContent = data.total_decisions || 0;
document.getElementById('stat-chunks').textContent = data.total_chunks || 0;
document.getElementById('stat-completed').textContent = data.completed_decisions || 0;
} catch(e) { console.error('Failed to load stats', e); }
}
loadStats();
// ── Upload ──
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
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');
if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', () => { if (fileInput.files.length) handleFile(fileInput.files[0]); });
async function handleFile(file) {
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/upload', { method: 'POST', body: formData });
if (!res.ok) throw new Error((await res.json()).detail);
const data = await res.json();
uploadedFilename = data.filename;
document.getElementById('uploaded-file-info').innerHTML =
`<strong>${data.original_name}</strong> (${(data.size/1024).toFixed(1)} KB)`;
document.getElementById('upload-form').style.display = 'block';
dropZone.style.display = 'none';
} catch(e) { alert('שגיאה בהעלאה: ' + e.message); }
}
document.getElementById('btn-cancel').addEventListener('click', () => {
document.getElementById('upload-form').style.display = 'none';
dropZone.style.display = 'block';
uploadedFilename = null;
});
document.getElementById('btn-process').addEventListener('click', async () => {
if (!uploadedFilename) return;
const btn = document.getElementById('btn-process');
btn.disabled = true;
const topics = document.getElementById('inp-topics').value
.split(',').map(t => t.trim()).filter(Boolean);
const body = {
filename: uploadedFilename,
title: document.getElementById('inp-title').value,
case_number: document.getElementById('inp-case-number').value,
court: document.getElementById('inp-court').value,
decision_date: document.getElementById('inp-date').value,
judge: document.getElementById('inp-judge').value,
parties_appellant: document.getElementById('inp-appellant').value,
parties_respondent: document.getElementById('inp-respondent').value,
topics: topics,
outcome: document.getElementById('inp-outcome').value,
};
try {
const res = await fetch('/api/decisions', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
if (!res.ok) throw new Error((await res.json()).detail);
const data = await res.json();
document.getElementById('progress-area').style.display = 'block';
// Listen to SSE progress
const evtSource = new EventSource('/api/progress/' + data.task_id);
evtSource.onmessage = (e) => {
const prog = JSON.parse(e.data);
document.getElementById('progress-text').textContent =
prog.status === 'completed' ? 'הושלם!' :
prog.status === 'failed' ? 'שגיאה: ' + (prog.error || '') :
'מעבד... (' + prog.status + ')';
if (prog.status === 'completed' || prog.status === 'failed') {
evtSource.close();
btn.disabled = false;
loadStats();
if (prog.status === 'completed') {
setTimeout(() => {
document.getElementById('upload-form').style.display = 'none';
document.getElementById('progress-area').style.display = 'none';
dropZone.style.display = 'block';
uploadedFilename = null;
// Reset form
document.querySelectorAll('#upload-form input[type=text], #upload-form input[type=date]').forEach(i => i.value = '');
document.getElementById('inp-respondent').value = 'המוסד לביטוח לאומי';
document.getElementById('inp-outcome').value = '';
}, 2000);
}
}
};
} catch(e) {
alert('שגיאה: ' + e.message);
btn.disabled = false;
}
});
// ── Search ──
document.getElementById('btn-search').addEventListener('click', doSearch);
document.getElementById('search-query').addEventListener('keypress', e => { if (e.key === 'Enter') doSearch(); });
async function doSearch() {
const q = document.getElementById('search-query').value.trim();
if (!q) return;
const params = new URLSearchParams({ q });
const court = document.getElementById('search-court').value;
const topic = document.getElementById('search-topic').value;
const outcome = document.getElementById('search-outcome').value;
if (court) params.append('court', court);
if (topic) params.append('topic', topic);
if (outcome) params.append('outcome', outcome);
const container = document.getElementById('search-results');
container.innerHTML = '<div class="empty-state">מחפש...</div>';
try {
const res = await fetch('/api/search?' + params);
if (!res.ok) throw new Error((await res.json()).detail);
const results = await res.json();
if (!results.length) {
container.innerHTML = '<div class="empty-state">לא נמצאו תוצאות</div>';
return;
}
container.innerHTML = results.map((r, i) => `
<div class="result-item">
<span class="result-score">${(r.score * 100).toFixed(1)}%</span>
<strong>${r.title || 'ללא כותרת'}</strong>
<div class="result-meta">
${r.case_number ? r.case_number + ' | ' : ''}
${r.court || ''}
${r.decision_date ? ' | ' + r.decision_date : ''}
${r.judge ? ' | שופט: ' + r.judge : ''}
${r.outcome ? ' | ' + outcomeHeb(r.outcome) : ''}
</div>
<div class="result-content">${escapeHtml(r.content || '').substring(0, 500)}</div>
</div>
`).join('');
} catch(e) {
container.innerHTML = `<div class="empty-state">שגיאה: ${e.message}</div>`;
}
}
// ── Browse ──
async function loadDecisions() {
const container = document.getElementById('decisions-list');
try {
const res = await fetch('/api/decisions');
const decisions = await res.json();
if (!decisions.length) {
container.innerHTML = '<div class="empty-state">אין פסקי דין במערכת</div>';
return;
}
container.innerHTML = `
<div class="decision-row decision-header">
<span>כותרת</span><span>בית משפט</span><span>תאריך</span><span>תוצאה</span><span></span>
</div>
${decisions.map(d => `
<div class="decision-row">
<span>
<strong>${d.title || 'ללא כותרת'}</strong>
${d.case_number ? '<br><small style="color:#64748b">' + d.case_number + '</small>' : ''}
${(d.topics || []).map(t => '<span class="tag">' + t + '</span>').join('')}
</span>
<span>${d.court || '-'}</span>
<span>${d.decision_date || '-'}</span>
<span>${d.outcome ? outcomeHeb(d.outcome) : '-'}</span>
<span>
<span class="status-badge status-${d.extraction_status || 'pending'}">${d.extraction_status || 'pending'}</span>
</span>
</div>
`).join('')}
`;
} catch(e) {
container.innerHTML = `<div class="empty-state">שגיאה: ${e.message}</div>`;
}
}
// ── Helpers ──
function outcomeHeb(o) {
return { accepted: 'התקבלה', rejected: 'נדחתה', partial: 'חלקית', remanded: 'הוחזרה' }[o] || o;
}
function escapeHtml(s) {
const d = document.createElement('div'); d.textContent = s; return d.innerHTML;
}
</script>
</body>
</html>