diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 26e66e8..7428bfb 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -1833,6 +1833,8 @@ async def list_external_case_law( appeal_subtype, source_type, precedent_level, is_binding, summary, headnote, subject_tags, source_kind, extraction_status, halacha_extraction_status, + metadata_extraction_requested_at, + halacha_extraction_requested_at, created_at, (SELECT COUNT(*) FROM halachot h WHERE h.case_law_id = case_law.id) AS halachot_count, (SELECT COUNT(*) FROM halachot h WHERE h.case_law_id = case_law.id @@ -1843,7 +1845,15 @@ async def list_external_case_law( LIMIT ${idx} OFFSET ${idx + 1} """ rows = await pool.fetch(sql, *params) - return [_row_to_case_law(r) for r in rows] + out = [] + for r in rows: + d = _row_to_case_law(r) + # Render timestamps as ISO strings so the JSON layer stays simple + for k in ("metadata_extraction_requested_at", "halacha_extraction_requested_at"): + if d.get(k) is not None: + d[k] = d[k].isoformat() + out.append(d) + return out async def delete_case_law(case_law_id: UUID) -> bool: diff --git a/mcp-server/src/legal_mcp/services/precedent_library.py b/mcp-server/src/legal_mcp/services/precedent_library.py index 1a1776b..a401884 100644 --- a/mcp-server/src/legal_mcp/services/precedent_library.py +++ b/mcp-server/src/legal_mcp/services/precedent_library.py @@ -190,19 +190,23 @@ async def ingest_precedent( # Pipeline split: the container does the non-LLM half (extract + # chunk + embed + store). LLM-driven extraction (metadata, halachot) - # runs separately via the MCP tools `precedent_extract_metadata` / - # `precedent_extract_halachot` from local Claude Code, where - # `claude` CLI is available. Mark statuses so the chair can see - # what's pending in the UI. + # runs separately via the MCP tool `precedent_process_pending` from + # local Claude Code, where `claude` CLI is available. + # + # We auto-queue both extractions so the chair doesn't need to click + # any button — the moment they (or me) run `precedent_process_pending` + # in chat, both kinds get processed. await db.set_case_law_extraction_status(case_law_id, "completed") await db.set_case_law_halacha_status(case_law_id, "pending") + await db.request_metadata_extraction(case_law_id) + await db.request_halacha_extraction(case_law_id) await progress( "completed", 100, f"הוכנס לספרייה: {stored_chunks} chunks. " - f"חילוץ הלכות ומטא-דאטה — להפעיל מ-Claude Code " - f"(precedent_extract_halachot / precedent_extract_metadata).", + f"חילוץ הלכות ומטא-דאטה ממתינים בתור — " + f"להפעיל מ-Claude Code: precedent_process_pending.", ) return { diff --git a/web-ui/src/app/globals.css b/web-ui/src/app/globals.css index e30afb9..fd6ef72 100644 --- a/web-ui/src/app/globals.css +++ b/web-ui/src/app/globals.css @@ -246,3 +246,24 @@ color: var(--color-navy); } } + +/* ── Status pill shimmer ────────────────────────────────────────── + * Indeterminate "in progress" indicator used by precedent-library + * StatusPill while extraction is running. A diagonal stripe slides + * left-to-right across the badge background. */ +@keyframes ezer-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.shimmer-active { + background-image: linear-gradient( + 90deg, + transparent 0%, + rgba(168, 124, 58, 0.18) 50%, + transparent 100% + ); + background-size: 200% 100%; + background-repeat: no-repeat; + animation: ezer-shimmer 1.6s linear infinite; +} diff --git a/web-ui/src/components/precedents/library-list-panel.tsx b/web-ui/src/components/precedents/library-list-panel.tsx index bf19cd7..4ae9866 100644 --- a/web-ui/src/components/precedents/library-list-panel.tsx +++ b/web-ui/src/components/precedents/library-list-panel.tsx @@ -16,6 +16,7 @@ import { import { usePrecedents, useDeletePrecedent, + isPrecedentActive, type Precedent, type PracticeArea, } from "@/lib/api/precedent-library"; @@ -41,16 +42,68 @@ function cleanCitation(s: string | null | undefined): string { return s.replace(/[‎‏‪-‮⁦-⁩]/g, "").trim(); } +/* Shimmering pill — used while extraction is actively running. + * Visually distinct from the static "queued" / "completed" pills. */ +function ActivePill({ label }: { label: string }) { + return ( + + {label} + + ); +} + +/** + * Five distinct states. The "queued" state is what the user actually + * sees most of the time (after upload, both extractions are auto-queued + * but the local MCP worker hasn't drained them yet); "מחלץ" / "מעבד" + * shimmers and only appears while the extractor is actively running. + */ function StatusPill({ p }: { p: Precedent }) { if (p.extraction_status === "failed") { - return נכשל; + return ( + + נכשל + + ); + } + if (p.extraction_status === "processing") { + return ; } if (p.extraction_status !== "completed") { - return בעיבוד; + return ( + + בתור + + ); } - if (p.halacha_extraction_status !== "completed") { - return מחלץ הלכות; + if (p.halacha_extraction_status === "processing") { + return ; } + if (p.halacha_extraction_status === "failed") { + return ( + + חילוץ נכשל + + ); + } + if (p.halacha_extraction_status === "pending") { + if (p.halacha_extraction_requested_at) { + return ( + + ממתין לחילוץ + + ); + } + return ( + + לא חולץ + + ); + } + // halacha_extraction_status === "completed" if (p.halachot_count === 0) { return ללא הלכות; } @@ -71,8 +124,15 @@ function PrecedentRow({ onEdit: (id: string) => void; }) { const del = useDeletePrecedent(); + const active = isPrecedentActive(p); const onDelete = async () => { + if (active) { + toast.error( + "מתבצע עיבוד — לא ניתן למחוק עכשיו. המתיני לסיום או רעני את הדף.", + ); + return; + } if (!window.confirm(`למחוק את ${p.case_number}? cascade ימחק את ה-chunks וההלכות.`)) return; try { await del.mutateAsync(p.id); @@ -136,9 +196,10 @@ function PrecedentRow({ diff --git a/web-ui/src/lib/api/precedent-library.ts b/web-ui/src/lib/api/precedent-library.ts index 437820f..9a46fc5 100644 --- a/web-ui/src/lib/api/precedent-library.ts +++ b/web-ui/src/lib/api/precedent-library.ts @@ -48,6 +48,8 @@ export type Precedent = { source_kind: string; extraction_status: string; halacha_extraction_status: string; + metadata_extraction_requested_at: string | null; + halacha_extraction_requested_at: string | null; created_at: string; halachot_count: number; approved_count: number; @@ -172,9 +174,29 @@ export function usePrecedents(filters: ListFilters = {}) { ); }, staleTime: 30_000, + /* Poll while any row is mid-processing or queued for the local MCP + * worker. Once everything settles to completed/failed the polling + * stops on its own — no fixed background timer. */ + refetchInterval: (query) => { + const data = query.state.data; + if (!data) return false; + const active = data.items.some((p) => isPrecedentActive(p)); + return active ? 5000 : false; + }, }); } +/** A precedent is "active" while text/halacha extraction is in flight or + * queued for the local MCP worker. Used by the auto-refresh poller and + * by the row UI to disable destructive actions. */ +export function isPrecedentActive(p: Precedent): boolean { + if (p.extraction_status === "processing") return true; + if (p.halacha_extraction_status === "processing") return true; + if (p.halacha_extraction_requested_at !== null) return true; + if (p.metadata_extraction_requested_at !== null) return true; + return false; +} + export function usePrecedent(id: string | null) { return useQuery({ queryKey: libraryKeys.detail(id ?? ""),