The extractor classified rule_type by SOURCE bindingness (higher-court→binding, committee→persuasive) instead of by rule KIND. The gold-set proved it: 'binding' appeared on 19/19 external rulings & 0 committees; 'persuasive' on 13/13 committees & 0 external — only 58% agreement with the human role tags. The two axes (authority vs rule role) were crammed into one enum. This splits them per INV-DM7: - authority (binding/persuasive) — DERIVED from case_law.precedent_level (עליון/מנהלי→binding, ועדת_ערר_מחוזית→persuasive), never stored, never LLM-guessed. New helper halacha_quality.derive_authority; surfaced read-only in list_halachot / goldset_list / search results. - rule_type — now the rule ROLE only: holding/interpretive/procedural/ application/obiter. Both extractor prompts unified to this vocabulary; _coerce_halacha no longer defaults rule_type from the source; legacy binding→holding / persuasive→interpretive fold for safety. UI: authority shown as a separate read-only badge (gold=מחייב / muted=משכנע) across the review queue, precedent detail, and gold-set; the gold-set role selector drops binding/persuasive and adds מהותי (holding). Migration: scripts/halacha_rule_role_backfill.py re-classifies the 276 pre-split binding/persuasive rows into a genuine role via local claude_session (run after deploy). Gold-set correct_type/ai_correct_type 'binding'→'holding' via SQL. Sources (≥3, per research-decision policy): OASIS LegalRuleML v1.0 (appliesAuthority/Strength as metadata orthogonal to rule logic) · SemEval-2023 Task 6 LegalEval (rhetorical roles by function, authority kept separate) · Bluebook signals (weight-of-authority is a separate dimension). Invariants: ESTABLISHES INV-DM7. Upholds G1 (normalize at source — extractor classifies role, system derives authority) and G2 (single source of truth — authority derived, not a parallel stored field). Tests: 211 pass + new derive_authority/coerce coverage. web-ui build + tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState } from "react";
|
||
import { Check, X, RotateCcw } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { CorroborationBadge } from "@/components/precedents/corroboration-badge";
|
||
import { useUpdateHalacha, type Halacha } from "@/lib/api/precedent-library";
|
||
import { AuthorityBadge, ruleTypeLabel } from "./halacha-meta";
|
||
|
||
type StatusFilter = "all" | "approved" | "pending" | "rejected";
|
||
|
||
export function ReviewStatusPill({ status }: { status: Halacha["review_status"] }) {
|
||
if (status === "approved" || status === "published") {
|
||
return (
|
||
<Badge
|
||
variant="outline"
|
||
className="text-[0.65rem] bg-gold-wash text-gold-deep border-gold/40"
|
||
>
|
||
מאושרת
|
||
</Badge>
|
||
);
|
||
}
|
||
if (status === "pending_review") {
|
||
return (
|
||
<Badge
|
||
variant="outline"
|
||
className="text-[0.65rem] bg-rule-soft text-ink-muted"
|
||
>
|
||
ממתינה
|
||
</Badge>
|
||
);
|
||
}
|
||
return (
|
||
<Badge
|
||
variant="outline"
|
||
className="text-[0.65rem] bg-danger-bg text-danger border-danger/40"
|
||
>
|
||
נדחתה
|
||
</Badge>
|
||
);
|
||
}
|
||
|
||
/* Roll-up of every halacha extracted from a precedent — approved +
|
||
* pending + rejected. The "ממתין לאישור" tab surfaces pending items
|
||
* globally; this section is the per-case view, with inline status
|
||
* actions so the chair can flip a single ruling (e.g. approve one that
|
||
* was rejected) without leaving the precedent. */
|
||
export function ExtractedHalachotSection({ halachot }: { halachot: Halacha[] }) {
|
||
const [filter, setFilter] = useState<StatusFilter>("all");
|
||
const update = useUpdateHalacha();
|
||
|
||
const setStatus = async (
|
||
h: Halacha,
|
||
status: "approved" | "rejected" | "pending_review",
|
||
) => {
|
||
try {
|
||
await update.mutateAsync({ id: h.id, patch: { review_status: status } });
|
||
toast.success("עודכן");
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "שגיאה");
|
||
}
|
||
};
|
||
|
||
const counts = useMemo(() => {
|
||
const c = { all: halachot.length, approved: 0, pending: 0, rejected: 0 };
|
||
for (const h of halachot) {
|
||
if (h.review_status === "approved" || h.review_status === "published") {
|
||
c.approved++;
|
||
} else if (h.review_status === "pending_review") {
|
||
c.pending++;
|
||
} else if (h.review_status === "rejected") {
|
||
c.rejected++;
|
||
}
|
||
}
|
||
return c;
|
||
}, [halachot]);
|
||
|
||
const sorted = useMemo(() => {
|
||
const matches = (h: Halacha) => {
|
||
if (filter === "all") return true;
|
||
if (filter === "approved") {
|
||
return h.review_status === "approved" || h.review_status === "published";
|
||
}
|
||
if (filter === "pending") return h.review_status === "pending_review";
|
||
return h.review_status === "rejected";
|
||
};
|
||
/* Surface citation-corroborated halachot first: any with a badge
|
||
* (positive count or a negative treatment) float to the top, ordered
|
||
* by corroboration strength; the rest keep document order by index. */
|
||
const hasTag = (h: Halacha) =>
|
||
(h.corroboration_count ?? 0) > 0 || (h.corroboration_negative ?? false);
|
||
return halachot
|
||
.filter(matches)
|
||
.sort((a, b) => {
|
||
const tagDelta = Number(hasTag(b)) - Number(hasTag(a));
|
||
if (tagDelta !== 0) return tagDelta;
|
||
const countDelta =
|
||
(b.corroboration_count ?? 0) - (a.corroboration_count ?? 0);
|
||
if (countDelta !== 0) return countDelta;
|
||
return a.halacha_index - b.halacha_index;
|
||
});
|
||
}, [halachot, filter]);
|
||
|
||
if (!halachot.length) {
|
||
return (
|
||
<div className="rounded-lg border border-rule bg-rule-soft/40 p-4 text-center text-ink-muted text-sm">
|
||
עדיין לא חולצו הלכות מהפסיקה הזו.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const tabs: { key: StatusFilter; label: string; count: number }[] = [
|
||
{ key: "all", label: "הכל", count: counts.all },
|
||
{ key: "approved", label: "מאושרות", count: counts.approved },
|
||
{ key: "pending", label: "ממתינות", count: counts.pending },
|
||
{ key: "rejected", label: "נדחו", count: counts.rejected },
|
||
];
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||
<h3 className="text-navy text-base font-semibold m-0">
|
||
הלכות שחולצו ({counts.all})
|
||
</h3>
|
||
<div className="flex items-center gap-1 flex-wrap" role="tablist">
|
||
{tabs.map((t) => {
|
||
const active = filter === t.key;
|
||
return (
|
||
<button
|
||
key={t.key}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={active}
|
||
onClick={() => setFilter(t.key)}
|
||
className={`text-[0.78rem] px-2.5 py-1 rounded border transition-colors tabular-nums ${
|
||
active
|
||
? "bg-navy text-parchment border-navy"
|
||
: "bg-surface text-ink-muted border-rule hover:bg-rule-soft"
|
||
}`}
|
||
>
|
||
{t.label} ({t.count})
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{!sorted.length ? (
|
||
<div className="text-center text-ink-muted text-sm py-6">
|
||
אין הלכות בקטגוריה זו.
|
||
</div>
|
||
) : (
|
||
<ol className="space-y-3 list-none ps-0 m-0">
|
||
{sorted.map((h) => (
|
||
<li
|
||
key={h.id}
|
||
className="rounded-lg border border-rule bg-surface p-3 space-y-2"
|
||
>
|
||
<div className="flex items-start gap-2 flex-wrap">
|
||
<span className="text-[0.7rem] text-ink-muted tabular-nums font-mono">
|
||
#{h.halacha_index}
|
||
</span>
|
||
<ReviewStatusPill status={h.review_status} />
|
||
<Badge variant="outline" className="text-[0.65rem]">
|
||
{ruleTypeLabel(h.rule_type)}
|
||
</Badge>
|
||
<AuthorityBadge authority={h.authority} />
|
||
<Badge variant="outline" className="text-[0.65rem] tabular-nums">
|
||
ביטחון {h.confidence.toFixed(2)}
|
||
</Badge>
|
||
<CorroborationBadge halacha={h} />
|
||
{h.page_reference ? (
|
||
<span className="text-[0.7rem] text-ink-muted ms-auto">
|
||
{h.page_reference}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
<p
|
||
className="text-navy font-medium text-sm leading-relaxed m-0"
|
||
dir="rtl"
|
||
>
|
||
{h.rule_statement}
|
||
</p>
|
||
|
||
{h.reasoning_summary ? (
|
||
<p
|
||
className="text-ink-soft text-[0.82rem] leading-relaxed m-0"
|
||
dir="rtl"
|
||
>
|
||
<span className="text-ink-muted text-[0.7rem]">היגיון: </span>
|
||
{h.reasoning_summary}
|
||
</p>
|
||
) : null}
|
||
|
||
{h.supporting_quote ? (
|
||
<blockquote
|
||
className="text-ink-soft text-[0.82rem] leading-relaxed border-r-2 border-gold pr-3 m-0"
|
||
dir="rtl"
|
||
>
|
||
“{h.supporting_quote}”
|
||
</blockquote>
|
||
) : null}
|
||
|
||
{h.subject_tags?.length ? (
|
||
<div className="flex items-center gap-1 flex-wrap pt-1">
|
||
{h.subject_tags.map((t) => (
|
||
<Badge key={t} variant="outline" className="text-[0.65rem]">
|
||
{t}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Inline status actions — contextual per current review_status */}
|
||
<div className="flex items-center gap-2 justify-end pt-2 border-t border-rule-soft">
|
||
{h.review_status === "rejected" && (
|
||
<>
|
||
<Button size="sm" variant="ghost" disabled={update.isPending}
|
||
onClick={() => setStatus(h, "pending_review")}
|
||
className="text-ink-muted hover:text-navy">
|
||
<RotateCcw className="w-3.5 h-3.5 me-1" />
|
||
שחזר לתור
|
||
</Button>
|
||
<Button size="sm" disabled={update.isPending}
|
||
onClick={() => setStatus(h, "approved")}
|
||
className="bg-gold text-navy hover:bg-gold-deep">
|
||
<Check className="w-3.5 h-3.5 me-1" />
|
||
אשר
|
||
</Button>
|
||
</>
|
||
)}
|
||
{(h.review_status === "approved" || h.review_status === "published") && (
|
||
<>
|
||
<Button size="sm" variant="ghost" disabled={update.isPending}
|
||
onClick={() => setStatus(h, "pending_review")}
|
||
className="text-ink-muted hover:text-navy">
|
||
<RotateCcw className="w-3.5 h-3.5 me-1" />
|
||
בטל אישור
|
||
</Button>
|
||
<Button size="sm" variant="ghost" disabled={update.isPending}
|
||
onClick={() => {
|
||
if (window.confirm("לדחות הלכה זו?")) setStatus(h, "rejected");
|
||
}}
|
||
className="text-danger hover:text-danger hover:bg-danger-bg">
|
||
<X className="w-3.5 h-3.5 me-1" />
|
||
דחה
|
||
</Button>
|
||
</>
|
||
)}
|
||
{h.review_status === "pending_review" && (
|
||
<>
|
||
<Button size="sm" variant="ghost" disabled={update.isPending}
|
||
onClick={() => {
|
||
if (window.confirm("לדחות הלכה זו?")) setStatus(h, "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={update.isPending}
|
||
onClick={() => setStatus(h, "approved")}
|
||
className="bg-gold text-navy hover:bg-gold-deep">
|
||
<Check className="w-3.5 h-3.5 me-1" />
|
||
אשר
|
||
</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|