feat(halacha-triage UI): wire gating + near-duplicate cluster cards (#84.2)
Completes #84 — surfaces the backend gating/prioritization (#84.1/#84.3, PR #93) in the chair's review UI and adds near-duplicate clustering (#84.2). Backend - db.list_halachot gains `cluster` (#84.2): annotates each row with cluster_id + cluster_size by unioning same-precedent halachot within HALACHA_CLUSTER_COSINE (0.90, new config). Display-only — never merges/deletes. Pairwise is confined to the returned set (cheap). - GET /api/halachot exposes the `cluster` query param (default off). Frontend (web-ui) - Halacha type gains optional cluster_id / cluster_size (hand-written module; no api:types regen needed — halachot aren't typed off the generated schema). - useHalachotPending(opts): the default "clean" queue now fetches exclude_low_quality + order_by_priority + cluster; needsFix:true returns the flagged 'needs extraction fix' bucket (filtered client-side). - HalachaReviewPanel: a "תור נקי / דורש תיקון-חילוץ" toggle (#84.1); near-dup clusters collapse into ONE card showing "+N וריאנטים" with an expandable list, and approve/reject/defer on a clustered card applies to all variants via the batch endpoint (#84.2 + #84.4). Counts show true halacha totals (pendingTotal). New flag labels added (application / near_duplicate / nevo_preamble_leak). Verified: - backend: list_halachot(cluster=True) on the live queue — algorithm correct (groups related same-precedent rules at 0.78; none at the production 0.90 because dedup #82 already removed near-dups — the desired state). - frontend: `tsc --noEmit` exits 0 (type-clean); no new lint errors (the one lint error is pre-existing in training/learning-panel.tsx from #94). Local Turbopack build can't run on the worktree node_modules symlink — CI builds in a clean checkout. Invariants: G1 (gate/cluster at source in SQL, not post-hoc); G2 (same list_halachot path); §6 (flagged items routed to a visible bucket, not dropped). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
10
web/app.py
10
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)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user