Management UI: corpus delete, process panel, activity feed, diagnostics

- DELETE /api/training/corpus/{id} + delete button on training page,
  with confirmation dialog and recompute hint
- /api/system/tasks + floating process panel (bottom-left) showing
  active background tasks with live 3s polling
- /api/system/recent-activity derives a feed from cases, style_corpus,
  and last style_patterns run; sidebar on home page renders with
  relative timestamps
- /api/system/diagnostics + /#/diagnostics page showing DB health,
  row counts per table, active tasks, stuck documents (>10 min),
  failed extractions
- Cosmetic: signature phrase headline now prefers clean phrases over
  bracket-heavy templates for display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 12:04:13 +00:00
parent fcb2e1a325
commit 3e0221ccec
3 changed files with 689 additions and 23 deletions

View File

@@ -604,9 +604,21 @@ async def _compute_signature_phrases(conn) -> dict:
total_decisions = await conn.fetchval("SELECT count(*) FROM style_corpus")
if items:
top = items[0]
# Clean up for display: strip placeholder brackets and split alternatives
display = re.sub(r"\[[^\]]*\]", "", top["text"]).replace(" ", " ").strip()
# Pick the first item that's a relatively clean phrase, not a template
# (templates with many placeholders make bad display text)
top = None
for item in items[:5]:
text = item["text"]
placeholder_count = len(re.findall(r"\[[^\]]*\]", text))
if placeholder_count <= 1:
top = item
break
if top is None:
top = items[0]
# Clean up for display
display = re.sub(r"\[[^\]]*\]", "", top["text"])
display = re.sub(r"\s+", " ", display).strip(" .,:;\"'")
display = display.split(" / ")[0].split(" או ")[0].strip(" .,:;\"'")
if len(display) > 60:
display = display[:57] + "..."
@@ -758,6 +770,19 @@ async def training_style_report():
}
@app.delete("/api/training/corpus/{corpus_id}")
async def training_corpus_delete(corpus_id: str):
"""Remove a decision from the style corpus."""
try:
cid = UUID(corpus_id)
except ValueError:
raise HTTPException(400, "invalid corpus_id")
result = await db.delete_from_style_corpus(cid)
if not result.get("deleted"):
raise HTTPException(404, result.get("reason", "not found"))
return result
@app.get("/api/training/corpus")
async def training_corpus_list():
"""List all decisions currently in the style corpus."""
@@ -786,6 +811,25 @@ async def training_corpus_list():
]
@app.get("/api/system/tasks")
async def system_tasks():
"""List all active background tasks (from in-memory _progress dict)."""
items = []
for task_id, data in list(_progress.items()):
status = data.get("status", "unknown")
# Skip terminal states older than this request
if status in ("completed", "failed"):
continue
items.append({
"task_id": task_id,
"status": status,
"step": data.get("step", ""),
"filename": data.get("filename", ""),
"error": data.get("error", ""),
})
return {"active": items, "count": len(items)}
@app.get("/api/progress/{task_id}")
async def progress_stream(task_id: str):
"""SSE stream of processing progress."""
@@ -971,6 +1015,134 @@ async def api_processing_status():
return json.loads(result)
@app.get("/api/system/diagnostics")
async def system_diagnostics():
"""System health snapshot: DB counts, recent failures, task queue."""
pool = await db.get_pool()
async with pool.acquire() as conn:
db_ok = False
try:
await conn.fetchval("SELECT 1")
db_ok = True
except Exception:
pass
tables = {}
for t in ("cases", "documents", "document_chunks", "style_corpus", "style_patterns"):
try:
tables[t] = await conn.fetchval(f"SELECT count(*) FROM {t}")
except Exception:
tables[t] = None
# Documents that failed extraction or are stuck
failed_docs = await conn.fetch(
"SELECT d.id, d.title, d.extraction_status, d.created_at, "
" c.case_number "
"FROM documents d LEFT JOIN cases c ON d.case_id = c.id "
"WHERE d.extraction_status IN ('failed', 'error') "
"ORDER BY d.created_at DESC LIMIT 20"
)
stuck_docs = await conn.fetch(
"SELECT d.id, d.title, d.extraction_status, d.created_at, "
" c.case_number "
"FROM documents d LEFT JOIN cases c ON d.case_id = c.id "
"WHERE d.extraction_status IN ('pending', 'processing') "
" AND d.created_at < now() - interval '10 minutes' "
"ORDER BY d.created_at DESC LIMIT 20"
)
active_tasks = [
{"task_id": tid, "filename": d.get("filename", ""),
"status": d.get("status", ""), "step": d.get("step", "")}
for tid, d in _progress.items()
if d.get("status") not in ("completed", "failed")
]
return {
"db_ok": db_ok,
"tables": tables,
"failed_documents": [
{
"id": str(r["id"]),
"title": r["title"] or "",
"status": r["extraction_status"],
"case_number": r["case_number"] or "",
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
}
for r in failed_docs
],
"stuck_documents": [
{
"id": str(r["id"]),
"title": r["title"] or "",
"status": r["extraction_status"],
"case_number": r["case_number"] or "",
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
}
for r in stuck_docs
],
"active_tasks": active_tasks,
}
@app.get("/api/system/recent-activity")
async def system_recent_activity(limit: int = 8):
"""Derive a feed of recent events from cases + style_corpus + style_patterns.
Each event has: type, label, timestamp, target.
"""
pool = await db.get_pool()
events: list[dict] = []
async with pool.acquire() as conn:
# Recent cases
cases = await conn.fetch(
"SELECT case_number, title, created_at FROM cases "
"ORDER BY created_at DESC LIMIT $1", limit
)
for c in cases:
events.append({
"type": "case_created",
"label": f"תיק חדש: ערר {c['case_number']}",
"detail": c["title"] or "",
"timestamp": c["created_at"].isoformat() if c["created_at"] else None,
"target": f"/#/case/{c['case_number']}",
})
# Recent corpus additions
corpus = await conn.fetch(
"SELECT decision_number, created_at FROM style_corpus "
"ORDER BY created_at DESC LIMIT $1", limit
)
for r in corpus:
events.append({
"type": "corpus_added",
"label": f"החלטה נוספה לקורפוס: {r['decision_number'] or 'ללא מספר'}",
"detail": "",
"timestamp": r["created_at"].isoformat() if r["created_at"] else None,
"target": "/#/training",
})
# Last style analysis run (if any)
last_pattern = await conn.fetchrow(
"SELECT created_at FROM style_patterns "
"ORDER BY created_at DESC LIMIT 1"
)
if last_pattern and last_pattern["created_at"]:
count = await conn.fetchval("SELECT count(*) FROM style_patterns")
events.append({
"type": "analysis_run",
"label": f"ניתוח סגנון — {count} דפוסים חולצו",
"detail": "",
"timestamp": last_pattern["created_at"].isoformat(),
"target": "/#/style-report",
})
# Sort by timestamp desc, take top N
events.sort(key=lambda e: e["timestamp"] or "", reverse=True)
return {"events": events[:limit]}
# ── Workflow API — outcome, direction, claims, QA, learning ──────

