diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 238490b..2000c6e 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -5284,6 +5284,7 @@ async def list_halachot( cluster: bool = False, include_equivalents: bool = False, include_panel_round: bool = False, + search: str | None = None, ) -> list[dict]: """List halachot with optional triage controls (#84). @@ -5291,6 +5292,10 @@ async def list_halachot( truncated_quote / quote_unverified / non_decision / thin_restatement / nli_unsupported / near_duplicate). These belong in a 'needs extraction fix' bucket, not the chair's approve queue (#84.1). + search — free-text locate within the queue (case_number / case_name / + rule_statement, case-insensitive). Filters server-side so a pending + halacha ranked below the display window is still reachable; without it + the queue only ever shows the top ``limit`` by priority. order_by_priority — replace FIFO with an active-learning order (#84.3, #133/FU-3): panel-disagreement first (the panel SPLIT, then ran INCOMPLETE — the labels of highest learning value: the chair's call resolves a genuine @@ -5320,6 +5325,14 @@ async def list_halachot( if exclude_low_quality: # a clean item has an empty/NULL quality_flags array conditions.append("COALESCE(array_length(h.quality_flags, 1), 0) = 0") + if search and search.strip(): + # locate by decision identity OR rule text — server-side so an item + # ranked below the display window is still reachable (no client-only filter). + conditions.append( + f"(cl.case_number ILIKE ${idx} OR cl.case_name ILIKE ${idx} " + f"OR h.rule_statement ILIKE ${idx})") + params.append(f"%{search.strip()}%") + idx += 1 where_sql = f"WHERE {' AND '.join(conditions)}" if conditions else "" # #133/FU-3: rank the panel's latest verdict so splits/incompletes — the # highest-value active-learning labels — float to the top of the queue. @@ -5387,6 +5400,48 @@ async def list_halachot( return out +async def count_halachot( + review_status: str | None = None, + practice_area: str | None = None, + exclude_low_quality: bool = False, + search: str | None = None, +) -> int: + """Full count for the same filter ``list_halachot`` uses (sans limit/offset). + + Powers the queue's "showing N of TOTAL" note so the chair knows how many + pending items sit beyond the display window. Mirrors the WHERE exactly — + one source of truth, no parallel counting logic. + """ + pool = await get_pool() + conditions = [] + params: list = [] + idx = 1 + if review_status: + conditions.append(f"h.review_status = ${idx}") + params.append(review_status) + idx += 1 + if practice_area: + conditions.append(f"${idx} = ANY(h.practice_areas)") + params.append(practice_area) + idx += 1 + if exclude_low_quality: + conditions.append("COALESCE(array_length(h.quality_flags, 1), 0) = 0") + if search and search.strip(): + conditions.append( + f"(cl.case_number ILIKE ${idx} OR cl.case_name ILIKE ${idx} " + f"OR h.rule_statement ILIKE ${idx})") + params.append(f"%{search.strip()}%") + idx += 1 + where_sql = f"WHERE {' AND '.join(conditions)}" if conditions else "" + sql = f""" + SELECT count(*) + FROM halachot h + LEFT JOIN case_law cl ON cl.id = h.case_law_id + {where_sql} + """ + return await pool.fetchval(sql, *params) or 0 + + async def _annotate_panel_rounds(pool, out: list[dict]) -> None: """Attach the LATEST 3-judge panel round to each row (#133/FU-2), display-only. diff --git a/web-ui/src/components/precedents/halacha-review-panel.tsx b/web-ui/src/components/precedents/halacha-review-panel.tsx index bf6cbb5..27d9ecd 100644 --- a/web-ui/src/components/precedents/halacha-review-panel.tsx +++ b/web-ui/src/components/precedents/halacha-review-panel.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState, type ReactNode } from "react"; -import { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle, Clock, RotateCcw, Info } from "lucide-react"; +import { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle, Clock, RotateCcw, Info, Search } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -669,13 +669,25 @@ function PendingPanel() { // "judgment" = items the panel deliberated (or clean) → chair approves/rejects; // "fix" = flagged-but-never-adjudicated → extraction repair. (No empty "תור נקי".) const [view, setView] = useState<"judgment" | "fix">("judgment"); - const { data, isPending, error } = useHalachotPending({ limit: 500 }); + // Locate a pending halacha by decision/text — server-side, so an item ranked + // below the 500-item display window is still reachable (the chair's core ask). + const [searchInput, setSearchInput] = useState(""); + const [search, setSearch] = useState(""); + useEffect(() => { + const t = setTimeout(() => setSearch(searchInput.trim()), 300); + return () => clearTimeout(t); + }, [searchInput]); + const searching = search.length > 0; + + const { data, isPending, error } = useHalachotPending({ limit: 500, search }); const update = useUpdateHalacha(); const batch = useBatchReviewHalachot(); const [expandedIds, setExpandedIds] = useState>(new Set()); const [focusedId, setFocusedId] = useState(null); const allItems = useMemo(() => data?.items ?? [], [data]); + // Full pending count for this filter (incl. items beyond the display window). + const pendingTotal = data?.total ?? allItems.length; const judgmentCount = useMemo( () => allItems.filter((h) => !isExtractionFixItem(h)).length, [allItems]); const fixCount = useMemo( @@ -692,10 +704,11 @@ function PendingPanel() { const visibleItems = useMemo(() => { const out: ReviewItem[] = []; for (const g of groups) { - if (expandedIds.has(g.caseLawId)) out.push(...g.items); + // a search narrows to a handful of cases — show them all open + if (searching || expandedIds.has(g.caseLawId)) out.push(...g.items); } return out; - }, [groups, expandedIds]); + }, [groups, expandedIds, searching]); useEffect(() => { if (focusedId === null) return; @@ -846,7 +859,16 @@ function PendingPanel() { ); } else if (!groups.length) { - body = ( + body = searching ? ( +
+

