feat(learning): אינדיקציית-תיק למצב למידת-קול + חילוץ-הלכות אחרי החלטה סופית
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
אחרי העלאת החלטה סופית והרצת שני הפייפליינים האוטומטיים (למידת-קול,
חילוץ/אימות-הלכות), התיק לא הציג אם כל תהליך בוצע/הצליח/למה-נכשל. במיוחד
תקלת chair_name ריק (2026-06-12) שמפילה בשקט את העתק-ה-case_law → חילוץ-הלכות
לא מתחיל בכלל, בלי שזה גלוי. כעת מוצגות שתי אינדיקציות ליד כפתורי-ההרצה.
Backend (גזירה ממקור-יחיד, ללא מסלול-מעקב מקביל):
- SCHEMA_V36: draft_final_pairs.learning_run (JSONB) — שדה-תיעוד על פנקס-ההתאמה
(INV-LRN4), חותם את תוצאת-הריצה של פייפליין-הלמידה (succeeded/failed+סיבה+at).
- set_learning_run_outcome() — חיתום הצלחה/כישלון על ה-pair האחרון.
- case_learning_status() — גזירה read-only מ-draft_final_pairs/style_corpus/
decision_lessons/case_law/halachot: בוצע? הצליח? למה-לא? כמה הלכות חולצו.
- final_learning_pipeline.py — חותם outcome בהצלחה וב-except (surfaced, לא בלוע).
- חשיפה: case_get מוסיף learning_status (→MCP + /api/cases/{case}/details) +
endpoint ייעודי GET /api/cases/{case}/learning-status (אותה פונקציה — בלי כפילות).
UI (אושר דרך שער-העיצוב Claude Design — כרטיס 21-final-learning-status):
- useCaseLearningStatus (api/learning.ts) — hook + polling עדין בזמן in-flight.
- LearningStatusBadges — 2 שורות (למידת-קול / חילוץ-הלכות) עם badge + תת-שורה
(מס' לקחים · רישום-קורפוס / מס' הלכות + פירוק אושרו/ממתינות/נדחו / סיבת-כישלון).
- שילוב ב-drafts-panel תחת "החלטה סופית של היו״ר" + אינוולידציה בכפתורי-ההרצה.
אומת מול ה-DB החי: הצליח+5 הלכות (8174-12-24) · נכנס-אך-pending (1200-12-25) ·
לא-נכנס-לקורפוס (8125-09-24) · round-trip חיתום-כישלון. tsc/eslint נקיים.
Invariants: G1 (נרמול-במקור — גזירה, לא טלאי), G2 (אין מסלול מקביל — שדה על
הפנקס הקיים + exposer יחיד), INV-LRN4 (פנקס-ההתאמה), INV-IA1 (מקור-אמת יחיד).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<HTMLInputElement>(null);
|
||||
const finalFileRef = useRef<HTMLInputElement>(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({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasFinal && <LearningStatusBadges caseNumber={caseNumber} />}
|
||||
</section>
|
||||
|
||||
{/* ── Precedents cited inside the signed decision ── */}
|
||||
|
||||
170
web-ui/src/components/cases/learning-status-badges.tsx
Normal file
170
web-ui/src/components/cases/learning-status-badges.tsx
Normal file
@@ -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<Tone, string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-md border border-rule bg-gold-wash text-gold-deep">
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-navy text-sm font-semibold">{name}</span>
|
||||
<Badge className={`text-[0.65rem] ${TONE_CLASS[tone]}`}>{label}</Badge>
|
||||
</div>
|
||||
<p className="text-ink-muted mt-0.5 text-xs leading-relaxed tabular-nums">
|
||||
{sub}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="border-rule-soft mt-4 flex flex-col gap-3 border-t pt-3.5">
|
||||
<Row
|
||||
icon={<Brain className="h-4 w-4" />}
|
||||
name="למידת-קול"
|
||||
tone={v.tone}
|
||||
label={v.label}
|
||||
sub={v.sub}
|
||||
/>
|
||||
<Row
|
||||
icon={<Scale className="h-4 w-4" />}
|
||||
name="חילוץ-הלכות"
|
||||
tone={h.tone}
|
||||
label={h.label}
|
||||
sub={h.sub}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<CaseLearningStatus>(
|
||||
`/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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user