From 6bf19bd0d791d8e6f1956f3492c4ab2e7bc94c16 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 6 Jun 2026 16:21:41 +0000 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=D7=90=D7=99=D7=A0=D7=93=D7=99?= =?UTF-8?q?=D7=A7=D7=98=D7=95=D7=A8=20=D7=94=D7=AA=D7=A7=D7=93=D7=9E=D7=95?= =?UTF-8?q?=D7=AA=20=D7=9C=D7=97=D7=99=D7=9C=D7=95=D7=A5=20=D7=9E=D7=98?= =?UTF-8?q?=D7=90-=D7=93=D7=90=D7=98=D7=94=20+=20=D7=9E=D7=AA=D7=92-=D7=9E?= =?UTF-8?q?=D7=A7=D7=98=D7=A2=D7=99=D7=9D=20=D7=91=D7=A1=D7=A4=D7=A8=D7=99?= =?UTF-8?q?=D7=99=D7=AA=20=D7=94=D7=A4=D7=A1=D7=99=D7=A7=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit שתי בעיות UX בדף /precedents: 1. חילוץ מטא-דאטה לא נתן שום אינדיקציה שהוא רץ. בניגוד לחילוץ טקסט/הלכות (extraction_status / halacha_extraction_status) למטא-דאטה היתה רק חותמת-זמן metadata_extraction_requested_at — אין מצב "processing", לכן StatusPill לא הציג כלום. נוספה עמודת metadata_extraction_status ('pending'|'processing'| 'completed'|'failed') במתכונת העמודות הקיימות, וה-worker (process_pending_extractions + reextract_metadata) מעדכן אותה: processing בתחילת פריט, completed בסיום (מנקה גם את החותמת), pending בכשל (לריטריי). ה-UI מציג תג "מחלץ מטא-דאטה" + באנר מונה-אצווה עם אחוז התקדמות (high-water-mark של עומק-התור) שמתעדכן אוטומטית דרך ה-polling הקיים (5ש'). 2. שתי טבלאות מוערמות (בתי משפט / ועדות ערר) חייבו גלילה ארוכה. הוחלפו במתג- מקטעים — טבלה אחת בכל פעם, עם שמירה על העמודות הייעודיות לכל סוג. Invariants: G2 (מרחיב מנגנון-סטטוס קיים, לא מסלול מקביל), INV-TOOL4/GAP-45 (המשך חשיפת תור-החילוץ הסמוי). אין נגיעה בתוכן משפטי (G11). Co-Authored-By: Claude Opus 4.8 (1M context) --- mcp-server/src/legal_mcp/services/db.py | 33 +++- .../legal_mcp/services/precedent_library.py | 40 ++++- .../precedents/library-list-panel.tsx | 160 ++++++++++++++++-- web-ui/src/lib/api/precedent-library.ts | 11 +- 4 files changed, 215 insertions(+), 29 deletions(-) diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index f8b18cd..9bb966d 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -610,6 +610,11 @@ ALTER TABLE case_law ADD COLUMN IF NOT EXISTS document_id UUID REFERENCES docume ALTER TABLE case_law ADD COLUMN IF NOT EXISTS extraction_status TEXT DEFAULT 'pending'; -- 'pending' | 'processing' | 'completed' | 'failed' ALTER TABLE case_law ADD COLUMN IF NOT EXISTS halacha_extraction_status TEXT DEFAULT 'pending'; +ALTER TABLE case_law ADD COLUMN IF NOT EXISTS metadata_extraction_status TEXT DEFAULT 'pending'; + -- 'pending' | 'processing' | 'completed' | 'failed'. Mirrors the + -- text/halacha status columns so the UI can show a live badge while the + -- local-MCP worker drains the metadata queue (previously only the + -- metadata_extraction_requested_at timestamp existed — no 'processing'). ALTER TABLE case_law ADD COLUMN IF NOT EXISTS practice_area TEXT DEFAULT ''; ALTER TABLE case_law ADD COLUMN IF NOT EXISTS appeal_subtype TEXT DEFAULT ''; ALTER TABLE case_law ADD COLUMN IF NOT EXISTS headnote TEXT DEFAULT ''; @@ -3070,6 +3075,27 @@ async def set_case_law_halacha_status(case_law_id: UUID, status: str) -> None: ) +async def set_case_law_metadata_status(case_law_id: UUID, status: str) -> None: + """Set metadata-extraction status. Mirrors ``set_case_law_halacha_status``: + on terminal states ('completed'/'failed') we also clear + ``metadata_extraction_requested_at`` so the local-MCP queue + (`process_pending_extractions`, which scans ``WHERE *_requested_at IS NOT + NULL``) stops re-picking the row and the UI's ``isPrecedentActive`` check + settles.""" + pool = await get_pool() + if status in ("completed", "failed"): + await pool.execute( + "UPDATE case_law SET metadata_extraction_status = $2, " + "metadata_extraction_requested_at = NULL WHERE id = $1", + case_law_id, status, + ) + else: + await pool.execute( + "UPDATE case_law SET metadata_extraction_status = $2 WHERE id = $1", + case_law_id, status, + ) + + async def list_external_case_law( practice_area: str = "", court: str = "", @@ -3126,6 +3152,7 @@ async def list_external_case_law( summary, headnote, subject_tags, source_kind, chair_name, district, citation_formatted, extraction_status, halacha_extraction_status, + metadata_extraction_status, metadata_extraction_requested_at, halacha_extraction_requested_at, created_at, @@ -4274,8 +4301,12 @@ async def request_metadata_extraction(case_law_id: UUID) -> bool: fills empty fields), so this is safe. """ pool = await get_pool() + # Reset the status to 'pending' alongside the timestamp so a re-request + # after a prior 'completed'/'failed' run shows "בתור" again in the UI + # instead of a stale terminal badge. result = await pool.execute( - "UPDATE case_law SET metadata_extraction_requested_at = now() " + "UPDATE case_law SET metadata_extraction_requested_at = now(), " + "metadata_extraction_status = 'pending' " "WHERE id = $1", case_law_id, ) diff --git a/mcp-server/src/legal_mcp/services/precedent_library.py b/mcp-server/src/legal_mcp/services/precedent_library.py index d15e100..053bbe1 100644 --- a/mcp-server/src/legal_mcp/services/precedent_library.py +++ b/mcp-server/src/legal_mcp/services/precedent_library.py @@ -235,6 +235,12 @@ async def process_pending_extractions(kind: str = "metadata", limit: int = 20) - attempts = 0 result: dict = {} try: + # Flip to 'processing' so the UI badge shows live progress while + # this row is being worked (metadata has no per-chunk status of + # its own — this is the only signal). Halacha already sets its own + # 'processing' inside the extractor. + if kind == "metadata": + await db.set_case_law_metadata_status(cid, "processing") result = await _run_once(cid) # Retry only on systematic extraction failure (rate-limit storm). # Don't retry on 'no_halachot' — that means Claude looked and @@ -259,9 +265,15 @@ async def process_pending_extractions(kind: str = "metadata", limit: int = 20) - # Finalise: success or terminal failure both clear the request # so the queue moves on. (Use 'failed' DB state for terminal # extraction_failed so the UI shows the warning chip.) - if kind == "halacha" and result.get("status") == "extraction_failed": - await db.set_case_law_halacha_status(cid, "failed") - await db.clear_extraction_request(cid, kind=kind) + if kind == "halacha": + if result.get("status") == "extraction_failed": + await db.set_case_law_halacha_status(cid, "failed") + await db.clear_extraction_request(cid, kind=kind) + else: + # metadata — set terminal 'completed' status (also clears the + # request timestamp) so the UI badge settles instead of + # lingering on 'processing'. + await db.set_case_law_metadata_status(cid, "completed") processed += 1 results.append({ "case_law_id": str(cid), @@ -273,6 +285,15 @@ async def process_pending_extractions(kind: str = "metadata", limit: int = 20) - }) except Exception as e: logger.exception("process_pending_extractions failed for %s: %s", cid, e) + # Don't clear the request — it stays for the next run. But for + # metadata, revert the badge from 'processing' back to 'pending' + # (the timestamp is preserved) so the row shows "בתור" rather than + # a stuck "מחלץ" until the retry picks it up. + if kind == "metadata": + try: + await db.set_case_law_metadata_status(cid, "pending") + except Exception: + logger.exception("failed to revert metadata status for %s", cid) results.append({ "case_law_id": str(cid), "case_number": row.get("case_number", ""), @@ -280,7 +301,6 @@ async def process_pending_extractions(kind: str = "metadata", limit: int = 20) - "error": str(e), "retry_attempts": attempts, }) - # Don't clear the request — it stays for the next run. return { "status": "completed", @@ -314,12 +334,18 @@ async def reextract_metadata( raise ValueError("precedent not found") # See note in db.request_metadata_extraction — opened to all source kinds. + # Mark 'processing' so a concurrent UI poll shows the live badge. + await db.set_case_law_metadata_status(case_law_id, "processing") await progress("extracting_metadata", 40, "מחלץ מטא-דאטה (תקציר, תגיות)") result = await precedent_metadata_extractor.extract_and_apply(case_law_id) - # Clear the queue timestamp so the UI / worker stop showing this row. - # See note in reextract_halachot. + # Settle to terminal 'completed' (also NULLs the queue timestamp) so the + # UI / worker stop showing this row. See note in reextract_halachot. if result.get("status") in ("completed", "no_changes"): - await db.clear_extraction_request(case_law_id, kind="metadata") + await db.set_case_law_metadata_status(case_law_id, "completed") + else: + # e.g. 'no_metadata' (no full_text) — don't leave the badge stuck on + # 'processing'; revert to 'pending' (preserves any queue timestamp). + await db.set_case_law_metadata_status(case_law_id, "pending") fields = result.get("fields") or [] msg = ( f"מולאו {len(fields)} שדות: {', '.join(fields)}" diff --git a/web-ui/src/components/precedents/library-list-panel.tsx b/web-ui/src/components/precedents/library-list-panel.tsx index fe3f630..32f4bce 100644 --- a/web-ui/src/components/precedents/library-list-panel.tsx +++ b/web-ui/src/components/precedents/library-list-panel.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import Link from "next/link"; -import { Trash2, Plus, Pencil, Wand2 } from "lucide-react"; +import { Trash2, Plus, Pencil, Wand2, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, @@ -10,7 +10,9 @@ import { import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; +import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; @@ -87,6 +89,28 @@ function StatusPill({ p }: { p: Precedent }) { ); } + // Metadata extraction (orthogonal one-off re-extract). Surface it while + // active so the chair sees the queued button actually ran on something. + if (p.metadata_extraction_status === "processing") { + return ; + } + if (p.metadata_extraction_status === "failed") { + return ( + + מטא-דאטה נכשל + + ); + } + if ( + p.metadata_extraction_requested_at && + p.metadata_extraction_status !== "completed" + ) { + return ( + + ממתין למטא-דאטה + + ); + } if (p.halacha_extraction_status === "processing") { return ; } @@ -325,6 +349,92 @@ function CommitteeRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => voi ); } +/** + * Live banner for the metadata-extraction queue. The local-MCP worker + * (`precedent_process_pending kind=metadata`) drains stamped rows serially; + * while it runs, rows carry metadata_extraction_status='processing' (current + * item) or a request timestamp (still queued). We surface a real, advancing + * percentage by tracking the session high-water-mark of the queue depth: + * percent = (peak - remaining) / peak. Hidden when the queue is empty. + */ +function MetadataQueueBanner({ items }: { items: Precedent[] }) { + const processing = items.filter( + (p) => p.metadata_extraction_status === "processing", + ).length; + const pending = items.filter( + (p) => + p.metadata_extraction_status !== "processing" && + p.metadata_extraction_requested_at !== null && + p.metadata_extraction_status !== "completed" && + p.metadata_extraction_status !== "failed", + ).length; + const remaining = processing + pending; + + // Session high-water-mark of the queue depth, using React's documented + // "adjust state during render" pattern (no effect, no ref-in-render). React + // re-renders immediately without committing the intermediate UI. Resets to 0 + // when the queue drains, so the next batch starts a fresh 0→100% sweep. + const [peak, setPeak] = useState(0); + if (remaining === 0) { + if (peak !== 0) setPeak(0); + } else if (remaining > peak) { + setPeak(remaining); + } + + if (remaining === 0) return null; + const done = Math.max(0, peak - remaining); + const percent = peak > 0 ? Math.round((done / peak) * 100) : 0; + + return ( +
+
+ + + חילוץ מטא-דאטה פעיל — מעבד {processing}, נותרו {pending} בתור + {peak > 0 ? ` · ${done}/${peak}` : ""} + +
+ +
+ ); +} + +function SegButton({ + active, onClick, label, count, +}: { + active: boolean; + onClick: () => void; + label: string; + count?: number; +}) { + return ( + + ); +} + function TableSkeleton({ cols }: { cols: number }) { return ( <> @@ -344,6 +454,9 @@ export function LibraryListPanel() { const [search, setSearch] = useState(""); const [uploadOpen, setUploadOpen] = useState(false); const [editingId, setEditingId] = useState(null); + // Which of the two corpora to show. Both queries stay mounted (counts + + // cross-table polling), but only the active table renders — no long scroll. + const [view, setView] = useState<"court" | "committee">("court"); const sharedFilters = { practiceArea: practiceArea || undefined, @@ -354,6 +467,12 @@ export function LibraryListPanel() { const courts = usePrecedents({ ...sharedFilters, sourceKind: "external_upload", sourceType: "court_ruling" }); const committee = usePrecedents({ ...sharedFilters, sourceKind: "all_committees" }); + // Metadata-queue banner draws on both corpora (a stamped row can be either). + const allItems = [ + ...(courts.data?.items ?? []), + ...(committee.data?.items ?? []), + ]; + return (
{/* Shared filters */} @@ -385,16 +504,28 @@ export function LibraryListPanel() {
+ {/* Metadata-extraction queue progress (hidden when idle) */} + + + {/* Segmented control — show one corpus at a time */} +
+ setView("court")} + label="פסיקת בתי משפט" + count={courts.data?.count} + /> + setView("committee")} + label="החלטות ועדות ערר" + count={committee.data?.count} + /> +
+ {/* Table 1 — Court rulings */} + {view === "court" && (
-
-

פסיקת בתי משפט

- {courts.data && ( - - {courts.data.count} - - )} -
{courts.error ? (
{courts.error.message} @@ -433,17 +564,11 @@ export function LibraryListPanel() {
)}
+ )} {/* Table 2 — Appeals committee decisions */} + {view === "committee" && (
-
-

החלטות ועדות ערר

- {committee.data && ( - - {committee.data.count} - - )} -
{committee.error ? (
{committee.error.message} @@ -483,6 +608,7 @@ export function LibraryListPanel() {
)}
+ )}