feat(halacha): review-queue triage — defer + batch group actions + quality-flag badges (#84)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string> = {
|
||||
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<EditState>) => Promise<void>;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -120,6 +129,19 @@ function HalachaCard({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* #81 quality flags — explain why this item needs a human eye */}
|
||||
{h.quality_flags && h.quality_flags.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{h.quality_flags.map((f) => (
|
||||
<Badge key={f} variant="outline"
|
||||
className="text-[0.65rem] bg-danger-bg text-danger border-danger/40">
|
||||
<AlertTriangle className="w-3 h-3 me-1" />
|
||||
{QUALITY_FLAG_LABELS[f] ?? f}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Side-by-side rule vs quote */}
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
@@ -194,6 +216,11 @@ function HalachaCard({
|
||||
<Edit2 className="w-3.5 h-3.5 me-1" />
|
||||
ערוך (E)
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onDefer}
|
||||
className="text-ink-muted hover:text-navy">
|
||||
<Clock className="w-3.5 h-3.5 me-1" />
|
||||
דחה למועד (D)
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost"
|
||||
onClick={onReject}
|
||||
className="text-danger hover:text-danger hover:bg-danger-bg">
|
||||
@@ -224,6 +251,7 @@ type Group = {
|
||||
export function HalachaReviewPanel() {
|
||||
const { data, isPending, error } = useHalachotPending(500);
|
||||
const update = useUpdateHalacha();
|
||||
const batch = useBatchReviewHalachot();
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||
|
||||
@@ -297,9 +325,13 @@ export function HalachaReviewPanel() {
|
||||
setFocusedId(visibleItems[next].id);
|
||||
};
|
||||
|
||||
const REVIEW_TOAST: Record<string, string> = {
|
||||
approved: "אושר", rejected: "נדחה", deferred: "נדחה למועד",
|
||||
};
|
||||
|
||||
const review = async (
|
||||
h: Halacha,
|
||||
status: "approved" | "rejected",
|
||||
status: "approved" | "rejected" | "deferred",
|
||||
extra?: Partial<EditState>,
|
||||
) => {
|
||||
try {
|
||||
@@ -307,7 +339,21 @@ export function HalachaReviewPanel() {
|
||||
id: h.id,
|
||||
patch: { review_status: status, ...extra },
|
||||
});
|
||||
toast.success(status === "approved" ? "אושר" : "נדחה");
|
||||
toast.success(REVIEW_TOAST[status] ?? "עודכן");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "שגיאה");
|
||||
}
|
||||
};
|
||||
|
||||
// #84 — one decision applied to a whole precedent group (one request).
|
||||
const reviewGroup = async (g: Group, status: "approved" | "rejected") => {
|
||||
try {
|
||||
const res = await batch.mutateAsync({
|
||||
ids: g.items.map((h) => h.id), status,
|
||||
});
|
||||
toast.success(
|
||||
`${status === "approved" ? "אושרו" : "נדחו"} ${res.updated} הלכות`,
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "שגיאה");
|
||||
}
|
||||
@@ -348,6 +394,9 @@ export function HalachaReviewPanel() {
|
||||
} else if ((e.key === "r" || e.key === "R") && focused) {
|
||||
e.preventDefault();
|
||||
if (window.confirm("לדחות הלכה זו?")) review(focused, "rejected");
|
||||
} else if ((e.key === "d" || e.key === "D") && focused) {
|
||||
e.preventDefault();
|
||||
review(focused, "deferred");
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
@@ -391,6 +440,7 @@ export function HalachaReviewPanel() {
|
||||
ניווט: <kbd className="bg-rule-soft px-1.5 rounded">J</kbd>/<kbd className="bg-rule-soft px-1.5 rounded">K</kbd>
|
||||
{" "}· אישור: <kbd className="bg-rule-soft px-1.5 rounded">A</kbd>
|
||||
{" "}· דחייה: <kbd className="bg-rule-soft px-1.5 rounded">R</kbd>
|
||||
{" "}· למועד: <kbd className="bg-rule-soft px-1.5 rounded">D</kbd>
|
||||
{" "}· עריכה: <kbd className="bg-rule-soft px-1.5 rounded">E</kbd>
|
||||
</span>
|
||||
{expandedIds.size > 0 && (
|
||||
@@ -446,6 +496,32 @@ export function HalachaReviewPanel() {
|
||||
|
||||
{isOpen && (
|
||||
<div className="p-4 space-y-3 bg-rule-soft/20">
|
||||
{/* #84 — group batch actions: one decision for the whole ruling */}
|
||||
<div className="flex items-center gap-2 justify-end pb-1">
|
||||
<span className="me-auto text-[0.72rem] text-ink-muted">
|
||||
פעולה קבוצתית על {g.items.length} ההלכות:
|
||||
</span>
|
||||
<Button
|
||||
size="sm" variant="ghost" disabled={batch.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`לדחות את כל ${g.items.length} ההלכות בפסק זה?`))
|
||||
reviewGroup(g, "rejected");
|
||||
}}
|
||||
className="text-danger hover:text-danger hover:bg-danger-bg"
|
||||
>
|
||||
<X className="w-3.5 h-3.5 me-1" /> דחה הכל
|
||||
</Button>
|
||||
<Button
|
||||
size="sm" disabled={batch.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`לאשר את כל ${g.items.length} ההלכות בפסק זה?`))
|
||||
reviewGroup(g, "approved");
|
||||
}}
|
||||
className="bg-gold text-navy hover:bg-gold-deep"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5 me-1" /> אשר הכל
|
||||
</Button>
|
||||
</div>
|
||||
{g.items.map((h) => (
|
||||
<HalachaCard
|
||||
key={h.id}
|
||||
@@ -455,6 +531,7 @@ export function HalachaReviewPanel() {
|
||||
onReject={() => {
|
||||
if (window.confirm("לדחות הלכה זו?")) review(h, "rejected");
|
||||
}}
|
||||
onDefer={() => review(h, "deferred")}
|
||||
onSave={async (patch) => {
|
||||
try {
|
||||
await update.mutateAsync({ id: h.id, patch });
|
||||
|
||||
Reference in New Issue
Block a user