לא נמצאו הלכות ממתינות התואמות ל״{search}״.

+

+ נסה מספר-פס״ד, שם-תיק או מילה מנוסח-ההלכה — או נקה את החיפוש. + {view === "judgment" && fixCount > 0 && " ייתכן שההתאמות נמצאות ב״דורש תיקון-חילוץ״."} + {view === "fix" && judgmentCount > 0 && " ייתכן שההתאמות נמצאות ב״להכרעתך״."} +

+
+ ) : (

{view === "fix" @@ -888,7 +910,7 @@ function PendingPanel() {

{groups.map((g) => { - const isOpen = expandedIds.has(g.caseLawId); + const isOpen = searching || expandedIds.has(g.caseLawId); return (
+ )} +
+ + {overWindow && ( +

+ + חלון התצוגה + + + התור מציג את {allItems.length} ההלכות בעלות-העדיפות מתוך{" "} + {pendingTotal} הממתינות. הלכה מחוץ לחלון — אתר אותה בחיפוש. + +

+ )} + + {searching && ( +
+ מציג ממתינות עבור + + {search} + + + · {pendingTotal} {pendingTotal === 1 ? "התאמה" : "התאמות"} + + +
+ )} + {viewToggle} {body}
diff --git a/web-ui/src/lib/api/precedent-library.ts b/web-ui/src/lib/api/precedent-library.ts index ab5a8bd..afbcd74 100644 --- a/web-ui/src/lib/api/precedent-library.ts +++ b/web-ui/src/lib/api/precedent-library.ts @@ -211,7 +211,7 @@ export const libraryKeys = { search: (q: string, filters: Record) => [...libraryKeys.all, "search", q, filters] as const, stats: () => [...libraryKeys.all, "stats"] as const, - halachotPending: () => [...libraryKeys.all, "halachot", "pending"] as const, + halachotPending: (search = "") => [...libraryKeys.all, "halachot", "pending", search] as const, halachot: (filters: Record) => [...libraryKeys.all, "halachot", filters] as const, }; @@ -615,15 +615,18 @@ export function useRequestHalachotExtraction() { * review panel splits this client-side by ACTION — "להכרעה" (has a panel round) * vs "תיקון-חילוץ" (flagged, never adjudicated) — instead of the old empty * clean/needsfix toggle. */ -export function useHalachotPending(opts: { limit?: number } = {}) { - const { limit = 200 } = opts; +export function useHalachotPending(opts: { limit?: number; search?: string } = {}) { + const { limit = 200, search = "" } = opts; + const term = search.trim(); const qs = `review_status=pending_review&exclude_low_quality=false` + `&order_by_priority=true&cluster=true&include_equivalents=true` - + `&include_panel_round=true&limit=${limit}`; + + `&include_panel_round=true&with_total=true&limit=${limit}` + + (term ? `&search=${encodeURIComponent(term)}` : ""); return useQuery({ - queryKey: libraryKeys.halachotPending(), + queryKey: libraryKeys.halachotPending(term), queryFn: ({ signal }) => - apiRequest<{ items: Halacha[]; count: number }>(`/api/halachot?${qs}`, { signal }), + apiRequest<{ items: Halacha[]; count: number; total?: number }>( + `/api/halachot?${qs}`, { signal }), staleTime: 5_000, refetchOnMount: "always", }); diff --git a/web/app.py b/web/app.py index f800331..bd6336f 100644 --- a/web/app.py +++ b/web/app.py @@ -7292,14 +7292,19 @@ async def halachot_list( cluster: bool = False, include_equivalents: bool = False, include_panel_round: bool = False, + search: str = "", + with_total: bool = False, ): """List halachot. ``exclude_low_quality`` hides flagged items (#84.1), ``order_by_priority`` switches to the active-learning order (#84.3), ``cluster`` annotates near-duplicate groups for one-card review (#84.2), ``include_equivalents`` attaches cross-precedent parallel-authority links, and ``include_panel_round`` attaches the latest 3-judge panel deliberation so the - chair sees why the panel split (#133/FU-2). All default off so existing callers - are unaffected; the review queue opts in.""" + chair sees why the panel split (#133/FU-2). ``search`` locates a pending + halacha by case_number / case_name / rule text server-side (so an item below + the display window stays reachable); ``with_total`` adds the full filter count + so the UI can show "N of TOTAL". All default off so existing callers are + unaffected; the review queue opts in.""" cid: UUID | None = None if case_law_id: try: @@ -7316,8 +7321,17 @@ async def halachot_list( cluster=cluster, include_equivalents=include_equivalents, include_panel_round=include_panel_round, + search=search or None, ) - return {"items": rows, "count": len(rows)} + resp: dict = {"items": rows, "count": len(rows)} + if with_total: + resp["total"] = await db.count_halachot( + review_status=review_status or None, + practice_area=practice_area or None, + exclude_low_quality=exclude_low_quality, + search=search or None, + ) + return resp class EquivalentLinkRequest(BaseModel):