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:
@@ -794,6 +794,51 @@ async def add_to_style_corpus(
|
||||
return corpus_id
|
||||
|
||||
|
||||
async def delete_from_style_corpus(corpus_id: UUID) -> dict:
|
||||
"""Remove a decision from style_corpus + related documents (cascades chunks).
|
||||
|
||||
Also tries to delete the [קורפוס] document associated by title match,
|
||||
since the current training pipeline inserts style_corpus with document_id=NULL.
|
||||
"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
row = await conn.fetchrow(
|
||||
"DELETE FROM style_corpus WHERE id = $1 "
|
||||
"RETURNING decision_number, document_id",
|
||||
corpus_id,
|
||||
)
|
||||
if not row:
|
||||
return {"deleted": False, "reason": "not found"}
|
||||
|
||||
docs_deleted = 0
|
||||
if row["document_id"]:
|
||||
await conn.execute(
|
||||
"DELETE FROM documents WHERE id = $1", row["document_id"]
|
||||
)
|
||||
docs_deleted = 1
|
||||
else:
|
||||
# Best-effort: match a [קורפוס] document by the decision_number
|
||||
# in its title. Only for single, unambiguous matches.
|
||||
if row["decision_number"]:
|
||||
docs = await conn.fetch(
|
||||
"SELECT id FROM documents "
|
||||
"WHERE case_id IS NULL AND title LIKE $1",
|
||||
f"%{row['decision_number']}%",
|
||||
)
|
||||
if len(docs) == 1:
|
||||
await conn.execute(
|
||||
"DELETE FROM documents WHERE id = $1", docs[0]["id"]
|
||||
)
|
||||
docs_deleted = 1
|
||||
|
||||
return {
|
||||
"deleted": True,
|
||||
"decision_number": row["decision_number"],
|
||||
"docs_deleted": docs_deleted,
|
||||
}
|
||||
|
||||
|
||||
async def get_style_patterns(pattern_type: str | None = None) -> list[dict]:
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
|
||||
178
web/app.py
178
web/app.py
@@ -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 ──────
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user