feat(learning): אינדיקציית-תיק למצב למידת-קול + חילוץ-הלכות אחרי החלטה סופית #233
@@ -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);
|
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 def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
||||||
async with pool.acquire() as conn:
|
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_V33_SQL)
|
||||||
await conn.execute(SCHEMA_V34_SQL)
|
await conn.execute(SCHEMA_V34_SQL)
|
||||||
await conn.execute(SCHEMA_V35_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:
|
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]:
|
async def list_draft_final_pairs(status: str | None = None, limit: int = 200) -> list[dict]:
|
||||||
"""Reconciliation ledger: all decisions paired with their final + status."""
|
"""Reconciliation ledger: all decisions paired with their final + status."""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
|
|||||||
@@ -295,6 +295,15 @@ async def case_get(case_number: str) -> str:
|
|||||||
|
|
||||||
docs = await db.list_documents(UUID(case["id"]))
|
docs = await db.list_documents(UUID(case["id"]))
|
||||||
case["documents"] = docs
|
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)
|
return ok(case)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
except Exception as e: # fatal step (e.g. ingest error) — clean non-zero exit
|
||||||
print(f"\n✗ pipeline-למידה נכשל: {e}")
|
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
|
return 1
|
||||||
print("\n✓ pipeline-למידה הושלם" + (" (dry-run)" if args.dry_run else ""))
|
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)
|
return int(results.get("panel_rc", 0) or 0)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -34,6 +35,8 @@ import {
|
|||||||
type FeedbackCategory,
|
type FeedbackCategory,
|
||||||
} from "@/lib/api/feedback";
|
} from "@/lib/api/feedback";
|
||||||
import { useCaseCitations } from "@/lib/api/citations";
|
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 type { CaseStatus } from "@/lib/api/cases";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -98,6 +101,7 @@ export function DraftsPanel({
|
|||||||
const uploadFinal = useUploadFinalDecision(caseNumber);
|
const uploadFinal = useUploadFinalDecision(caseNumber);
|
||||||
const runLearning = useRunFinalLearning(caseNumber);
|
const runLearning = useRunFinalLearning(caseNumber);
|
||||||
const runHalacha = useRunFinalHalacha(caseNumber);
|
const runHalacha = useRunFinalHalacha(caseNumber);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const finalFileRef = useRef<HTMLInputElement>(null);
|
const finalFileRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -186,20 +190,29 @@ export function DraftsPanel({
|
|||||||
|
|
||||||
function handleRunLearning() {
|
function handleRunLearning() {
|
||||||
runLearning.mutate(undefined, {
|
runLearning.mutate(undefined, {
|
||||||
onSuccess: (d) =>
|
onSuccess: (d) => {
|
||||||
d.status === "ok"
|
// Background run — refresh the indicator (the poll picks up later transitions).
|
||||||
? toast.success("למידת-הקול הופעלה — רצה ברקע (אופוס + פאנל דיפסיק/גמיני)")
|
qc.invalidateQueries({ queryKey: learningKeys.caseStatus(caseNumber) });
|
||||||
: toast.warning(`לא הופעלה למידה: ${d.reason ?? d.error ?? d.status}`),
|
if (d.status === "ok") {
|
||||||
|
toast.success("למידת-הקול הופעלה — רצה ברקע (אופוס + פאנל דיפסיק/גמיני)");
|
||||||
|
} else {
|
||||||
|
toast.warning(`לא הופעלה למידה: ${d.reason ?? d.error ?? d.status}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
onError: () => toast.error("שגיאה בהפעלת למידת-הקול"),
|
onError: () => toast.error("שגיאה בהפעלת למידת-הקול"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRunHalacha() {
|
function handleRunHalacha() {
|
||||||
runHalacha.mutate(undefined, {
|
runHalacha.mutate(undefined, {
|
||||||
onSuccess: (d) =>
|
onSuccess: (d) => {
|
||||||
d.status === "ok"
|
qc.invalidateQueries({ queryKey: learningKeys.caseStatus(caseNumber) });
|
||||||
? toast.success("אימות-ההלכות הופעל — רץ ברקע (פאנל אופוס/דיפסיק/גמיני)")
|
if (d.status === "ok") {
|
||||||
: toast.warning(`לא הופעל אימות: ${d.reason ?? d.error ?? d.status}`),
|
toast.success("אימות-ההלכות הופעל — רץ ברקע (פאנל אופוס/דיפסיק/גמיני)");
|
||||||
|
} else {
|
||||||
|
toast.warning(`לא הופעל אימות: ${d.reason ?? d.error ?? d.status}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
onError: () => toast.error("שגיאה בהפעלת אימות-ההלכות"),
|
onError: () => toast.error("שגיאה בהפעלת אימות-ההלכות"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -337,6 +350,7 @@ export function DraftsPanel({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{hasFinal && <LearningStatusBadges caseNumber={caseNumber} />}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Precedents cited inside the signed decision ── */}
|
{/* ── 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,
|
all: ["learning"] as const,
|
||||||
pairs: (status: string) => [...learningKeys.all, "pairs", status] as const,
|
pairs: (status: string) => [...learningKeys.all, "pairs", status] as const,
|
||||||
distance: (caseNumber: string) => [...learningKeys.all, "distance", caseNumber] 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 = "") {
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
13
web/app.py
13
web/app.py
@@ -3764,6 +3764,19 @@ async def api_final_run_halacha(case_number: str):
|
|||||||
return await _wake_final_task(case_number, "halacha")
|
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")
|
@app.post("/api/cases/{case_number}/export-docx")
|
||||||
async def api_export_docx(case_number: str, background_tasks: BackgroundTasks):
|
async def api_export_docx(case_number: str, background_tasks: BackgroundTasks):
|
||||||
"""Trigger DOCX export for a case.
|
"""Trigger DOCX export for a case.
|
||||||
|
|||||||
Reference in New Issue
Block a user