From eeb70a5758b9af8edbd911ce28900479f57e6b62 Mon Sep 17 00:00:00 2001 From: Chaim Date: Wed, 3 Jun 2026 13:42:21 +0000 Subject: [PATCH] =?UTF-8?q?feat(halacha):=20review-queue=20triage=20?= =?UTF-8?q?=E2=80=94=20defer=20+=20batch=20group=20actions=20+=20quality-f?= =?UTF-8?q?lag=20badges=20(#84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the chair's pending-halacha review faster and less exhausting. Backend: - New 'deferred' review_status (snooze): stays out of the active library AND out of the default pending queue, without the finality of 'rejected'. update_halacha stamps reviewer+reviewed_at on defer; HALACHA_REVIEW_STATUSES is the single source of valid statuses (PATCH validation now uses it). - db.update_halachot_batch(ids, status, reviewer) — one atomic UPDATE for a whole group; invalid status / empty ids are a no-op. - POST /api/halachot/batch (HalachaBatchReviewRequest) wraps it. - update_halacha now RETURNs quality_flags too (parity with list_halachot). Frontend (halacha-review-panel): - Quality-flag badges (#81: non_decision / truncated_quote / thin_restatement / quote_unverified) so the chair sees WHY an item was held back. - Defer action — button + keyboard 'D' — to snooze without rejecting (fixes the 'leave in pending forever' anti-pattern; reject stays the junk verb). - Per-precedent batch bar: 'אשר הכל' / 'דחה הכל' via useBatchReviewHalachot (one request, one refetch) with confirm guards. - Halacha/HalachaPatch types gain quality_flags + 'deferred'. Verified: mcp-server suite 156 passed; web build green; end-to-end integration against dev DB (batch approve/reject, defer sets status+timestamp, pending excludes approved+deferred, deferred queryable, invalid status no-op). Note: api:types regen deferred until deploy (the batch hook is hand-typed, not dependent on generated types). Co-Authored-By: Claude Opus 4.8 (1M context) --- mcp-server/src/legal_mcp/services/db.py | 45 +++++++++- .../precedents/halacha-review-panel.tsx | 87 +++++++++++++++++-- web-ui/src/lib/api/precedent-library.ts | 27 +++++- web/app.py | 28 +++++- 4 files changed, 173 insertions(+), 14 deletions(-) diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 3fd9889..2269425 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -659,7 +659,7 @@ CREATE TABLE IF NOT EXISTS halachot ( confidence NUMERIC(3,2) DEFAULT 0.0, quote_verified BOOLEAN DEFAULT FALSE, review_status TEXT DEFAULT 'pending_review', - -- pending_review | approved | rejected | published + -- pending_review | approved | rejected | published | deferred (#84 snooze) reviewer TEXT DEFAULT '', reviewed_at TIMESTAMPTZ, quality_flags TEXT[] DEFAULT '{}', @@ -3520,7 +3520,7 @@ async def update_halacha( set_parts.append(f"review_status = ${idx}") params.append(review_status) idx += 1 - if review_status in ("approved", "rejected", "published"): + if review_status in ("approved", "rejected", "published", "deferred"): set_parts.append(f"reviewed_at = now()") set_parts.append(f"reviewer = ${idx}") params.append(reviewer) @@ -3552,13 +3552,50 @@ async def update_halacha( RETURNING id, case_law_id, halacha_index, rule_statement, rule_type, reasoning_summary, supporting_quote, page_reference, practice_areas, subject_tags, cites, confidence, - quote_verified, review_status, reviewer, reviewed_at, - created_at, updated_at + quote_verified, quality_flags, review_status, reviewer, + reviewed_at, created_at, updated_at """ row = await pool.fetchrow(sql, *params) return dict(row) if row else None +# Statuses the chair can set via review (batch or single). 'deferred' = snooze: +# stays out of the active library AND out of the default pending queue, without +# the finality of 'rejected'. #84 review-queue triage. +HALACHA_REVIEW_STATUSES = { + "pending_review", "approved", "rejected", "published", "deferred", +} + + +async def update_halachot_batch( + halacha_ids: list[str], review_status: str, reviewer: str = "", +) -> int: + """Bulk-set review_status for many halachot in one atomic statement. + + Powers the #84 "approve/reject/defer the whole group" action — one request, + one transaction, one refetch (vs N PATCH round-trips). Only the status + + reviewer + reviewed_at are touched (no content edits in batch). Returns the + number of rows updated. + """ + if not halacha_ids or review_status not in HALACHA_REVIEW_STATUSES: + return 0 + ids = [UUID(str(i)) for i in halacha_ids] + stamp = review_status in ("approved", "rejected", "published", "deferred") + pool = await get_pool() + result = await pool.execute( + f"""UPDATE halachot + SET review_status = $2, + updated_at = now() + {", reviewed_at = now(), reviewer = $3" if stamp else ""} + WHERE id = ANY($1::uuid[])""", + ids, review_status, *( [reviewer] if stamp else [] ), + ) + try: + return int(result.split()[-1]) + except (ValueError, IndexError): + return 0 + + async def approve_halacha_by_corroboration( halacha_id: UUID, n_sources: int, min_cites: int, ) -> bool: diff --git a/web-ui/src/components/precedents/halacha-review-panel.tsx b/web-ui/src/components/precedents/halacha-review-panel.tsx index a8de818..8ecaa9e 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 } from "react"; -import { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle } from "lucide-react"; +import { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle, Clock } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -10,9 +10,17 @@ import { Textarea } from "@/components/ui/textarea"; import { CorroborationBadge } from "./corroboration-badge"; import { practiceAreaLabel } from "./practice-area"; import { - useHalachotPending, useUpdateHalacha, type Halacha, + useHalachotPending, useUpdateHalacha, useBatchReviewHalachot, type Halacha, } from "@/lib/api/precedent-library"; +/** #81 strict-rubric flags — why an item was held back from auto-approval. */ +const QUALITY_FLAG_LABELS: Record = { + non_decision: "אי-הכרעה", + truncated_quote: "ציטוט קטוע", + thin_restatement: "ניסוח דק", + quote_unverified: "ציטוט לא מאומת", +}; + /** * Halacha review queue — the chair-only path between automatic * extraction and agent visibility. Per the project's review policy, @@ -59,12 +67,13 @@ function ruleTypeLabel(t: string): string { type EditState = { rule_statement: string; reasoning_summary: string }; function HalachaCard({ - h, focused, onApprove, onReject, onSave, + h, focused, onApprove, onReject, onDefer, onSave, }: { h: Halacha; focused: boolean; onApprove: () => void; onReject: () => void; + onDefer: () => void; onSave: (patch: Partial) => Promise; }) { const [editing, setEditing] = useState(false); @@ -120,6 +129,19 @@ function HalachaCard({ + {/* #81 quality flags — explain why this item needs a human eye */} + {h.quality_flags && h.quality_flags.length > 0 && ( +
+ {h.quality_flags.map((f) => ( + + + {QUALITY_FLAG_LABELS[f] ?? f} + + ))} +
+ )} + {/* Side-by-side rule vs quote */}
@@ -194,6 +216,11 @@ function HalachaCard({ ערוך (E) + + +
{g.items.map((h) => ( { if (window.confirm("לדחות הלכה זו?")) review(h, "rejected"); }} + onDefer={() => review(h, "deferred")} onSave={async (patch) => { try { await update.mutateAsync({ id: h.id, patch }); diff --git a/web-ui/src/lib/api/precedent-library.ts b/web-ui/src/lib/api/precedent-library.ts index 8b2d940..179fcb9 100644 --- a/web-ui/src/lib/api/precedent-library.ts +++ b/web-ui/src/lib/api/precedent-library.ts @@ -72,7 +72,10 @@ export type Halacha = { cites: string[]; confidence: number; quote_verified: boolean; - review_status: "pending_review" | "approved" | "rejected" | "published"; + /* #81 strict-rubric quality flags — non_decision | truncated_quote | + * thin_restatement | quote_unverified. Any flag blocked auto-approval. */ + quality_flags?: string[]; + review_status: "pending_review" | "approved" | "rejected" | "published" | "deferred"; reviewer: string; reviewed_at: string | null; created_at: string; @@ -574,7 +577,7 @@ export function useHalachotPending(limit = 200) { } export type HalachaPatch = Partial<{ - review_status: "pending_review" | "approved" | "rejected" | "published"; + review_status: "pending_review" | "approved" | "rejected" | "published" | "deferred"; reviewer: string; rule_statement: string; reasoning_summary: string; @@ -595,3 +598,23 @@ export function useUpdateHalacha() { }, }); } + +export type BatchReviewStatus = + | "approved" | "rejected" | "deferred" | "pending_review" | "published"; + +/** #84 — apply one review status to many halachot in a single request. */ +export function useBatchReviewHalachot() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ ids, status, reviewer }: { + ids: string[]; status: BatchReviewStatus; reviewer?: string; + }) => + apiRequest<{ updated: number }>( + `/api/halachot/batch`, + { method: "POST", body: { halacha_ids: ids, review_status: status, reviewer } }, + ), + onSuccess: () => { + qc.invalidateQueries({ queryKey: libraryKeys.all }); + }, + }); +} diff --git a/web/app.py b/web/app.py index df9c159..e917ee8 100644 --- a/web/app.py +++ b/web/app.py @@ -5177,6 +5177,13 @@ class HalachaUpdateRequest(BaseModel): practice_areas: list[str] | None = None +class HalachaBatchReviewRequest(BaseModel): + """#84 — apply one review status to many halachot at once (group action).""" + halacha_ids: list[str] + review_status: str + reviewer: str | None = "דפנה" + + @app.post("/api/precedent-library/upload") async def precedent_library_upload( file: UploadFile = File(...), @@ -5712,9 +5719,7 @@ async def halacha_update(halacha_id: str, req: HalachaUpdateRequest): hid = UUID(halacha_id) except ValueError: raise HTTPException(400, "halacha_id לא תקין") - if req.review_status and req.review_status not in { - "pending_review", "approved", "rejected", "published", - }: + if req.review_status and req.review_status not in db.HALACHA_REVIEW_STATUSES: raise HTTPException(400, "review_status לא תקין") row = await db.update_halacha( halacha_id=hid, @@ -5730,6 +5735,23 @@ async def halacha_update(halacha_id: str, req: HalachaUpdateRequest): return row +@app.post("/api/halachot/batch") +async def halacha_batch_review(req: HalachaBatchReviewRequest): + """Apply one review status to many halachot at once (#84 group action).""" + if req.review_status not in db.HALACHA_REVIEW_STATUSES: + raise HTTPException(400, "review_status לא תקין") + if not req.halacha_ids: + return {"updated": 0} + try: + ids = [str(UUID(i)) for i in req.halacha_ids] + except ValueError: + raise HTTPException(400, "halacha_id לא תקין ברשימה") + updated = await db.update_halachot_batch( + ids, review_status=req.review_status, reviewer=req.reviewer or "", + ) + return {"updated": updated} + + # ── Missing Precedents (TaskMaster #35) ──────────────────────────── # Track citations from party briefs that aren't yet in the precedent # corpus. Researcher logs gaps; chair closes them by uploading the