Merge pull request 'feat(halacha): review-queue triage — defer + batch + quality-flag badges (#84)' (#52) from feat/halacha-review-triage into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m41s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m41s
This commit was merged in pull request #52.
This commit is contained in:
@@ -659,7 +659,7 @@ CREATE TABLE IF NOT EXISTS halachot (
|
|||||||
confidence NUMERIC(3,2) DEFAULT 0.0,
|
confidence NUMERIC(3,2) DEFAULT 0.0,
|
||||||
quote_verified BOOLEAN DEFAULT FALSE,
|
quote_verified BOOLEAN DEFAULT FALSE,
|
||||||
review_status TEXT DEFAULT 'pending_review',
|
review_status TEXT DEFAULT 'pending_review',
|
||||||
-- pending_review | approved | rejected | published
|
-- pending_review | approved | rejected | published | deferred (#84 snooze)
|
||||||
reviewer TEXT DEFAULT '',
|
reviewer TEXT DEFAULT '',
|
||||||
reviewed_at TIMESTAMPTZ,
|
reviewed_at TIMESTAMPTZ,
|
||||||
quality_flags TEXT[] DEFAULT '{}',
|
quality_flags TEXT[] DEFAULT '{}',
|
||||||
@@ -3520,7 +3520,7 @@ async def update_halacha(
|
|||||||
set_parts.append(f"review_status = ${idx}")
|
set_parts.append(f"review_status = ${idx}")
|
||||||
params.append(review_status)
|
params.append(review_status)
|
||||||
idx += 1
|
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"reviewed_at = now()")
|
||||||
set_parts.append(f"reviewer = ${idx}")
|
set_parts.append(f"reviewer = ${idx}")
|
||||||
params.append(reviewer)
|
params.append(reviewer)
|
||||||
@@ -3552,13 +3552,50 @@ async def update_halacha(
|
|||||||
RETURNING id, case_law_id, halacha_index, rule_statement, rule_type,
|
RETURNING id, case_law_id, halacha_index, rule_statement, rule_type,
|
||||||
reasoning_summary, supporting_quote, page_reference,
|
reasoning_summary, supporting_quote, page_reference,
|
||||||
practice_areas, subject_tags, cites, confidence,
|
practice_areas, subject_tags, cites, confidence,
|
||||||
quote_verified, review_status, reviewer, reviewed_at,
|
quote_verified, quality_flags, review_status, reviewer,
|
||||||
created_at, updated_at
|
reviewed_at, created_at, updated_at
|
||||||
"""
|
"""
|
||||||
row = await pool.fetchrow(sql, *params)
|
row = await pool.fetchrow(sql, *params)
|
||||||
return dict(row) if row else None
|
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(
|
async def approve_halacha_by_corroboration(
|
||||||
halacha_id: UUID, n_sources: int, min_cites: int,
|
halacha_id: UUID, n_sources: int, min_cites: int,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
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 { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -10,9 +10,17 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { CorroborationBadge } from "./corroboration-badge";
|
import { CorroborationBadge } from "./corroboration-badge";
|
||||||
import { practiceAreaLabel } from "./practice-area";
|
import { practiceAreaLabel } from "./practice-area";
|
||||||
import {
|
import {
|
||||||
useHalachotPending, useUpdateHalacha, type Halacha,
|
useHalachotPending, useUpdateHalacha, useBatchReviewHalachot, type Halacha,
|
||||||
} from "@/lib/api/precedent-library";
|
} 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
|
* Halacha review queue — the chair-only path between automatic
|
||||||
* extraction and agent visibility. Per the project's review policy,
|
* 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 };
|
type EditState = { rule_statement: string; reasoning_summary: string };
|
||||||
|
|
||||||
function HalachaCard({
|
function HalachaCard({
|
||||||
h, focused, onApprove, onReject, onSave,
|
h, focused, onApprove, onReject, onDefer, onSave,
|
||||||
}: {
|
}: {
|
||||||
h: Halacha;
|
h: Halacha;
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
onApprove: () => void;
|
onApprove: () => void;
|
||||||
onReject: () => void;
|
onReject: () => void;
|
||||||
|
onDefer: () => void;
|
||||||
onSave: (patch: Partial<EditState>) => Promise<void>;
|
onSave: (patch: Partial<EditState>) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
@@ -120,6 +129,19 @@ function HalachaCard({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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 */}
|
{/* Side-by-side rule vs quote */}
|
||||||
<div className="grid md:grid-cols-2 gap-3">
|
<div className="grid md:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -194,6 +216,11 @@ function HalachaCard({
|
|||||||
<Edit2 className="w-3.5 h-3.5 me-1" />
|
<Edit2 className="w-3.5 h-3.5 me-1" />
|
||||||
ערוך (E)
|
ערוך (E)
|
||||||
</Button>
|
</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"
|
<Button size="sm" variant="ghost"
|
||||||
onClick={onReject}
|
onClick={onReject}
|
||||||
className="text-danger hover:text-danger hover:bg-danger-bg">
|
className="text-danger hover:text-danger hover:bg-danger-bg">
|
||||||
@@ -224,6 +251,7 @@ type Group = {
|
|||||||
export function HalachaReviewPanel() {
|
export function HalachaReviewPanel() {
|
||||||
const { data, isPending, error } = useHalachotPending(500);
|
const { data, isPending, error } = useHalachotPending(500);
|
||||||
const update = useUpdateHalacha();
|
const update = useUpdateHalacha();
|
||||||
|
const batch = useBatchReviewHalachot();
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -297,9 +325,13 @@ export function HalachaReviewPanel() {
|
|||||||
setFocusedId(visibleItems[next].id);
|
setFocusedId(visibleItems[next].id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const REVIEW_TOAST: Record<string, string> = {
|
||||||
|
approved: "אושר", rejected: "נדחה", deferred: "נדחה למועד",
|
||||||
|
};
|
||||||
|
|
||||||
const review = async (
|
const review = async (
|
||||||
h: Halacha,
|
h: Halacha,
|
||||||
status: "approved" | "rejected",
|
status: "approved" | "rejected" | "deferred",
|
||||||
extra?: Partial<EditState>,
|
extra?: Partial<EditState>,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
@@ -307,7 +339,21 @@ export function HalachaReviewPanel() {
|
|||||||
id: h.id,
|
id: h.id,
|
||||||
patch: { review_status: status, ...extra },
|
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) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "שגיאה");
|
toast.error(e instanceof Error ? e.message : "שגיאה");
|
||||||
}
|
}
|
||||||
@@ -348,6 +394,9 @@ export function HalachaReviewPanel() {
|
|||||||
} else if ((e.key === "r" || e.key === "R") && focused) {
|
} else if ((e.key === "r" || e.key === "R") && focused) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (window.confirm("לדחות הלכה זו?")) review(focused, "rejected");
|
if (window.confirm("לדחות הלכה זו?")) review(focused, "rejected");
|
||||||
|
} else if ((e.key === "d" || e.key === "D") && focused) {
|
||||||
|
e.preventDefault();
|
||||||
|
review(focused, "deferred");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
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">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">A</kbd>
|
||||||
{" "}· דחייה: <kbd className="bg-rule-soft px-1.5 rounded">R</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>
|
{" "}· עריכה: <kbd className="bg-rule-soft px-1.5 rounded">E</kbd>
|
||||||
</span>
|
</span>
|
||||||
{expandedIds.size > 0 && (
|
{expandedIds.size > 0 && (
|
||||||
@@ -446,6 +496,32 @@ export function HalachaReviewPanel() {
|
|||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="p-4 space-y-3 bg-rule-soft/20">
|
<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) => (
|
{g.items.map((h) => (
|
||||||
<HalachaCard
|
<HalachaCard
|
||||||
key={h.id}
|
key={h.id}
|
||||||
@@ -455,6 +531,7 @@ export function HalachaReviewPanel() {
|
|||||||
onReject={() => {
|
onReject={() => {
|
||||||
if (window.confirm("לדחות הלכה זו?")) review(h, "rejected");
|
if (window.confirm("לדחות הלכה זו?")) review(h, "rejected");
|
||||||
}}
|
}}
|
||||||
|
onDefer={() => review(h, "deferred")}
|
||||||
onSave={async (patch) => {
|
onSave={async (patch) => {
|
||||||
try {
|
try {
|
||||||
await update.mutateAsync({ id: h.id, patch });
|
await update.mutateAsync({ id: h.id, patch });
|
||||||
|
|||||||
@@ -72,7 +72,10 @@ export type Halacha = {
|
|||||||
cites: string[];
|
cites: string[];
|
||||||
confidence: number;
|
confidence: number;
|
||||||
quote_verified: boolean;
|
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;
|
reviewer: string;
|
||||||
reviewed_at: string | null;
|
reviewed_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -574,7 +577,7 @@ export function useHalachotPending(limit = 200) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type HalachaPatch = Partial<{
|
export type HalachaPatch = Partial<{
|
||||||
review_status: "pending_review" | "approved" | "rejected" | "published";
|
review_status: "pending_review" | "approved" | "rejected" | "published" | "deferred";
|
||||||
reviewer: string;
|
reviewer: string;
|
||||||
rule_statement: string;
|
rule_statement: string;
|
||||||
reasoning_summary: 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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
28
web/app.py
28
web/app.py
@@ -5177,6 +5177,13 @@ class HalachaUpdateRequest(BaseModel):
|
|||||||
practice_areas: list[str] | None = None
|
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")
|
@app.post("/api/precedent-library/upload")
|
||||||
async def precedent_library_upload(
|
async def precedent_library_upload(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -5712,9 +5719,7 @@ async def halacha_update(halacha_id: str, req: HalachaUpdateRequest):
|
|||||||
hid = UUID(halacha_id)
|
hid = UUID(halacha_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(400, "halacha_id לא תקין")
|
raise HTTPException(400, "halacha_id לא תקין")
|
||||||
if req.review_status and req.review_status not in {
|
if req.review_status and req.review_status not in db.HALACHA_REVIEW_STATUSES:
|
||||||
"pending_review", "approved", "rejected", "published",
|
|
||||||
}:
|
|
||||||
raise HTTPException(400, "review_status לא תקין")
|
raise HTTPException(400, "review_status לא תקין")
|
||||||
row = await db.update_halacha(
|
row = await db.update_halacha(
|
||||||
halacha_id=hid,
|
halacha_id=hid,
|
||||||
@@ -5730,6 +5735,23 @@ async def halacha_update(halacha_id: str, req: HalachaUpdateRequest):
|
|||||||
return row
|
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) ────────────────────────────
|
# ── Missing Precedents (TaskMaster #35) ────────────────────────────
|
||||||
# Track citations from party briefs that aren't yet in the precedent
|
# Track citations from party briefs that aren't yet in the precedent
|
||||||
# corpus. Researcher logs gaps; chair closes them by uploading the
|
# corpus. Researcher logs gaps; chair closes them by uploading the
|
||||||
|
|||||||
Reference in New Issue
Block a user