Files
legal-ai/web-ui/src/components/precedents/extracted-halachot.tsx
Chaim 2e33cac043 fix(halacha): split authority (derived) from rule_role — stop source-conflation (INV-DM7)
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>
2026-06-07 18:18:41 +00:00

279 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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"
>
&ldquo;{h.supporting_quote}&rdquo;
</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>
);
}