diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 59b5b7a..b15fbbe 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -1488,6 +1488,19 @@ CREATE INDEX IF NOT EXISTS idx_panel_rounds_halacha ON halacha_panel_rounds(hala CREATE INDEX IF NOT EXISTS idx_panel_rounds_ts ON halacha_panel_rounds(round_ts); """ +SCHEMA_V36_SQL = """ +-- learning_run on draft_final_pairs: the voice-learning pipeline's RUN OUTCOME. +-- The pair's `status` (final_received→analyzed→lessons_folded) records how far the +-- DISTILLATION advanced, but not whether the run-learning button's pipeline +-- (scripts/final_learning_pipeline.py) actually completed or crashed mid-way — a +-- crash leaves status at final_received, indistinguishable from "never run". This +-- column stamps the explicit outcome so the case can SHOW "succeeded / failed + +-- reason / not-run". CAPTURE field on the existing INV-LRN4 ledger (not a parallel +-- tracking path, G1/G2). Shape: {status:'succeeded'|'failed', error, at, steps}. +-- NULL = the pipeline never recorded an outcome for this pair (treated as not-run). +ALTER TABLE draft_final_pairs ADD COLUMN IF NOT EXISTS learning_run JSONB; +""" + async def _run_schema_migrations(pool: asyncpg.Pool) -> None: async with pool.acquire() as conn: @@ -1527,7 +1540,8 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None: await conn.execute(SCHEMA_V33_SQL) await conn.execute(SCHEMA_V34_SQL) await conn.execute(SCHEMA_V35_SQL) - logger.info("Database schema initialized (v1-v35)") + await conn.execute(SCHEMA_V36_SQL) + logger.info("Database schema initialized (v1-v36)") async def init_schema() -> None: @@ -2627,6 +2641,158 @@ async def update_draft_final_pair( ) +async def set_learning_run_outcome( + case_id: UUID, + status: str, + error: str = "", + steps: dict | None = None, +) -> bool: + """Stamp the voice-learning pipeline's RUN OUTCOME on the case's latest pair (SCHEMA_V36). + + The pair's `status` says how far the distillation advanced; this says whether the + run-learning pipeline (scripts/final_learning_pipeline.py) completed or crashed — + so the case can show 'succeeded / failed + reason'. CAPTURE field on the INV-LRN4 + ledger (not a parallel path). Returns False if no pair exists yet (nothing to stamp). + """ + payload = { + "status": status, # 'succeeded' | 'failed' + "error": error or "", + "steps": steps or {}, + } + pool = await get_pool() + async with pool.acquire() as conn: + # at = DB now() (no clock in the script path); written via the SQL expression. + row = await conn.fetchrow( + """UPDATE draft_final_pairs + SET learning_run = ($2::jsonb || jsonb_build_object('at', now())), + updated_at = now() + WHERE id = ( + SELECT id FROM draft_final_pairs + WHERE case_id = $1 ORDER BY created_at DESC LIMIT 1 + ) + RETURNING id""", + UUID(str(case_id)), json.dumps(payload, ensure_ascii=False), + ) + return row is not None + + +def _as_obj(value) -> dict: + """JSONB columns come back as text (no codec registered) — parse defensively.""" + if isinstance(value, dict): + return value + if isinstance(value, str) and value: + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except (ValueError, TypeError): + return {} + return {} + + +# Halacha-extraction status values that mean the extractor ran and could not produce +# a usable result — surfaced (not swallowed) so the case shows WHY it didn't complete. +_HALACHA_FAIL_STATUSES = {"failed", "partial", "extraction_failed", "no_chunks"} + + +async def case_learning_status(case: dict) -> dict: + """Derived (read-only) status of the two post-final pipelines for one case. + + Single source of truth — both MCP `case_get` and GET /api/cases/{case}/learning-status + call this; no parallel logic. Derives from EXISTING tables (draft_final_pairs, + style_corpus, decision_lessons, case_law, halachot); the only persisted addition is + draft_final_pairs.learning_run (SCHEMA_V36), the voice pipeline's explicit outcome. + """ + case_id = UUID(str(case["id"])) + case_number = case.get("case_number", "") + pool = await get_pool() + async with pool.acquire() as conn: + pair = await conn.fetchrow( + """SELECT status, analysis, learning_run, updated_at + FROM draft_final_pairs + WHERE case_id = $1 ORDER BY created_at DESC LIMIT 1""", + case_id, + ) + corpus = await conn.fetchrow( + """SELECT id FROM style_corpus WHERE decision_number = $1 LIMIT 1""", + case_number, + ) + lessons_proposed = await conn.fetchval( + """SELECT count(*) FROM decision_lessons dl + JOIN style_corpus sc ON sc.id = dl.style_corpus_id + WHERE sc.decision_number = $1""", + case_number, + ) + law = await conn.fetchrow( + """SELECT id, halacha_extraction_status FROM case_law + WHERE case_number = $1 AND source_kind = 'internal_committee' + ORDER BY created_at DESC LIMIT 1""", + case_number, + ) + halacha_counts = {"total": 0, "approved": 0, "pending": 0, "rejected": 0} + if law: + crow = await conn.fetchrow( + """SELECT count(*) AS total, + count(*) FILTER (WHERE review_status = 'approved') AS approved, + count(*) FILTER (WHERE review_status = 'pending_review') AS pending, + count(*) FILTER (WHERE review_status = 'rejected') AS rejected + FROM halachot WHERE case_law_id = $1""", + law["id"], + ) + halacha_counts = dict(crow) + + # ── Voice learning ────────────────────────────────────────────────── + pair_status = pair["status"] if pair else "" + run = _as_obj(pair["learning_run"]) if pair else {} + if run: + outcome = run.get("status") or "not_run" # explicit pipeline outcome (V36) + run_error = run.get("error") or "" + elif pair_status in ("analyzed", "lessons_folded"): + outcome = "succeeded" # distillation done (pre-V36 / direct ingest) + run_error = "" + else: + outcome = "not_run" + run_error = "" + analysis = _as_obj(pair["analysis"]) if pair else {} + voice = { + "ran": bool(run), + "outcome": outcome, # succeeded | failed | not_run + "error": run_error or None, + "pair_status": pair_status, + "lessons_count": len(analysis.get("changes", []) or []), + "style_corpus_enrolled": corpus is not None, + "lessons_proposed": int(lessons_proposed or 0), + "analyzed_at": pair["updated_at"].isoformat() if pair and pair["updated_at"] else None, + } + + # ── Halacha extraction ────────────────────────────────────────────── + if not law: + halacha = { + "enrolled_in_corpus": False, + "not_enrolled_reason": "ההחלטה הסופית טרם נכנסה לקורפוס-הפסיקה הפנימי — אין ממה לחלץ הלכות", + "status": None, + "halachot_count": 0, + "approved": 0, "pending": 0, "rejected": 0, + } + else: + hstatus = law["halacha_extraction_status"] or "pending" + halacha = { + "enrolled_in_corpus": True, + "not_enrolled_reason": None, + "status": hstatus, + "failed": hstatus in _HALACHA_FAIL_STATUSES, + "halachot_count": int(halacha_counts.get("total", 0) or 0), + "approved": int(halacha_counts.get("approved", 0) or 0), + "pending": int(halacha_counts.get("pending", 0) or 0), + "rejected": int(halacha_counts.get("rejected", 0) or 0), + } + + return { + "final_uploaded": pair is not None, + "voice_learning": voice, + "halacha_extraction": halacha, + } + + async def list_draft_final_pairs(status: str | None = None, limit: int = 200) -> list[dict]: """Reconciliation ledger: all decisions paired with their final + status.""" pool = await get_pool() diff --git a/mcp-server/src/legal_mcp/tools/cases.py b/mcp-server/src/legal_mcp/tools/cases.py index 0020ef4..4b90b88 100644 --- a/mcp-server/src/legal_mcp/tools/cases.py +++ b/mcp-server/src/legal_mcp/tools/cases.py @@ -295,6 +295,15 @@ async def case_get(case_number: str) -> str: docs = await db.list_documents(UUID(case["id"])) case["documents"] = docs + # Derived post-final pipeline status (voice learning + halacha extraction) so the + # case shows whether each ran, succeeded, and how many halachot were extracted. + # Read-only derivation from existing tables (single source — same fn the + # /learning-status endpoint uses); best-effort, never fails the case fetch. + try: + case["learning_status"] = await db.case_learning_status(case) + except Exception as e: # noqa: BLE001 — indicator is best-effort, must not 500 case_get + logger.warning("case_learning_status failed for %s: %s", case_number, e) + case["learning_status"] = None return ok(case) diff --git a/scripts/final_learning_pipeline.py b/scripts/final_learning_pipeline.py index 7afa7c6..edbb893 100644 --- a/scripts/final_learning_pipeline.py +++ b/scripts/final_learning_pipeline.py @@ -165,8 +165,21 @@ async def main(args: argparse.Namespace) -> int: ) except Exception as e: # fatal step (e.g. ingest error) — clean non-zero exit print(f"\n✗ pipeline-למידה נכשל: {e}") + # Stamp the explicit FAILURE outcome on the pair so the case shows why (a + # crash otherwise leaves status='final_received' — indistinguishable from + # never-run). Skipped in dry-run. Surfaced, never swallowed. + if not args.dry_run: + try: + await db.set_learning_run_outcome(case["id"], "failed", error=str(e)) + except Exception as stamp_err: + print(f" ⚠️ לא ניתן לחתום תוצאת-כישלון: {stamp_err}") return 1 print("\n✓ pipeline-למידה הושלם" + (" (dry-run)" if args.dry_run else "")) + if not args.dry_run: + try: + await db.set_learning_run_outcome(case["id"], "succeeded", steps=results) + except Exception as stamp_err: + print(f" ⚠️ לא ניתן לחתום תוצאת-הצלחה: {stamp_err}") return int(results.get("panel_rc", 0) or 0) diff --git a/web-ui/src/components/cases/drafts-panel.tsx b/web-ui/src/components/cases/drafts-panel.tsx index a3c9fe7..67c0c59 100644 --- a/web-ui/src/components/cases/drafts-panel.tsx +++ b/web-ui/src/components/cases/drafts-panel.tsx @@ -1,6 +1,7 @@ "use client"; import { useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -34,6 +35,8 @@ import { type FeedbackCategory, } from "@/lib/api/feedback"; import { useCaseCitations } from "@/lib/api/citations"; +import { learningKeys } from "@/lib/api/learning"; +import { LearningStatusBadges } from "@/components/cases/learning-status-badges"; import type { CaseStatus } from "@/lib/api/cases"; import { toast } from "sonner"; import { @@ -98,6 +101,7 @@ export function DraftsPanel({ const uploadFinal = useUploadFinalDecision(caseNumber); const runLearning = useRunFinalLearning(caseNumber); const runHalacha = useRunFinalHalacha(caseNumber); + const qc = useQueryClient(); const fileRef = useRef(null); const finalFileRef = useRef(null); @@ -186,20 +190,29 @@ export function DraftsPanel({ function handleRunLearning() { runLearning.mutate(undefined, { - onSuccess: (d) => - d.status === "ok" - ? toast.success("למידת-הקול הופעלה — רצה ברקע (אופוס + פאנל דיפסיק/גמיני)") - : toast.warning(`לא הופעלה למידה: ${d.reason ?? d.error ?? d.status}`), + onSuccess: (d) => { + // Background run — refresh the indicator (the poll picks up later transitions). + qc.invalidateQueries({ queryKey: learningKeys.caseStatus(caseNumber) }); + if (d.status === "ok") { + toast.success("למידת-הקול הופעלה — רצה ברקע (אופוס + פאנל דיפסיק/גמיני)"); + } else { + toast.warning(`לא הופעלה למידה: ${d.reason ?? d.error ?? d.status}`); + } + }, onError: () => toast.error("שגיאה בהפעלת למידת-הקול"), }); } function handleRunHalacha() { runHalacha.mutate(undefined, { - onSuccess: (d) => - d.status === "ok" - ? toast.success("אימות-ההלכות הופעל — רץ ברקע (פאנל אופוס/דיפסיק/גמיני)") - : toast.warning(`לא הופעל אימות: ${d.reason ?? d.error ?? d.status}`), + onSuccess: (d) => { + qc.invalidateQueries({ queryKey: learningKeys.caseStatus(caseNumber) }); + if (d.status === "ok") { + toast.success("אימות-ההלכות הופעל — רץ ברקע (פאנל אופוס/דיפסיק/גמיני)"); + } else { + toast.warning(`לא הופעל אימות: ${d.reason ?? d.error ?? d.status}`); + } + }, onError: () => toast.error("שגיאה בהפעלת אימות-ההלכות"), }); } @@ -337,6 +350,7 @@ export function DraftsPanel({ )} + {hasFinal && } {/* ── Precedents cited inside the signed decision ── */} diff --git a/web-ui/src/components/cases/learning-status-badges.tsx b/web-ui/src/components/cases/learning-status-badges.tsx new file mode 100644 index 0000000..58a7bf6 --- /dev/null +++ b/web-ui/src/components/cases/learning-status-badges.tsx @@ -0,0 +1,170 @@ +"use client"; + +/** + * Post-final pipeline indicator — shows, on the case, whether the two automatic + * steps (voice learning + halacha extraction) ran, succeeded, why not, and how many + * halachot were extracted. Rendered in the drafts panel's "החלטה סופית של היו״ר" + * section, below the run buttons. Read-only; derives from db.case_learning_status. + */ + +import { Brain, Scale } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { + useCaseLearningStatus, + type VoiceLearningStatus, + type HalachaExtractionStatus, +} from "@/lib/api/learning"; + +type Tone = "ok" | "danger" | "warn" | "info" | "muted"; + +const TONE_CLASS: Record = { + ok: "bg-success-bg text-success border-success/40", + danger: "bg-danger-bg text-danger border-danger/40", + warn: "bg-warn-bg text-warn border-warn/40", + info: "bg-info-bg text-info border-info/40", + muted: "bg-rule-soft text-ink-muted border-rule", +}; + +function voiceView(v: VoiceLearningStatus): { + tone: Tone; + label: string; + sub: string; +} { + if (v.outcome === "succeeded") { + const bits = [`${v.lessons_count} לקחים הופקו`]; + if (v.style_corpus_enrolled) bits.push("נרשם לקורפוס-הסגנון"); + if (v.lessons_proposed > 0) bits.push(`${v.lessons_proposed} הוצעו לאישור`); + return { tone: "ok", label: "הצליח", sub: bits.join(" · ") }; + } + if (v.outcome === "failed") { + return { + tone: "danger", + label: "נכשל", + sub: v.error ? `הריצה נכשלה: ${v.error}` : "הריצה נכשלה", + }; + } + return { + tone: "muted", + label: "טרם בוצע", + sub: 'הרץ "למידת-קול" כדי להפיק לקחים מההשוואה טיוטה↔סופי', + }; +} + +const HALACHA_LABEL: Record = { + completed: "הושלם", + processing: "רץ עכשיו", + pending: "בתור", + busy: "ממתין לתור", + partial: "חלקי", + failed: "נכשל", + extraction_failed: "נכשל", + no_chunks: "אין טקסט לחילוץ", +}; + +function halachaView(h: HalachaExtractionStatus): { + tone: Tone; + label: string; + sub: string; +} { + if (!h.enrolled_in_corpus) { + return { + tone: "danger", + label: "לא נכנס לקורפוס", + sub: + h.not_enrolled_reason ?? + "ההחלטה הסופית טרם נכנסה לקורפוס-הפסיקה הפנימי — אין ממה לחלץ הלכות", + }; + } + const counts = `חולצו ${h.halachot_count} הלכות · ${h.approved} אושרו · ${h.pending} ממתינות · ${h.rejected} נדחו`; + switch (h.status) { + case "completed": + return { tone: "ok", label: "הושלם", sub: counts }; + case "processing": + case "pending": + case "busy": + return { + tone: "info", + label: HALACHA_LABEL[h.status], + sub: 'ממתין לעיבוד — הרץ "אימות-הלכות" אם טרם הופעל', + }; + case "partial": + return { + tone: "warn", + label: "חלקי", + sub: `חלק מהקטעים נכשלו — חולצו ${h.halachot_count} הלכות`, + }; + case "no_chunks": + return { + tone: "warn", + label: "אין טקסט לחילוץ", + sub: "לא נמצא טקסט מתאים בהחלטה לחילוץ הלכות", + }; + case "failed": + case "extraction_failed": + return { + tone: "danger", + label: "נכשל", + sub: "חילוץ ההלכות נכשל — ראה דף התפעול", + }; + default: + return { tone: "muted", label: "טרם בוצע", sub: counts }; + } +} + +function Row({ + icon, + name, + tone, + label, + sub, +}: { + icon: React.ReactNode; + name: string; + tone: Tone; + label: string; + sub: string; +}) { + return ( +
+ + {icon} + +
+
+ {name} + {label} +
+

+ {sub} +

+
+
+ ); +} + +export function LearningStatusBadges({ caseNumber }: { caseNumber: string }) { + const { data } = useCaseLearningStatus(caseNumber); + if (!data) return null; + + const v = voiceView(data.voice_learning); + const h = halachaView(data.halacha_extraction); + + return ( +
+ } + name="למידת-קול" + tone={v.tone} + label={v.label} + sub={v.sub} + /> + } + name="חילוץ-הלכות" + tone={h.tone} + label={h.label} + sub={h.sub} + /> +
+ ); +} diff --git a/web-ui/src/lib/api/learning.ts b/web-ui/src/lib/api/learning.ts index 345dd8d..931def5 100644 --- a/web-ui/src/lib/api/learning.ts +++ b/web-ui/src/lib/api/learning.ts @@ -43,6 +43,7 @@ export const learningKeys = { all: ["learning"] as const, pairs: (status: string) => [...learningKeys.all, "pairs", status] as const, distance: (caseNumber: string) => [...learningKeys.all, "distance", caseNumber] as const, + caseStatus: (caseNumber: string) => [...learningKeys.all, "case-status", caseNumber] as const, }; export function useReconciliationLedger(status = "") { @@ -123,3 +124,69 @@ export function usePromoteLearning(pairId: string) { }, }); } + +// ── Post-final pipeline status (case indicator) ────────────────────── +// Derived status of the two post-final pipelines for one case: whether voice +// learning + halacha extraction ran, succeeded, why not, and how many halachot +// were extracted. Backs the indicator in the drafts panel's final-decision section. + +export type VoiceLearningStatus = { + ran: boolean; + outcome: "succeeded" | "failed" | "not_run"; + error: string | null; + pair_status: string; // final_received | analyzed | lessons_folded | '' + lessons_count: number; + style_corpus_enrolled: boolean; + lessons_proposed: number; + analyzed_at: string | null; +}; + +export type HalachaExtractionStatus = { + enrolled_in_corpus: boolean; + not_enrolled_reason: string | null; + status: + | "pending" + | "processing" + | "completed" + | "failed" + | "partial" + | "extraction_failed" + | "no_chunks" + | "busy" + | null; + failed?: boolean; + halachot_count: number; + approved: number; + pending: number; + rejected: number; +}; + +export type CaseLearningStatus = { + final_uploaded: boolean; + voice_learning: VoiceLearningStatus; + halacha_extraction: HalachaExtractionStatus; +}; + +/** Whether the indicator should keep polling (a pipeline is mid-flight). */ +function isLearningInFlight(s: CaseLearningStatus | undefined): boolean { + const h = s?.halacha_extraction.status; + return h === "processing" || h === "pending" || h === "busy"; +} + +export function useCaseLearningStatus(caseNumber: string, enabled = true) { + return useQuery({ + queryKey: learningKeys.caseStatus(caseNumber), + queryFn: ({ signal }) => + apiRequest( + `/api/cases/${caseNumber}/learning-status`, + { signal }, + ), + enabled, + staleTime: 15_000, + // Background pipelines: refetch gently while something is still running. + refetchInterval: (query) => + isLearningInFlight(query.state.data as CaseLearningStatus | undefined) + ? 15_000 + : false, + }); +} diff --git a/web/app.py b/web/app.py index 2caa50e..4d8153f 100644 --- a/web/app.py +++ b/web/app.py @@ -3764,6 +3764,19 @@ async def api_final_run_halacha(case_number: str): return await _wake_final_task(case_number, "halacha") +@app.get("/api/cases/{case_number}/learning-status") +async def api_case_learning_status(case_number: str): + """Derived status of the two post-final pipelines (voice learning + halacha + extraction) for the case: whether each ran, succeeded, why not, and how many + halachot were extracted. Focused/cheap endpoint for the UI to poll + invalidate + after the run-learning/run-halacha buttons. Same derivation as case_get's + learning_status (single source — db.case_learning_status).""" + case = await db.get_case_by_number(case_number) + if not case: + raise HTTPException(404, f"תיק {case_number} לא נמצא") + return await db.case_learning_status(case) + + @app.post("/api/cases/{case_number}/export-docx") async def api_export_docx(case_number: str, background_tasks: BackgroundTasks): """Trigger DOCX export for a case.