From 3e0221ccecac5b9bd9a6048fe58c9b38e8706608 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 11 Apr 2026 12:04:13 +0000 Subject: [PATCH] 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) --- mcp-server/src/legal_mcp/services/db.py | 45 +++ web/app.py | 178 ++++++++- web/static/index.html | 489 +++++++++++++++++++++++- 3 files changed, 689 insertions(+), 23 deletions(-) diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 125f578..1f8a4a5 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -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: diff --git a/web/app.py b/web/app.py index 5f1a984..80936fc 100644 --- a/web/app.py +++ b/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 ────── diff --git a/web/static/index.html b/web/static/index.html index 48c35b6..be1addb 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -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 { אימון סגנון הסגנון שלי Skills + מצב מערכת @@ -1058,13 +1266,21 @@ header nav a.active::after { -
- - -
-
טוען תיקים...
+
+
+ +
+
טוען תיקים...
+
+
+
@@ -1434,6 +1650,17 @@ header nav a.active::after { + + +
+ +
+
טוען...
+
+
@@ -1456,6 +1683,16 @@ header nav a.active::after {
+ + +