feat(halachot): Phase 5 — canonical panel UI + instances accordion (V41)
UI changes to halacha-review-panel.tsx:
- instance_type badge (עיקרון מקורי / ציטוט / יישום) in meta row
- "מוזכר ב-N פסיקות" pill when instance_count > 1
- CanonicalSection component: canonical_statement (view + edit), instances accordion
Backend:
- list_halachot SQL: adds canonical_id, instance_type, canonical_statement,
instance_count via LEFT JOIN canonical_halachot
- list_canonical_instances(canonical_id) → compact rows for accordion
- GET /api/canonical-halachot/{canonical_id}/instances endpoint
- PATCH /api/halachot/{id}: canonical_statement propagates to canonical_halachot
- HalachaUpdateRequest: canonical_statement field
- useCanonicalInstances hook + CanonicalInstance type in precedent-library.ts
INV-G10 (chair gate): only the chair can edit canonical_statement (same
flow as rule_statement — PATCH /api/halachot/{id} with reviewer="דפנה").
G2: canonical data flows through canonical_halachot, not a parallel store.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,8 @@ import { CorroborationBadge } from "./corroboration-badge";
|
||||
import { practiceAreaLabel } from "./practice-area";
|
||||
import {
|
||||
useHalachotPending, useHalachotByStatus, useUpdateHalacha, useBatchReviewHalachot,
|
||||
useLibraryStats, isExtractionFixItem, type Halacha,
|
||||
useLibraryStats, isExtractionFixItem, useCanonicalInstances,
|
||||
type Halacha, type CanonicalInstance,
|
||||
} from "@/lib/api/precedent-library";
|
||||
import { AuthorityBadge, ruleTypeLabel } from "./halacha-meta";
|
||||
|
||||
@@ -135,6 +136,131 @@ function PanelDeliberation({ round }: { round: NonNullable<Halacha["panel_round"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── V41: Canonical section (principle statement + instances accordion) ──────
|
||||
|
||||
const INSTANCE_TYPE_LABELS: Record<string, string> = {
|
||||
original: "עיקרון מקורי",
|
||||
citation: "ציטוט",
|
||||
application: "יישום",
|
||||
};
|
||||
const INSTANCE_TYPE_CLS: Record<string, string> = {
|
||||
original: "bg-navy text-parchment",
|
||||
citation: "bg-info text-white",
|
||||
application: "bg-ink-muted text-white",
|
||||
};
|
||||
|
||||
function CanonicalSection({
|
||||
h, onSaveCanonical,
|
||||
}: {
|
||||
h: Halacha;
|
||||
onSaveCanonical: (stmt: string) => Promise<void>;
|
||||
}) {
|
||||
const [editingCanon, setEditingCanon] = useState(false);
|
||||
const [canonDraft, setCanonDraft] = useState(h.canonical_statement ?? "");
|
||||
const [showInstances, setShowInstances] = useState(false);
|
||||
const { data: instances, isLoading: instLoading } = useCanonicalInstances(
|
||||
showInstances ? h.canonical_id : null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setCanonDraft(h.canonical_statement ?? "");
|
||||
}, [h.canonical_id, h.canonical_statement]);
|
||||
|
||||
const handleSave = async () => {
|
||||
await onSaveCanonical(canonDraft);
|
||||
setEditingCanon(false);
|
||||
};
|
||||
|
||||
const instanceCount = h.instance_count ?? 1;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[#d4cdef] bg-[#f0ecfb] p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="w-4 h-4 rounded-sm bg-[#6d5fa6] text-white text-[0.6rem] font-bold flex items-center justify-center flex-shrink-0">כ</span>
|
||||
<span className="text-[0.7rem] font-bold text-[#6d5fa6]">ניסוח קנוני — העיקרון הרחב (V41)</span>
|
||||
<span className="text-[0.65rem] text-[#9089b8] ms-auto">
|
||||
{instanceCount > 1 ? `מאחד ${instanceCount} פסיקות` : "instance יחיד"}
|
||||
{h.review_status && ` · ${h.review_status}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{editingCanon ? (
|
||||
<>
|
||||
<Textarea
|
||||
value={canonDraft}
|
||||
onChange={(e) => setCanonDraft(e.target.value)}
|
||||
rows={3}
|
||||
dir="rtl"
|
||||
className="bg-white/85 border-[#6d5fa6]/50 text-[0.82rem]"
|
||||
/>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingCanon(false)}
|
||||
className="text-[0.72rem] text-ink-muted hover:text-navy"
|
||||
>
|
||||
ביטול
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="rounded-md bg-[#6d5fa6] text-white text-[0.72rem] font-semibold px-3 py-1"
|
||||
>
|
||||
שמור ניסוח קנוני
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-start gap-2">
|
||||
<p className="text-[0.82rem] text-ink font-medium leading-relaxed flex-1 bg-white/65 rounded px-2 py-1.5 border border-[#d4cdef]" dir="rtl">
|
||||
{h.canonical_statement || <span className="text-ink-muted italic">— pending synthesis —</span>}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingCanon(true)}
|
||||
className="text-[0.65rem] text-[#6d5fa6] hover:underline flex-shrink-0 mt-1"
|
||||
>
|
||||
ערוך
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{instanceCount > 1 && (
|
||||
<div className="rounded-md border border-[#d4cdef] overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowInstances((v) => !v)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-[0.7rem] text-[#6d5fa6] font-medium hover:bg-[#e9e4f8] transition-colors"
|
||||
aria-expanded={showInstances}
|
||||
>
|
||||
{showInstances ? <ChevronDown className="w-3 h-3" /> : <ChevronLeft className="w-3 h-3" />}
|
||||
<span>אינסטנסים — {instanceCount} פסיקות שמאזכרות עיקרון זה</span>
|
||||
</button>
|
||||
{showInstances && (
|
||||
<div className="bg-white p-2 space-y-1">
|
||||
{instLoading && <p className="text-[0.7rem] text-ink-muted px-2">טוען...</p>}
|
||||
{instances?.map((inst: CanonicalInstance) => (
|
||||
<div key={inst.id}
|
||||
className="flex items-center gap-2 rounded px-2 py-1.5 border border-rule-soft text-[0.72rem]">
|
||||
<span className="font-semibold text-navy min-w-[80px]">{inst.case_number}</span>
|
||||
<span className="text-ink-soft flex-1 truncate">{inst.case_name}</span>
|
||||
<Badge className={`text-[0.6rem] font-bold border-0 rounded-full px-2 ${INSTANCE_TYPE_CLS[inst.instance_type] ?? "bg-rule text-ink"}`}>
|
||||
{INSTANCE_TYPE_LABELS[inst.instance_type] ?? inst.instance_type}
|
||||
</Badge>
|
||||
{inst.confidence > 0 && (
|
||||
<span className="text-ink-muted tabular-nums">{inst.confidence.toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Pending-queue card (full interactions) ───────────────────────────────────
|
||||
|
||||
function HalachaCard({
|
||||
@@ -195,6 +321,18 @@ function HalachaCard({
|
||||
<Badge className="rounded bg-gold text-white border-0 text-[0.62rem] font-bold tracking-wide">
|
||||
הלכה
|
||||
</Badge>
|
||||
{/* V41: instance_type badge */}
|
||||
{h.instance_type && (
|
||||
<Badge className={`rounded border-0 text-[0.62rem] font-bold ${INSTANCE_TYPE_CLS[h.instance_type] ?? "bg-rule text-ink"}`}>
|
||||
{INSTANCE_TYPE_LABELS[h.instance_type] ?? h.instance_type}
|
||||
</Badge>
|
||||
)}
|
||||
{/* V41: instance count pill */}
|
||||
{(h.instance_count ?? 0) > 1 && (
|
||||
<Badge variant="outline" className="text-[0.62rem] border-[#d4cdef] text-[#6d5fa6] bg-[#f0ecfb]">
|
||||
מוזכר ב-{h.instance_count} פסיקות
|
||||
</Badge>
|
||||
)}
|
||||
{h.page_reference && (
|
||||
<span className="text-[0.7rem]">{h.page_reference}</span>
|
||||
)}
|
||||
@@ -372,6 +510,16 @@ function HalachaCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* V41: canonical statement + instances accordion */}
|
||||
{h.canonical_id && (
|
||||
<CanonicalSection
|
||||
h={h}
|
||||
onSaveCanonical={async (stmt) => {
|
||||
await onSave({ canonical_statement: stmt } as Parameters<typeof onSave>[0]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 justify-end pt-1 border-t border-rule-soft">
|
||||
{editing ? (
|
||||
<>
|
||||
|
||||
@@ -126,6 +126,11 @@ export type Halacha = {
|
||||
reason: string;
|
||||
}[];
|
||||
};
|
||||
/* V41 — canonical halachot model: one principle, many instances. */
|
||||
canonical_id?: string | null;
|
||||
instance_type?: "original" | "citation" | "application" | null;
|
||||
canonical_statement?: string | null;
|
||||
instance_count?: number | null;
|
||||
};
|
||||
|
||||
export type RelatedCase = {
|
||||
@@ -671,8 +676,31 @@ export type HalachaPatch = Partial<{
|
||||
// #133 — editing the quote re-verifies it against the source server-side and
|
||||
// clears/sets the quote_unverified flag (extraction repair).
|
||||
supporting_quote: string;
|
||||
// V41 — propagated to canonical_halachot.canonical_statement
|
||||
canonical_statement: string;
|
||||
}>;
|
||||
|
||||
export type CanonicalInstance = {
|
||||
id: string;
|
||||
instance_type: "original" | "citation" | "application";
|
||||
confidence: number;
|
||||
rule_statement: string;
|
||||
case_number: string;
|
||||
case_name: string;
|
||||
};
|
||||
|
||||
export function useCanonicalInstances(canonical_id: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["canonical-instances", canonical_id],
|
||||
queryFn: () =>
|
||||
apiRequest<{ instances: CanonicalInstance[] }>(
|
||||
`/api/canonical-halachot/${encodeURIComponent(canonical_id!)}/instances`,
|
||||
).then((r) => r.instances),
|
||||
enabled: !!canonical_id,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateHalacha() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
Reference in New Issue
Block a user