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
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:
@@ -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.
|
# 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_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
|
# Halacha NLI entailment validator (#81.3) — after extraction, a claude_session
|
||||||
# judge checks each halacha's rule_statement is entailed by its supporting_quote.
|
# judge checks each halacha's rule_statement is entailed by its supporting_quote.
|
||||||
# Non-entailed (neutral/contradiction) → quality flag 'nli_unsupported' that
|
# Non-entailed (neutral/contradiction) → quality flag 'nli_unsupported' that
|
||||||
|
|||||||
@@ -3794,6 +3794,7 @@ async def list_halachot(
|
|||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
exclude_low_quality: bool = False,
|
exclude_low_quality: bool = False,
|
||||||
order_by_priority: bool = False,
|
order_by_priority: bool = False,
|
||||||
|
cluster: bool = False,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""List halachot with optional triage controls (#84).
|
"""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):
|
order_by_priority — replace FIFO with an active-learning order (#84.3):
|
||||||
negatively-treated first, then most-uncertain (lowest confidence), then
|
negatively-treated first, then most-uncertain (lowest confidence), then
|
||||||
oldest — so the chair sees the highest-value decisions first.
|
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()
|
pool = await get_pool()
|
||||||
conditions = []
|
conditions = []
|
||||||
@@ -3868,9 +3872,47 @@ async def list_halachot(
|
|||||||
if d.get("decision_date") is not None:
|
if d.get("decision_date") is not None:
|
||||||
d["decision_date"] = d["decision_date"].isoformat()
|
d["decision_date"] = d["decision_date"].isoformat()
|
||||||
out.append(d)
|
out.append(d)
|
||||||
|
if cluster and out:
|
||||||
|
await _annotate_clusters(pool, out)
|
||||||
return 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(
|
async def update_halacha(
|
||||||
halacha_id: UUID,
|
halacha_id: UUID,
|
||||||
review_status: str | None = None,
|
review_status: str | None = None,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle, Clock, RotateCcw } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -20,6 +20,9 @@ const QUALITY_FLAG_LABELS: Record<string, string> = {
|
|||||||
thin_restatement: "ניסוח דק",
|
thin_restatement: "ניסוח דק",
|
||||||
quote_unverified: "ציטוט לא מאומת",
|
quote_unverified: "ציטוט לא מאומת",
|
||||||
nli_unsupported: "כלל לא נגזר מהציטוט",
|
nli_unsupported: "כלל לא נגזר מהציטוט",
|
||||||
|
application: "יישום תלוי-עובדות",
|
||||||
|
near_duplicate: "כפילות-קרובה",
|
||||||
|
nevo_preamble_leak: "דליפת רציו נבו",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDate(iso: string | null | undefined) {
|
function formatDate(iso: string | null | undefined) {
|
||||||
@@ -57,13 +60,15 @@ type EditState = { rule_statement: string; reasoning_summary: string };
|
|||||||
function HalachaCard({
|
function HalachaCard({
|
||||||
h, focused, onApprove, onReject, onDefer, onSave,
|
h, focused, onApprove, onReject, onDefer, onSave,
|
||||||
}: {
|
}: {
|
||||||
h: Halacha;
|
h: Halacha & { variants?: Halacha[] };
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
onApprove: () => void;
|
onApprove: () => void;
|
||||||
onReject: () => void;
|
onReject: () => void;
|
||||||
onDefer: () => void;
|
onDefer: () => void;
|
||||||
onSave: (patch: Partial<EditState>) => Promise<void>;
|
onSave: (patch: Partial<EditState>) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
|
const variants = h.variants ?? [];
|
||||||
|
const [showVariants, setShowVariants] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState<EditState>({
|
const [draft, setDraft] = useState<EditState>({
|
||||||
rule_statement: h.rule_statement,
|
rule_statement: h.rule_statement,
|
||||||
@@ -111,6 +116,12 @@ function HalachaCard({
|
|||||||
<Badge variant="outline" className="text-[0.65rem]">
|
<Badge variant="outline" className="text-[0.65rem]">
|
||||||
{ruleTypeLabel(h.rule_type)}
|
{ruleTypeLabel(h.rule_type)}
|
||||||
</Badge>
|
</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} />
|
<CorroborationBadge halacha={h} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,6 +191,35 @@ function HalachaCard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2 justify-end pt-1 border-t border-rule-soft">
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<>
|
<>
|
||||||
@@ -292,37 +332,64 @@ function HalachaRestoreCard({
|
|||||||
|
|
||||||
// ─── Shared group type ────────────────────────────────────────────────────────
|
// ─── 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 = {
|
type Group = {
|
||||||
caseLawId: string;
|
caseLawId: string;
|
||||||
caseNumber: string;
|
caseNumber: string;
|
||||||
court: string;
|
court: string;
|
||||||
decisionDate: string | null;
|
decisionDate: string | null;
|
||||||
precedentLevel: string;
|
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[] {
|
function buildGroups(items: Halacha[]): Group[] {
|
||||||
const map = new Map<string, Group>();
|
const byCase = new Map<string, Halacha[]>();
|
||||||
for (const h of items) {
|
for (const h of items) {
|
||||||
const k = h.case_law_id;
|
const arr = byCase.get(h.case_law_id) ?? [];
|
||||||
let g = map.get(k);
|
arr.push(h);
|
||||||
if (!g) {
|
byCase.set(h.case_law_id, arr);
|
||||||
g = {
|
}
|
||||||
caseLawId: k,
|
const groups: Group[] = [];
|
||||||
caseNumber: h.case_number ?? "",
|
for (const [caseLawId, members] of byCase) {
|
||||||
court: h.court ?? "",
|
// collapse near-duplicate clusters (#84.2): one card per cluster_id.
|
||||||
decisionDate: h.decision_date ?? null,
|
const clusters = new Map<string, Halacha[]>();
|
||||||
precedentLevel: h.precedent_level ?? "",
|
for (const h of members) {
|
||||||
items: [],
|
const key = h.cluster_id ?? h.id;
|
||||||
};
|
const arr = clusters.get(key) ?? [];
|
||||||
map.set(k, g);
|
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()) {
|
return groups.sort((a, b) => b.pendingTotal - a.pendingTotal);
|
||||||
g.items.sort((a, b) => a.confidence - b.confidence);
|
|
||||||
}
|
|
||||||
return Array.from(map.values()).sort((a, b) => b.items.length - a.items.length);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Restore panel (used for "rejected" and "approved" tabs) ──────────────────
|
// ─── Restore panel (used for "rejected" and "approved" tabs) ──────────────────
|
||||||
@@ -422,7 +489,7 @@ function RestorePanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="tabular-nums">
|
<Badge variant="outline" className="tabular-nums">
|
||||||
{g.items.length}
|
{g.pendingTotal}
|
||||||
</Badge>
|
</Badge>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -450,7 +517,12 @@ function RestorePanel({
|
|||||||
// ─── Pending queue panel (main review flow) ───────────────────────────────────
|
// ─── Pending queue panel (main review flow) ───────────────────────────────────
|
||||||
|
|
||||||
function PendingPanel() {
|
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 update = useUpdateHalacha();
|
||||||
const batch = useBatchReviewHalachot();
|
const batch = useBatchReviewHalachot();
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||||
@@ -463,8 +535,8 @@ function PendingPanel() {
|
|||||||
|
|
||||||
const totalCount = data?.items.length ?? 0;
|
const totalCount = data?.items.length ?? 0;
|
||||||
|
|
||||||
const visibleItems = useMemo<Halacha[]>(() => {
|
const visibleItems = useMemo<ReviewItem[]>(() => {
|
||||||
const out: Halacha[] = [];
|
const out: ReviewItem[] = [];
|
||||||
for (const g of groups) {
|
for (const g of groups) {
|
||||||
if (expandedIds.has(g.caseLawId)) out.push(...g.items);
|
if (expandedIds.has(g.caseLawId)) out.push(...g.items);
|
||||||
}
|
}
|
||||||
@@ -483,7 +555,7 @@ function PendingPanel() {
|
|||||||
el?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
el?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
}, [focusedId]);
|
}, [focusedId]);
|
||||||
|
|
||||||
const focused = focusedId
|
const focused: ReviewItem | null = focusedId
|
||||||
? visibleItems.find((h) => h.id === focusedId) ?? null
|
? visibleItems.find((h) => h.id === focusedId) ?? null
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -503,16 +575,26 @@ function PendingPanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const review = async (
|
const review = async (
|
||||||
h: Halacha,
|
it: ReviewItem,
|
||||||
status: "approved" | "rejected" | "deferred",
|
status: "approved" | "rejected" | "deferred",
|
||||||
extra?: Partial<EditState>,
|
extra?: Partial<EditState>,
|
||||||
) => {
|
) => {
|
||||||
|
const ids = itemIds(it);
|
||||||
try {
|
try {
|
||||||
await update.mutateAsync({
|
// #84.2 — a cluster card applies the decision to all its variants at once
|
||||||
id: h.id,
|
// (an edit, which is canonical-specific, stays single).
|
||||||
patch: { review_status: status, ...extra },
|
if (ids.length > 1 && !extra) {
|
||||||
});
|
await batch.mutateAsync({ ids, status });
|
||||||
toast.success(REVIEW_TOAST[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) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "שגיאה");
|
toast.error(e instanceof Error ? e.message : "שגיאה");
|
||||||
}
|
}
|
||||||
@@ -521,7 +603,7 @@ function PendingPanel() {
|
|||||||
const reviewGroup = async (g: Group, status: "approved" | "rejected") => {
|
const reviewGroup = async (g: Group, status: "approved" | "rejected") => {
|
||||||
try {
|
try {
|
||||||
const res = await batch.mutateAsync({
|
const res = await batch.mutateAsync({
|
||||||
ids: g.items.map((h) => h.id), status,
|
ids: g.items.flatMap(itemIds), status,
|
||||||
});
|
});
|
||||||
toast.success(
|
toast.success(
|
||||||
`${status === "approved" ? "אושרו" : "נדחו"} ${res.updated} הלכות`,
|
`${status === "approved" ? "אושרו" : "נדחו"} ${res.updated} הלכות`,
|
||||||
@@ -573,37 +655,63 @@ function PendingPanel() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [focused, visibleItems]);
|
}, [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) {
|
if (error) {
|
||||||
return (
|
body = (
|
||||||
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
|
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
|
||||||
{error.message}
|
{error.message}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
} else if (isPending) {
|
||||||
|
body = (
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
} else if (!groups.length) {
|
||||||
|
body = (
|
||||||
if (!groups.length) {
|
|
||||||
return (
|
|
||||||
<div className="text-center text-ink-muted py-16">
|
<div className="text-center text-ink-muted py-16">
|
||||||
<p className="text-lg">אין הלכות הממתינות לאישור.</p>
|
<p className="text-lg">
|
||||||
<p className="text-sm mt-2">העלה פסיקה חדשה — ההלכות שיחולצו ממנה יופיעו כאן.</p>
|
{view === "needsfix"
|
||||||
|
? "בקט התיקון ריק — אין הלכות מסומנות-איכות."
|
||||||
|
: "אין הלכות נקיות הממתינות לאישור."}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm mt-2">
|
||||||
|
{view === "needsfix"
|
||||||
|
? "פריטים שסומנו (ציטוט לא-מאומת, יישום, כפילות-קרובה וכו') יופיעו כאן."
|
||||||
|
: "העלה פסיקה חדשה — ההלכות שיחולצו ממנה יופיעו כאן."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
} else {
|
||||||
|
body = (
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3 text-sm text-ink-muted flex-wrap">
|
<div className="flex items-center gap-3 text-sm text-ink-muted flex-wrap">
|
||||||
<span>
|
<span>
|
||||||
<span className="text-navy font-semibold">{totalCount}</span> ממתינות
|
<span className="text-navy font-semibold">{totalCount}</span>
|
||||||
ב-<span className="text-navy font-semibold">{groups.length}</span> פסיקות
|
{view === "needsfix" ? " מסומנות" : " ממתינות"}
|
||||||
|
{" "}ב-<span className="text-navy font-semibold">{groups.length}</span> פסיקות
|
||||||
</span>
|
</span>
|
||||||
<span className="me-auto text-[0.72rem]">
|
<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>
|
ניווט: <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>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="bg-gold-wash text-gold-deep border-gold/40 tabular-nums">
|
<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>
|
</Badge>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -658,12 +767,12 @@ function PendingPanel() {
|
|||||||
<div className="p-4 space-y-3 bg-rule-soft/20">
|
<div className="p-4 space-y-3 bg-rule-soft/20">
|
||||||
<div className="flex items-center gap-2 justify-end pb-1">
|
<div className="flex items-center gap-2 justify-end pb-1">
|
||||||
<span className="me-auto text-[0.72rem] text-ink-muted">
|
<span className="me-auto text-[0.72rem] text-ink-muted">
|
||||||
פעולה קבוצתית על {g.items.length} ההלכות:
|
פעולה קבוצתית על {g.pendingTotal} ההלכות:
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
size="sm" variant="ghost" disabled={batch.isPending}
|
size="sm" variant="ghost" disabled={batch.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.confirm(`לדחות את כל ${g.items.length} ההלכות בפסק זה?`))
|
if (window.confirm(`לדחות את כל ${g.pendingTotal} ההלכות בפסק זה?`))
|
||||||
reviewGroup(g, "rejected");
|
reviewGroup(g, "rejected");
|
||||||
}}
|
}}
|
||||||
className="text-danger hover:text-danger hover:bg-danger-bg"
|
className="text-danger hover:text-danger hover:bg-danger-bg"
|
||||||
@@ -673,7 +782,7 @@ function PendingPanel() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm" disabled={batch.isPending}
|
size="sm" disabled={batch.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.confirm(`לאשר את כל ${g.items.length} ההלכות בפסק זה?`))
|
if (window.confirm(`לאשר את כל ${g.pendingTotal} ההלכות בפסק זה?`))
|
||||||
reviewGroup(g, "approved");
|
reviewGroup(g, "approved");
|
||||||
}}
|
}}
|
||||||
className="bg-gold text-navy hover:bg-gold-deep"
|
className="bg-gold text-navy hover:bg-gold-deep"
|
||||||
@@ -708,6 +817,14 @@ function PendingPanel() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{viewToggle}
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,7 +847,7 @@ const TAB_LABELS: Record<Tab, string> = {
|
|||||||
|
|
||||||
export function HalachaReviewPanel() {
|
export function HalachaReviewPanel() {
|
||||||
const [tab, setTab] = useState<Tab>("pending");
|
const [tab, setTab] = useState<Tab>("pending");
|
||||||
const { data: pendingData } = useHalachotPending(500);
|
const { data: pendingData } = useHalachotPending({ limit: 500 });
|
||||||
const rejectedCount = useHalachaCount("rejected");
|
const rejectedCount = useHalachaCount("rejected");
|
||||||
const approvedCount = useHalachaCount("approved");
|
const approvedCount = useHalachaCount("approved");
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ export type Halacha = {
|
|||||||
* negatively (distinguished/criticized/overruled). */
|
* negatively (distinguished/criticized/overruled). */
|
||||||
corroboration_count?: number;
|
corroboration_count?: number;
|
||||||
corroboration_negative?: boolean;
|
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 = {
|
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({
|
return useQuery({
|
||||||
queryKey: libraryKeys.halachotPending(),
|
queryKey: [...libraryKeys.halachotPending(), needsFix ? "needsfix" : "clean"],
|
||||||
queryFn: ({ signal }) =>
|
queryFn: async ({ signal }) => {
|
||||||
apiRequest<{ items: Halacha[]; count: number }>(
|
const res = await apiRequest<{ items: Halacha[]; count: number }>(
|
||||||
`/api/halachot?review_status=pending_review&limit=${limit}`,
|
`/api/halachot?${qs}`,
|
||||||
{ signal },
|
{ 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,
|
staleTime: 5_000,
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
});
|
});
|
||||||
|
|||||||
10
web/app.py
10
web/app.py
@@ -6033,11 +6033,12 @@ async def halachot_list(
|
|||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
exclude_low_quality: bool = False,
|
exclude_low_quality: bool = False,
|
||||||
order_by_priority: bool = False,
|
order_by_priority: bool = False,
|
||||||
|
cluster: bool = False,
|
||||||
):
|
):
|
||||||
"""List halachot. ``exclude_low_quality`` hides flagged items (#84.1) and
|
"""List halachot. ``exclude_low_quality`` hides flagged items (#84.1),
|
||||||
``order_by_priority`` switches to the active-learning order (#84.3). Both
|
``order_by_priority`` switches to the active-learning order (#84.3), and
|
||||||
default off so existing callers are unaffected; the review-queue view opts
|
``cluster`` annotates near-duplicate groups for one-card review (#84.2). All
|
||||||
in."""
|
default off so existing callers are unaffected; the review queue opts in."""
|
||||||
cid: UUID | None = None
|
cid: UUID | None = None
|
||||||
if case_law_id:
|
if case_law_id:
|
||||||
try:
|
try:
|
||||||
@@ -6051,6 +6052,7 @@ async def halachot_list(
|
|||||||
limit=limit, offset=offset,
|
limit=limit, offset=offset,
|
||||||
exclude_low_quality=exclude_low_quality,
|
exclude_low_quality=exclude_low_quality,
|
||||||
order_by_priority=order_by_priority,
|
order_by_priority=order_by_priority,
|
||||||
|
cluster=cluster,
|
||||||
)
|
)
|
||||||
return {"items": rows, "count": len(rows)}
|
return {"items": rows, "count": len(rows)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user