View File

@@ -396,6 +396,71 @@ header nav a.active::after {
font-style: italic;
}
/* Home layout: main + activity sidebar */
.home-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: var(--space-6);
}
.home-main { min-width: 0; }
.home-sidebar {
background: var(--color-parchment);
border: 1px solid var(--color-rule);
border-radius: var(--radius-lg);
padding: var(--space-5);
height: fit-content;
position: sticky;
top: var(--space-5);
}
.home-aside-title {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--color-gold-deep);
font-weight: 700;
margin-bottom: var(--space-4);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-gold);
}
/* Activity feed */
.activity-feed { display: flex; flex-direction: column; gap: var(--space-3); }
.activity-item {
padding: var(--space-3);
background: var(--color-surface);
border: 1px solid var(--color-rule-soft);
border-radius: var(--radius);
font-size: 0.82em;
transition: all var(--t);
cursor: pointer;
border-right: 3px solid var(--color-gold);
}
.activity-item:hover {
box-shadow: var(--shadow-sm);
border-right-color: var(--color-gold-deep);
}
.activity-label {
color: var(--color-navy);
font-weight: 600;
line-height: 1.4;
margin-bottom: 4px;
}
.activity-detail {
color: var(--color-ink-muted);
font-size: 0.9em;
line-height: 1.4;
margin-bottom: 4px;
}
.activity-time {
color: var(--color-ink-light);
font-size: 0.78em;
}
.activity-icon { font-size: 0.9em; margin-left: 4px; }
@media (max-width: 1000px) {
.home-grid { grid-template-columns: 1fr; }
.home-sidebar { position: static; }
}
@media (max-width: 800px) {
.kpi-row { grid-template-columns: repeat(2, 1fr); }
.home-hero { flex-direction: column; align-items: flex-start; gap: var(--space-4); }
@@ -556,6 +621,140 @@ header nav a.active::after {
border-color: var(--color-success);
}
/* Processing visibility panel (floating bottom-right) */
.process-panel {
position: fixed;
bottom: var(--space-5);
left: var(--space-5);
width: 320px;
max-height: 60vh;
background: var(--color-surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
border: 1px solid var(--color-gold);
overflow: hidden;
z-index: 900;
animation: fadeSlideUp 0.35s var(--ease-out);
}
@keyframes fadeSlideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.process-panel.collapsed .process-panel-body { display: none; }
.process-panel-header {
padding: 12px 16px;
background: var(--color-navy);
color: var(--color-parchment);
display: flex;
align-items: center;
gap: 10px;
border-bottom: 2px solid var(--color-gold);
}
.process-panel-title {
font-weight: 600;
font-size: 0.9em;
flex: 1;
}
.process-panel-count {
background: var(--color-gold);
color: var(--color-navy);
padding: 2px 10px;
border-radius: var(--radius-pill);
font-size: 0.78em;
font-weight: 700;
min-width: 24px;
text-align: center;
}
.process-panel .btn-icon {
color: var(--color-parchment);
font-size: 1.3em;
line-height: 1;
padding: 0 8px;
}
.process-panel .btn-icon:hover {
color: var(--color-gold-soft);
background: transparent;
}
.process-panel-body {
padding: 10px 14px;
max-height: calc(60vh - 50px);
overflow-y: auto;
}
.process-item {
padding: 10px 12px;
background: var(--color-cream);
border-radius: var(--radius);
margin-bottom: 6px;
font-size: 0.82em;
border-right: 3px solid var(--color-gold);
}
.process-item:last-child { margin-bottom: 0; }
.process-item-name {
font-weight: 500;
color: var(--color-navy);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.process-item-status {
display: flex;
align-items: center;
gap: 6px;
color: var(--color-ink-muted);
font-size: 0.92em;
}
/* Diagnostics page */
.diag-pill {
display: inline-block;
padding: 3px 12px;
border-radius: var(--radius-pill);
font-size: 0.78em;
font-weight: 600;
margin-right: auto;
}
.diag-pill.ok { background: var(--color-success-bg); color: var(--color-success); }
.diag-pill.error { background: var(--color-danger-bg); color: var(--color-danger); }
.diag-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: var(--space-4);
}
.diag-stat {
padding: var(--space-4);
background: var(--color-cream);
border-radius: var(--radius);
border-right: 3px solid var(--color-gold);
}
.diag-stat-label {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-ink-muted);
font-weight: 600;
margin-bottom: 4px;
}
.diag-stat-value {
font-size: var(--text-2xl);
font-weight: 800;
color: var(--color-navy);
line-height: 1;
}
.diag-row {
padding: 10px 14px;
background: var(--color-cream);
border-radius: var(--radius);
margin-bottom: 6px;
font-size: 0.86em;
border-right: 3px solid var(--color-rule);
}
.diag-row:last-child { margin-bottom: 0; }
.diag-row-error { border-right-color: var(--color-danger); background: var(--color-danger-bg); }
.diag-row-warn { border-right-color: var(--color-warn); background: var(--color-warn-bg); }
.diag-row-title { font-weight: 600; color: var(--color-navy); margin-bottom: 2px; }
.diag-row-meta { font-size: 0.88em; color: var(--color-ink-muted); }
/* ── Local files (research, drafts, proofread) ───────── */
.local-file-group { margin-bottom: 12px; }
.local-file-group-header { font-size: 0.82em; font-weight: 600; color: #666; margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid #eee; }
@@ -696,7 +895,15 @@ header nav a.active::after {
background: #f7f7f7; font-weight: 600; color: #555;
font-size: 0.78em; text-transform: uppercase;
}
.corpus-table tr:hover td { background: #fafafa; }
.corpus-table tr:hover td { background: var(--color-cream); }
.btn-icon {
background: transparent; border: none; cursor: pointer;
color: var(--color-ink-light); font-size: 1.05em;
padding: 6px 8px; border-radius: var(--radius);
transition: all var(--t);
}
.btn-icon:hover { color: var(--color-navy); background: var(--color-cream-deep); }
.btn-icon-danger:hover { color: var(--color-danger); background: var(--color-danger-bg); }
.cat-tag {
display: inline-block; padding: 2px 8px; margin: 0 2px;
background: #e3f2fd; color: #1565c0; border-radius: 10px;
@@ -1019,6 +1226,7 @@ header nav a.active::after {
<a href="#/training" id="navTraining">אימון סגנון</a>
<a href="#/style-report" id="navStyleReport">הסגנון שלי</a>
<a href="#/skills" id="navSkills">Skills</a>
<a href="#/diagnostics" id="navDiagnostics">מצב מערכת</a>
</nav>
</header>
@@ -1058,13 +1266,21 @@ header nav a.active::after {
</div>
</div>
<div class="divider-gold"></div>
<div class="page-header">
<h2>תיקים פעילים</h2>
</div>
<div class="case-grid" id="caseGrid">
<div class="empty">טוען תיקים...</div>
<div class="home-grid">
<div class="home-main">
<div class="page-header">
<h2>תיקים פעילים</h2>
</div>
<div class="case-grid" id="caseGrid">
<div class="empty">טוען תיקים...</div>
</div>
</div>
<aside class="home-sidebar">
<h3 class="home-aside-title">פעילות אחרונה</h3>
<div class="activity-feed" id="activityFeed">
<div class="empty" style="font-size:0.82em;padding:20px">טוען...</div>
</div>
</aside>
</div>
</div>
@@ -1434,6 +1650,17 @@ header nav a.active::after {
</div>
</div>
<!-- ══ Page: Diagnostics ══ -->
<div class="page" id="page-diagnostics">
<div class="page-header">
<h2>מצב מערכת</h2>
<button class="btn btn-ghost btn-sm" onclick="loadDiagnostics()">רענן</button>
</div>
<div id="diagnosticsContent">
<div class="empty">טוען...</div>
</div>
</div>
</div>
<!-- Modal for pattern examples -->
@@ -1456,6 +1683,16 @@ header nav a.active::after {
<div class="toast" id="toast"></div>
<!-- Processing visibility panel (floating) -->
<div class="process-panel" id="processPanel" style="display:none">
<div class="process-panel-header">
<span class="process-panel-title">עיבוד פעיל</span>
<span class="process-panel-count" id="processPanelCount">0</span>
<button class="btn-icon" onclick="toggleProcessPanel()" title="הסתר"></button>
</div>
<div class="process-panel-body" id="processPanelBody"></div>
</div>
<script>
const API = '/api';
let currentCaseNumber = '';
@@ -1518,13 +1755,22 @@ function handleRoute() {
document.getElementById('navStyleReport').classList.add('active');
subtitle = 'פורטרט הסגנון שלי';
loadStyleReport();
} else if (hash === '#/diagnostics') {
document.getElementById('page-diagnostics').classList.add('active');
document.getElementById('navDiagnostics').classList.add('active');
subtitle = 'מצב מערכת';
loadDiagnostics();
}
document.getElementById('pageSubtitle').textContent = subtitle;
}
window.addEventListener('hashchange', handleRoute);
window.addEventListener('load', () => { handleRoute(); loadStatus(); });
window.addEventListener('load', () => {
handleRoute();
loadStatus();
startProcessPanelPolling();
});
// ── Case List ────────────────────────────────────────────
async function loadCaseList() {
@@ -2268,31 +2514,215 @@ async function loadStatus() {
} catch (e) {}
}
// ── Diagnostics Page ─────────────────────────────────
async function loadDiagnostics() {
const container = document.getElementById('diagnosticsContent');
if (!container) return;
container.innerHTML = '<div class="empty">טוען...</div>';
try {
const res = await fetch(API + '/system/diagnostics');
const data = await res.json();
const dbBadge = data.db_ok
? '<span class="diag-pill ok">✓ מחובר</span>'
: '<span class="diag-pill error">✕ תקלה</span>';
const tablesHtml = Object.entries(data.tables).map(([name, count]) => {
const label = {
cases: 'תיקים',
documents: 'מסמכים',
document_chunks: 'קטעים',
style_corpus: 'קורפוס סגנון',
style_patterns: 'דפוסים',
}[name] || name;
const val = count === null ? '⚠️' : count.toLocaleString('he-IL');
return `<div class="diag-stat"><div class="diag-stat-label">${esc(label)}</div><div class="diag-stat-value">${val}</div></div>`;
}).join('');
const failedHtml = (data.failed_documents || []).map(d => `
<div class="diag-row diag-row-error">
<div class="diag-row-title">${esc(d.title || '(ללא שם)')}</div>
<div class="diag-row-meta">
${d.case_number ? `תיק ${esc(d.case_number)} · ` : ''}
סטטוס: <strong>${esc(d.status)}</strong>
</div>
</div>
`).join('') || '<div class="empty" style="padding:16px">אין כישלונות</div>';
const stuckHtml = (data.stuck_documents || []).map(d => `
<div class="diag-row diag-row-warn">
<div class="diag-row-title">${esc(d.title || '(ללא שם)')}</div>
<div class="diag-row-meta">
${d.case_number ? `תיק ${esc(d.case_number)} · ` : ''}
${esc(d.status)} מאז ${formatRelativeTime(d.created_at)}
</div>
</div>
`).join('') || '<div class="empty" style="padding:16px">אין מסמכים תקועים</div>';
const activeHtml = (data.active_tasks || []).map(t => `
<div class="diag-row">
<div class="diag-row-title">${esc(t.filename || t.task_id)}</div>
<div class="diag-row-meta">${esc(STEP_LABELS[t.step] || STEP_LABELS[t.status] || t.status)}</div>
</div>
`).join('') || '<div class="empty" style="padding:16px">אין משימות פעילות</div>';
container.innerHTML = `
<div class="card">
<div class="card-header">בריאות בסיס נתונים ${dbBadge}</div>
<div class="card-body">
<div class="diag-stats-grid">${tablesHtml}</div>
</div>
</div>
<div class="card">
<div class="card-header">משימות פעילות ברקע</div>
<div class="card-body">${activeHtml}</div>
</div>
<div class="card">
<div class="card-header">מסמכים תקועים (יותר מ-10 דקות)</div>
<div class="card-body">${stuckHtml}</div>
</div>
<div class="card">
<div class="card-header">מסמכים שכשלו</div>
<div class="card-body">${failedHtml}</div>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="empty">שגיאה בטעינה: ${esc(e.message)}</div>`;
}
}
// ── Processing Visibility Panel ──────────────────────
let _processPollTimer = null;
function startProcessPanelPolling() {
if (_processPollTimer) return;
pollProcessPanel();
_processPollTimer = setInterval(pollProcessPanel, 3000);
}
async function pollProcessPanel() {
try {
const res = await fetch(API + '/system/tasks');
const data = await res.json();
renderProcessPanel(data.active || []);
} catch (e) {}
}
const STEP_LABELS = {
queued: 'בתור',
processing: 'מעבד',
proofreading: 'הגהה',
saving: 'שומר',
corpus: 'קורפוס',
chunking: 'פיצול',
embedding: 'embeddings',
validating: 'מאמת',
copying: 'מעתיק',
registering: 'רושם',
extracting: 'חילוץ טקסט',
};
function renderProcessPanel(items) {
const panel = document.getElementById('processPanel');
const body = document.getElementById('processPanelBody');
const count = document.getElementById('processPanelCount');
if (!panel) return;
if (!items.length) {
panel.style.display = 'none';
return;
}
panel.style.display = '';
count.textContent = items.length;
body.innerHTML = items.map(t => {
const label = STEP_LABELS[t.step] || STEP_LABELS[t.status] || t.status;
const name = t.filename || t.task_id;
return `
<div class="process-item">
<div class="process-item-name">${esc(name)}</div>
<div class="process-item-status">
<span class="mini-spinner"></span>
<span>${esc(label)}</span>
</div>
</div>
`;
}).join('');
}
function toggleProcessPanel() {
const panel = document.getElementById('processPanel');
panel.classList.toggle('collapsed');
}
async function loadKPIs() {
// Home dashboard KPI tiles
// Home dashboard KPI tiles + activity feed
const casesEl = document.getElementById('kpiCases');
const corpusEl = document.getElementById('kpiCorpus');
const patternsEl = document.getElementById('kpiPatterns');
const procEl = document.getElementById('kpiProcessing');
if (!casesEl) return;
try {
const [statusRes, corpusRes, patternsRes] = await Promise.all([
const [statusRes, corpusRes, patternsRes, activityRes] = await Promise.all([
fetch(API + '/processing-status').then(r => r.json()).catch(() => ({})),
fetch(API + '/training/corpus').then(r => r.json()).catch(() => []),
fetch(API + '/training/patterns').then(r => r.json()).catch(() => ({total: 0})),
fetch(API + '/system/recent-activity').then(r => r.json()).catch(() => ({events: []})),
]);
casesEl.textContent = statusRes.cases ?? '0';
corpusEl.textContent = corpusRes.length || '0';
patternsEl.textContent = patternsRes.total || '0';
// Count decisions currently being processed
document.getElementById('kpiCases').textContent = statusRes.cases ?? '0';
document.getElementById('kpiCorpus').textContent = corpusRes.length || '0';
document.getElementById('kpiPatterns').textContent = patternsRes.total || '0';
const procCount = (statusRes.processing_documents ?? statusRes.processing ?? 0);
procEl.textContent = procCount || '0';
document.getElementById('kpiProcessing').textContent = procCount || '0';
renderActivityFeed(activityRes.events || []);
} catch (e) {
console.error('KPI load failed', e);
}
}
const ACTIVITY_ICONS = {
case_created: '📁',
corpus_added: '📚',
analysis_run: '✨',
};
function formatRelativeTime(iso) {
if (!iso) return '';
const then = new Date(iso);
const diffMs = Date.now() - then.getTime();
const min = Math.floor(diffMs / 60000);
if (min < 1) return 'עכשיו';
if (min < 60) return `לפני ${min} דקות`;
const hr = Math.floor(min / 60);
if (hr < 24) return `לפני ${hr} שעות`;
const days = Math.floor(hr / 24);
if (days < 30) return `לפני ${days} ימים`;
return then.toLocaleDateString('he-IL');
}
function renderActivityFeed(events) {
const feed = document.getElementById('activityFeed');
if (!feed) return;
if (!events.length) {
feed.innerHTML = '<div class="empty" style="font-size:0.82em;padding:20px">עדיין אין פעילות</div>';
return;
}
feed.innerHTML = events.map(e => {
const icon = ACTIVITY_ICONS[e.type] || '•';
const target = e.target || '#/';
return `
<div class="activity-item" onclick="location.hash='${esc(target.replace(/^\/?#/, ''))}'">
<div class="activity-label"><span class="activity-icon">${icon}</span>${esc(e.label)}</div>
${e.detail ? `<div class="activity-detail">${esc(e.detail)}</div>` : ''}
<div class="activity-time">${formatRelativeTime(e.timestamp)}</div>
</div>
`;
}).join('');
}
// ── Helpers ──────────────────────────────────────────────
function esc(s) {
if (!s) return '';
@@ -3108,6 +3538,24 @@ function renderContribution(contrib) {
}).join('');
}
async function deleteCorpusItem(id, decisionNumber) {
if (!confirm(`להסיר את החלטה ${decisionNumber || '(ללא מספר)'} מהקורפוס?\n\nפעולה זו בלתי הפיכה — הדפוסים הקשורים להחלטה עצמה יישארו, אבל עקומת הלמידה תחושב מחדש בריצה הבאה של "נתח קורפוס".`)) {
return;
}
try {
const res = await fetch(API + '/training/corpus/' + encodeURIComponent(id), { method: 'DELETE' });
if (!res.ok) {
const err = await res.text();
throw new Error(err);
}
toast('ההחלטה הוסרה מהקורפוס', 'success');
loadCorpusList();
loadKPIs();
} catch (e) {
toast('שגיאה במחיקה: ' + e.message, 'error');
}
}
async function loadCorpusList() {
const container = document.getElementById('corpusList');
const count = document.getElementById('corpusCount');
@@ -3122,7 +3570,7 @@ async function loadCorpusList() {
container.innerHTML = `
<table class="corpus-table">
<thead>
<tr><th>מספר</th><th>תאריך</th><th>קטגוריות</th><th>תווים</th><th>נוצר</th></tr>
<tr><th>מספר</th><th>תאריך</th><th>קטגוריות</th><th>תווים</th><th>נוצר</th><th></th></tr>
</thead>
<tbody>
${rows.map(r => `
@@ -3132,6 +3580,7 @@ async function loadCorpusList() {
<td>${(r.subject_categories || []).map(c => `<span class="cat-tag">${esc(c)}</span>`).join('')}</td>
<td>${r.chars.toLocaleString('he-IL')}</td>
<td>${esc(r.created_at ? r.created_at.substring(0, 10) : '—')}</td>
<td><button class="btn-icon btn-icon-danger" title="הסר מהקורפוס" onclick="deleteCorpusItem('${esc(r.id)}', '${esc(r.decision_number || '')}')">🗑</button></td>
</tr>
`).join('')}
</tbody>