diff --git a/mcp-server/src/legal_mcp/config.py b/mcp-server/src/legal_mcp/config.py index 2ad66fa..7a96859 100644 --- a/mcp-server/src/legal_mcp/config.py +++ b/mcp-server/src/legal_mcp/config.py @@ -162,6 +162,13 @@ HALACHA_DEDUP_COSINE = float(os.environ.get("HALACHA_DEDUP_COSINE", "0.93")) # dropping a possibly-distinct principle unreviewed. 0.83 from the same cleanup. HALACHA_DEDUP_BAND_COSINE = float(os.environ.get("HALACHA_DEDUP_BAND_COSINE", "0.83")) +# Halacha review-queue clustering (#84.2) — when the review queue is requested +# with cluster=true, halachot of the SAME precedent whose rule-embeddings are +# within this cosine are grouped into ONE review card (canonical + variants), so +# the chair judges near-identical principles once instead of repeatedly. Display +# only — never merges/deletes. 0.90 = "same principle, reworded". +HALACHA_CLUSTER_COSINE = float(os.environ.get("HALACHA_CLUSTER_COSINE", "0.90")) + # Halacha NLI entailment validator (#81.3) — after extraction, a claude_session # judge checks each halacha's rule_statement is entailed by its supporting_quote. # Non-entailed (neutral/contradiction) → quality flag 'nli_unsupported' that diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 346edb9..664139f 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -3794,6 +3794,7 @@ async def list_halachot( offset: int = 0, exclude_low_quality: bool = False, order_by_priority: bool = False, + cluster: bool = False, ) -> list[dict]: """List halachot with optional triage controls (#84). @@ -3804,6 +3805,9 @@ async def list_halachot( order_by_priority — replace FIFO with an active-learning order (#84.3): negatively-treated first, then most-uncertain (lowest confidence), then oldest — so the chair sees the highest-value decisions first. + cluster — annotate each row with ``cluster_id`` + ``cluster_size`` (#84.2): + same-precedent halachot within HALACHA_CLUSTER_COSINE form one group so + the UI can collapse near-identical principles into a single review card. """ pool = await get_pool() conditions = [] @@ -3868,9 +3872,47 @@ async def list_halachot( if d.get("decision_date") is not None: d["decision_date"] = d["decision_date"].isoformat() out.append(d) + if cluster and out: + await _annotate_clusters(pool, out) return out +async def _annotate_clusters(pool, out: list[dict]) -> None: + """Add cluster_id + cluster_size to each row (#84.2), display-only. + + Same-precedent halachot within HALACHA_CLUSTER_COSINE are unioned into one + group. Singletons get their own id as cluster_id and size 1. Pairwise is + confined to the returned set (cheap; the queue is ~hundreds of rows).""" + ids = [d["id"] for d in out] + max_dist = 1.0 - config.HALACHA_CLUSTER_COSINE + pairs = await pool.fetch( + "SELECT a.id AS a, b.id AS b FROM halachot a JOIN halachot b " + "ON a.case_law_id = b.case_law_id AND a.id < b.id " + "AND a.embedding IS NOT NULL AND b.embedding IS NOT NULL " + "AND (a.embedding <=> b.embedding) <= $2 " + "WHERE a.id = ANY($1::uuid[]) AND b.id = ANY($1::uuid[])", + ids, max_dist, + ) + parent = {str(i): str(i) for i in ids} + + def find(x: str) -> str: + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + for p in pairs: + ra, rb = find(str(p["a"])), find(str(p["b"])) + if ra != rb: + parent[ra] = rb + from collections import Counter + sizes = Counter(find(str(i)) for i in ids) + for d in out: + root = find(str(d["id"])) + d["cluster_id"] = root + d["cluster_size"] = sizes[root] + + async def update_halacha( halacha_id: UUID, review_status: str | None = None, diff --git a/web-ui/src/components/precedents/halacha-review-panel.tsx b/web-ui/src/components/precedents/halacha-review-panel.tsx index fcd6b3e..51bef3a 100644 --- a/web-ui/src/components/precedents/halacha-review-panel.tsx +++ b/web-ui/src/components/precedents/halacha-review-panel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle, Clock, RotateCcw } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -20,6 +20,9 @@ const QUALITY_FLAG_LABELS: Record = { thin_restatement: "ניסוח דק", quote_unverified: "ציטוט לא מאומת", nli_unsupported: "כלל לא נגזר מהציטוט", + application: "יישום תלוי-עובדות", + near_duplicate: "כפילות-קרובה", + nevo_preamble_leak: "דליפת רציו נבו", }; function formatDate(iso: string | null | undefined) { @@ -57,13 +60,15 @@ type EditState = { rule_statement: string; reasoning_summary: string }; function HalachaCard({ h, focused, onApprove, onReject, onDefer, onSave, }: { - h: Halacha; + h: Halacha & { variants?: Halacha[] }; focused: boolean; onApprove: () => void; onReject: () => void; onDefer: () => void; onSave: (patch: Partial) => Promise; }) { + const variants = h.variants ?? []; + const [showVariants, setShowVariants] = useState(false); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState({ rule_statement: h.rule_statement, @@ -111,6 +116,12 @@ function HalachaCard({ {ruleTypeLabel(h.rule_type)} + {variants.length > 0 && ( + + +{variants.length} וריאנטים + + )} @@ -180,6 +191,35 @@ function HalachaCard({ ))} + {variants.length > 0 && ( +
+ + {showVariants && ( +
    + {variants.map((v) => ( +
  • + {v.rule_statement} + + (ביטחון {v.confidence.toFixed(2)}) + +
  • + ))} +
+ )} +
+ )} +
{editing ? ( <> @@ -292,37 +332,64 @@ function HalachaRestoreCard({ // ─── Shared group type ──────────────────────────────────────────────────────── +/** #84.2 — a review card: the canonical halacha plus its near-duplicate + * variants (empty for singletons). Acting on the card acts on the whole set. */ +type ReviewItem = Halacha & { variants: Halacha[] }; + type Group = { caseLawId: string; caseNumber: string; court: string; decisionDate: string | null; precedentLevel: string; - items: Halacha[]; + items: ReviewItem[]; // canonicals (clusters collapsed) + pendingTotal: number; // total halachot incl. variants (for the count badge) }; +/** All halacha ids represented by a review item (canonical + its variants). */ +function itemIds(it: ReviewItem): string[] { + return [it.id, ...it.variants.map((v) => v.id)]; +} + function buildGroups(items: Halacha[]): Group[] { - const map = new Map(); + const byCase = new Map(); for (const h of items) { - const k = h.case_law_id; - let g = map.get(k); - if (!g) { - g = { - caseLawId: k, - caseNumber: h.case_number ?? "", - court: h.court ?? "", - decisionDate: h.decision_date ?? null, - precedentLevel: h.precedent_level ?? "", - items: [], - }; - map.set(k, g); + const arr = byCase.get(h.case_law_id) ?? []; + arr.push(h); + byCase.set(h.case_law_id, arr); + } + const groups: Group[] = []; + for (const [caseLawId, members] of byCase) { + // collapse near-duplicate clusters (#84.2): one card per cluster_id. + const clusters = new Map(); + for (const h of members) { + const key = h.cluster_id ?? h.id; + const arr = clusters.get(key) ?? []; + arr.push(h); + clusters.set(key, arr); } - g.items.push(h); + const reviewItems: ReviewItem[] = []; + for (const mem of clusters.values()) { + // canonical = strongest phrasing (highest confidence, verified wins ties) + mem.sort((a, b) => + b.confidence - a.confidence + || Number(b.quote_verified) - Number(a.quote_verified)); + reviewItems.push({ ...mem[0], variants: mem.slice(1) }); + } + // active-learning order within the case: most-uncertain first + reviewItems.sort((a, b) => a.confidence - b.confidence); + const first = members[0]; + groups.push({ + caseLawId, + caseNumber: first.case_number ?? "", + court: first.court ?? "", + decisionDate: first.decision_date ?? null, + precedentLevel: first.precedent_level ?? "", + items: reviewItems, + pendingTotal: members.length, + }); } - for (const g of map.values()) { - g.items.sort((a, b) => a.confidence - b.confidence); - } - return Array.from(map.values()).sort((a, b) => b.items.length - a.items.length); + return groups.sort((a, b) => b.pendingTotal - a.pendingTotal); } // ─── Restore panel (used for "rejected" and "approved" tabs) ────────────────── @@ -422,7 +489,7 @@ function RestorePanel({
- {g.items.length} + {g.pendingTotal} @@ -450,7 +517,12 @@ function RestorePanel({ // ─── Pending queue panel (main review flow) ─────────────────────────────────── function PendingPanel() { - const { data, isPending, error } = useHalachotPending(500); + // #84.1 — "clean" = quality-gated + prioritized + clustered review queue; + // "needsfix" = the flagged 'needs extraction fix' bucket. + const [view, setView] = useState<"clean" | "needsfix">("clean"); + const { data, isPending, error } = useHalachotPending({ + limit: 500, needsFix: view === "needsfix", + }); const update = useUpdateHalacha(); const batch = useBatchReviewHalachot(); const [expandedIds, setExpandedIds] = useState>(new Set()); @@ -463,8 +535,8 @@ function PendingPanel() { const totalCount = data?.items.length ?? 0; - const visibleItems = useMemo(() => { - const out: Halacha[] = []; + const visibleItems = useMemo(() => { + const out: ReviewItem[] = []; for (const g of groups) { if (expandedIds.has(g.caseLawId)) out.push(...g.items); } @@ -483,7 +555,7 @@ function PendingPanel() { el?.scrollIntoView({ block: "nearest", behavior: "smooth" }); }, [focusedId]); - const focused = focusedId + const focused: ReviewItem | null = focusedId ? visibleItems.find((h) => h.id === focusedId) ?? null : null; @@ -503,16 +575,26 @@ function PendingPanel() { }; const review = async ( - h: Halacha, + it: ReviewItem, status: "approved" | "rejected" | "deferred", extra?: Partial, ) => { + const ids = itemIds(it); try { - await update.mutateAsync({ - id: h.id, - patch: { review_status: status, ...extra }, - }); - toast.success(REVIEW_TOAST[status] ?? "עודכן"); + // #84.2 — a cluster card applies the decision to all its variants at once + // (an edit, which is canonical-specific, stays single). + if (ids.length > 1 && !extra) { + await batch.mutateAsync({ ids, status }); + } else { + await update.mutateAsync({ + id: it.id, + patch: { review_status: status, ...extra }, + }); + } + toast.success( + (REVIEW_TOAST[status] ?? "עודכן") + + (ids.length > 1 ? ` (${ids.length} וריאנטים)` : ""), + ); } catch (e) { toast.error(e instanceof Error ? e.message : "שגיאה"); } @@ -521,7 +603,7 @@ function PendingPanel() { const reviewGroup = async (g: Group, status: "approved" | "rejected") => { try { const res = await batch.mutateAsync({ - ids: g.items.map((h) => h.id), status, + ids: g.items.flatMap(itemIds), status, }); toast.success( `${status === "approved" ? "אושרו" : "נדחו"} ${res.updated} הלכות`, @@ -573,37 +655,63 @@ function PendingPanel() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [focused, visibleItems]); + const viewToggle = ( +
+ + +
+ ); + + let body: ReactNode; if (error) { - return ( + body = (
{error.message}
); - } - - if (isPending) { - return ( + } else if (isPending) { + body = (
{[...Array(3)].map((_, i) => )}
); - } - - if (!groups.length) { - return ( + } else if (!groups.length) { + body = (
-

אין הלכות הממתינות לאישור.

-

העלה פסיקה חדשה — ההלכות שיחולצו ממנה יופיעו כאן.

+

+ {view === "needsfix" + ? "בקט התיקון ריק — אין הלכות מסומנות-איכות." + : "אין הלכות נקיות הממתינות לאישור."} +

+

+ {view === "needsfix" + ? "פריטים שסומנו (ציטוט לא-מאומת, יישום, כפילות-קרובה וכו') יופיעו כאן." + : "העלה פסיקה חדשה — ההלכות שיחולצו ממנה יופיעו כאן."} +

); - } - - return ( + } else { + body = (
- {totalCount} ממתינות - ב-{groups.length} פסיקות + {totalCount} + {view === "needsfix" ? " מסומנות" : " ממתינות"} + {" "}ב-{groups.length} פסיקות ניווט: J/K @@ -650,7 +758,8 @@ function PendingPanel() {
- {g.items.length} ממתינות + {g.pendingTotal} ממתינות + {g.pendingTotal !== g.items.length && ` · ${g.items.length} כרטיסים`} @@ -658,12 +767,12 @@ function PendingPanel() {
- פעולה קבוצתית על {g.items.length} ההלכות: + פעולה קבוצתית על {g.pendingTotal} ההלכות:
+ ); + } + + return ( +
+ {viewToggle} + {body} +
); } @@ -730,7 +847,7 @@ const TAB_LABELS: Record = { export function HalachaReviewPanel() { const [tab, setTab] = useState("pending"); - const { data: pendingData } = useHalachotPending(500); + const { data: pendingData } = useHalachotPending({ limit: 500 }); const rejectedCount = useHalachaCount("rejected"); const approvedCount = useHalachaCount("approved"); diff --git a/web-ui/src/lib/api/precedent-library.ts b/web-ui/src/lib/api/precedent-library.ts index 7b122e4..4956d28 100644 --- a/web-ui/src/lib/api/precedent-library.ts +++ b/web-ui/src/lib/api/precedent-library.ts @@ -92,6 +92,11 @@ export type Halacha = { * negatively (distinguished/criticized/overruled). */ corroboration_count?: number; corroboration_negative?: boolean; + /* #84.2 near-duplicate clustering (present only when fetched with cluster=true): + * same-precedent halachot within the cluster cosine share a cluster_id, so the + * UI collapses them into one review card. cluster_size === 1 → singleton. */ + cluster_id?: string; + cluster_size?: number; }; export type RelatedCase = { @@ -566,14 +571,32 @@ export function useRequestHalachotExtraction() { }); } -export function useHalachotPending(limit = 200) { +/** #84.1/#84.2/#84.3 — the chair review queue. + * + * Default ("clean") view: quality-gated (flagged items hidden), priority-ordered + * (most-uncertain/negatively-treated first), and near-duplicate-clustered into + * one card. Pass `needsFix: true` for the 'needs extraction fix' bucket — every + * pending item carrying a quality flag (filtered client-side). */ +export function useHalachotPending( + opts: { limit?: number; needsFix?: boolean } = {}, +) { + const { limit = 200, needsFix = false } = opts; + const qs = needsFix + ? `review_status=pending_review&exclude_low_quality=false&limit=${limit}` + : `review_status=pending_review&exclude_low_quality=true` + + `&order_by_priority=true&cluster=true&limit=${limit}`; return useQuery({ - queryKey: libraryKeys.halachotPending(), - queryFn: ({ signal }) => - apiRequest<{ items: Halacha[]; count: number }>( - `/api/halachot?review_status=pending_review&limit=${limit}`, + queryKey: [...libraryKeys.halachotPending(), needsFix ? "needsfix" : "clean"], + queryFn: async ({ signal }) => { + const res = await apiRequest<{ items: Halacha[]; count: number }>( + `/api/halachot?${qs}`, { signal }, - ), + ); + if (!needsFix) return res; + // needs-fix bucket = pending items that carry a quality flag + const items = res.items.filter((h) => (h.quality_flags?.length ?? 0) > 0); + return { items, count: items.length }; + }, staleTime: 5_000, refetchOnMount: "always", }); diff --git a/web/app.py b/web/app.py index 05a35ab..c31fa31 100644 --- a/web/app.py +++ b/web/app.py @@ -6033,11 +6033,12 @@ async def halachot_list( offset: int = 0, exclude_low_quality: bool = False, order_by_priority: bool = False, + cluster: bool = False, ): - """List halachot. ``exclude_low_quality`` hides flagged items (#84.1) and - ``order_by_priority`` switches to the active-learning order (#84.3). Both - default off so existing callers are unaffected; the review-queue view opts - in.""" + """List halachot. ``exclude_low_quality`` hides flagged items (#84.1), + ``order_by_priority`` switches to the active-learning order (#84.3), and + ``cluster`` annotates near-duplicate groups for one-card review (#84.2). All + default off so existing callers are unaffected; the review queue opts in.""" cid: UUID | None = None if case_law_id: try: @@ -6051,6 +6052,7 @@ async def halachot_list( limit=limit, offset=offset, exclude_low_quality=exclude_low_quality, order_by_priority=order_by_priority, + cluster=cluster, ) return {"items": rows, "count": len(rows)}