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