Merge pull request 'feat(halacha-triage UI): wire gating + near-duplicate cluster cards (#84.2)' (#98) from worktree-task84.2-ui-clustering into main
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled

This commit was merged in pull request #98.
This commit is contained in:
2026-06-06 21:02:09 +00:00
5 changed files with 255 additions and 64 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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<string, string> = {
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<EditState>) => Promise<void>;
}) {
const variants = h.variants ?? [];
const [showVariants, setShowVariants] = useState(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<EditState>({
rule_statement: h.rule_statement,
@@ -111,6 +116,12 @@ function HalachaCard({
<Badge variant="outline" className="text-[0.65rem]">
{ruleTypeLabel(h.rule_type)}
</Badge>
{variants.length > 0 && (
<Badge variant="outline"
className="text-[0.65rem] bg-navy-soft/30 text-navy border-navy/30">
+{variants.length} וריאנטים
</Badge>
)}
<CorroborationBadge halacha={h} />
</span>
</div>
@@ -180,6 +191,35 @@ function HalachaCard({
))}
</div>
{variants.length > 0 && (
<div className="rounded-md border border-navy/20 bg-navy-soft/10">
<button
type="button"
onClick={() => setShowVariants((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 text-[0.72rem] text-navy hover:bg-navy-soft/20 transition-colors"
aria-expanded={showVariants}
>
{showVariants ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
<span className="font-medium">
{variants.length} ניסוחים כמעט-זהים של אותו עיקרון
</span>
<span className="me-auto text-ink-muted">החלטה תחול על כולם</span>
</button>
{showVariants && (
<ul className="px-4 pb-3 pt-1 space-y-2">
{variants.map((v) => (
<li key={v.id} className="text-[0.78rem] text-ink-soft leading-relaxed border-r-2 border-navy/20 pr-3" dir="rtl">
{v.rule_statement}
<span className="text-[0.65rem] text-ink-muted tabular-nums ms-2">
(ביטחון {v.confidence.toFixed(2)})
</span>
</li>
))}
</ul>
)}
</div>
)}
<div className="flex items-center gap-2 justify-end pt-1 border-t border-rule-soft">
{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<string, Group>();
const byCase = new Map<string, Halacha[]>();
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<string, Halacha[]>();
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({
</div>
</div>
<Badge variant="outline" className="tabular-nums">
{g.items.length}
{g.pendingTotal}
</Badge>
</button>
@@ -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<Set<string>>(new Set());
@@ -463,8 +535,8 @@ function PendingPanel() {
const totalCount = data?.items.length ?? 0;
const visibleItems = useMemo<Halacha[]>(() => {
const out: Halacha[] = [];
const visibleItems = useMemo<ReviewItem[]>(() => {
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<EditState>,
) => {
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 = (
<div className="flex items-center gap-1 rounded-lg border border-rule p-0.5 bg-rule-soft/30 w-fit">
<Button
size="sm"
variant={view === "clean" ? "default" : "ghost"}
className={view === "clean" ? "bg-gold text-navy hover:bg-gold-deep" : ""}
onClick={() => setView("clean")}
>
תור נקי
</Button>
<Button
size="sm"
variant={view === "needsfix" ? "default" : "ghost"}
className={view === "needsfix" ? "bg-gold text-navy hover:bg-gold-deep" : ""}
onClick={() => setView("needsfix")}
>
דורש תיקון-חילוץ
</Button>
</div>
);
let body: ReactNode;
if (error) {
return (
body = (
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
{error.message}
</div>
);
}
if (isPending) {
return (
} else if (isPending) {
body = (
<div className="space-y-3">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
);
}
if (!groups.length) {
return (
} else if (!groups.length) {
body = (
<div className="text-center text-ink-muted py-16">
<p className="text-lg">אין הלכות הממתינות לאישור.</p>
<p className="text-sm mt-2">העלה פסיקה חדשה ההלכות שיחולצו ממנה יופיעו כאן.</p>
<p className="text-lg">
{view === "needsfix"
? "בקט התיקון ריק — אין הלכות מסומנות-איכות."
: "אין הלכות נקיות הממתינות לאישור."}
</p>
<p className="text-sm mt-2">
{view === "needsfix"
? "פריטים שסומנו (ציטוט לא-מאומת, יישום, כפילות-קרובה וכו') יופיעו כאן."
: "העלה פסיקה חדשה — ההלכות שיחולצו ממנה יופיעו כאן."}
</p>
</div>
);
}
return (
} else {
body = (
<div className="space-y-4">
<div className="flex items-center gap-3 text-sm text-ink-muted flex-wrap">
<span>
<span className="text-navy font-semibold">{totalCount}</span> ממתינות
ב-<span className="text-navy font-semibold">{groups.length}</span> פסיקות
<span className="text-navy font-semibold">{totalCount}</span>
{view === "needsfix" ? " מסומנות" : " ממתינות"}
{" "}ב-<span className="text-navy font-semibold">{groups.length}</span> פסיקות
</span>
<span className="me-auto text-[0.72rem]">
ניווט: <kbd className="bg-rule-soft px-1.5 rounded">J</kbd>/<kbd className="bg-rule-soft px-1.5 rounded">K</kbd>
@@ -650,7 +758,8 @@ function PendingPanel() {
</div>
</div>
<Badge variant="outline" className="bg-gold-wash text-gold-deep border-gold/40 tabular-nums">
{g.items.length} ממתינות
{g.pendingTotal} ממתינות
{g.pendingTotal !== g.items.length && ` · ${g.items.length} כרטיסים`}
</Badge>
</button>
@@ -658,12 +767,12 @@ function PendingPanel() {
<div className="p-4 space-y-3 bg-rule-soft/20">
<div className="flex items-center gap-2 justify-end pb-1">
<span className="me-auto text-[0.72rem] text-ink-muted">
פעולה קבוצתית על {g.items.length} ההלכות:
פעולה קבוצתית על {g.pendingTotal} ההלכות:
</span>
<Button
size="sm" variant="ghost" disabled={batch.isPending}
onClick={() => {
if (window.confirm(`לדחות את כל ${g.items.length} ההלכות בפסק זה?`))
if (window.confirm(`לדחות את כל ${g.pendingTotal} ההלכות בפסק זה?`))
reviewGroup(g, "rejected");
}}
className="text-danger hover:text-danger hover:bg-danger-bg"
@@ -673,7 +782,7 @@ function PendingPanel() {
<Button
size="sm" disabled={batch.isPending}
onClick={() => {
if (window.confirm(`לאשר את כל ${g.items.length} ההלכות בפסק זה?`))
if (window.confirm(`לאשר את כל ${g.pendingTotal} ההלכות בפסק זה?`))
reviewGroup(g, "approved");
}}
className="bg-gold text-navy hover:bg-gold-deep"
@@ -708,6 +817,14 @@ function PendingPanel() {
})}
</div>
</div>
);
}
return (
<div className="space-y-4">
{viewToggle}
{body}
</div>
);
}
@@ -730,7 +847,7 @@ const TAB_LABELS: Record<Tab, string> = {
export function HalachaReviewPanel() {
const [tab, setTab] = useState<Tab>("pending");
const { data: pendingData } = useHalachotPending(500);
const { data: pendingData } = useHalachotPending({ limit: 500 });
const rejectedCount = useHalachaCount("rejected");
const approvedCount = useHalachaCount("approved");

View File

@@ -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",
});

View File

@@ -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)}