feat(halachot): Phase 5 — canonical panel UI + instances accordion (V41)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s

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:
2026-06-19 05:41:24 +00:00
parent 75f40cc778
commit dd2e12f902
4 changed files with 218 additions and 1 deletions

View File

@@ -5494,12 +5494,15 @@ async def list_halachot(
h.cites, h.confidence, h.quote_verified, h.quality_flags, h.cites, h.confidence, h.quote_verified, h.quality_flags,
h.review_status, h.review_status,
h.reviewer, h.reviewed_at, h.created_at, h.updated_at, h.reviewer, h.reviewed_at, h.created_at, h.updated_at,
h.canonical_id, h.instance_type,
ch.canonical_statement, ch.instance_count,
cl.case_number, cl.case_name, cl.court, cl.date AS decision_date, cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
cl.precedent_level, cl.precedent_level,
COALESCE(cor.corroboration_count, 0)::int AS corroboration_count, COALESCE(cor.corroboration_count, 0)::int AS corroboration_count,
COALESCE(cor.corroboration_negative, false) AS corroboration_negative, COALESCE(cor.corroboration_negative, false) AS corroboration_negative,
pr.verdict AS panel_verdict pr.verdict AS panel_verdict
FROM halachot h FROM halachot h
LEFT JOIN canonical_halachot ch ON ch.id = h.canonical_id
LEFT JOIN case_law cl ON cl.id = h.case_law_id LEFT JOIN case_law cl ON cl.id = h.case_law_id
LEFT JOIN ( LEFT JOIN (
SELECT halacha_id, SELECT halacha_id,
@@ -6144,6 +6147,21 @@ async def update_canonical_statement(
return result.split()[-1] != "0" return result.split()[-1] != "0"
async def list_canonical_instances(canonical_id: "UUID") -> list[dict]:
"""List all halachot (instances) sharing a canonical_id — used by the UI accordion."""
pool = await get_pool()
rows = await pool.fetch(
"""SELECT h.id, h.instance_type, h.confidence, h.rule_statement,
cl.case_number, cl.case_name
FROM halachot h
LEFT JOIN case_law cl ON cl.id = h.case_law_id
WHERE h.canonical_id = $1
ORDER BY h.instance_type, cl.case_number""",
canonical_id,
)
return [dict(r) for r in rows]
async def _annotate_equivalents(pool, out: list[dict]) -> None: async def _annotate_equivalents(pool, out: list[dict]) -> None:
"""Attach an `equivalents` list to each row (#84.2) — parallel-authority links. """Attach an `equivalents` list to each row (#84.2) — parallel-authority links.

View File

@@ -11,7 +11,8 @@ import { CorroborationBadge } from "./corroboration-badge";
import { practiceAreaLabel } from "./practice-area"; import { practiceAreaLabel } from "./practice-area";
import { import {
useHalachotPending, useHalachotByStatus, useUpdateHalacha, useBatchReviewHalachot, useHalachotPending, useHalachotByStatus, useUpdateHalacha, useBatchReviewHalachot,
useLibraryStats, isExtractionFixItem, type Halacha, useLibraryStats, isExtractionFixItem, useCanonicalInstances,
type Halacha, type CanonicalInstance,
} from "@/lib/api/precedent-library"; } from "@/lib/api/precedent-library";
import { AuthorityBadge, ruleTypeLabel } from "./halacha-meta"; 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) ─────────────────────────────────── // ─── Pending-queue card (full interactions) ───────────────────────────────────
function HalachaCard({ 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 className="rounded bg-gold text-white border-0 text-[0.62rem] font-bold tracking-wide">
הלכה הלכה
</Badge> </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 && ( {h.page_reference && (
<span className="text-[0.7rem]">{h.page_reference}</span> <span className="text-[0.7rem]">{h.page_reference}</span>
)} )}
@@ -372,6 +510,16 @@ function HalachaCard({
</div> </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"> <div className="flex items-center gap-2 justify-end pt-1 border-t border-rule-soft">
{editing ? ( {editing ? (
<> <>

View File

@@ -126,6 +126,11 @@ export type Halacha = {
reason: string; 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 = { export type RelatedCase = {
@@ -671,8 +676,31 @@ export type HalachaPatch = Partial<{
// #133 — editing the quote re-verifies it against the source server-side and // #133 — editing the quote re-verifies it against the source server-side and
// clears/sets the quote_unverified flag (extraction repair). // clears/sets the quote_unverified flag (extraction repair).
supporting_quote: string; 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() { export function useUpdateHalacha() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({

View File

@@ -6025,6 +6025,7 @@ class HalachaUpdateRequest(BaseModel):
subject_tags: list[str] | None = None subject_tags: list[str] | None = None
practice_areas: list[str] | None = None practice_areas: list[str] | None = None
supporting_quote: str | None = None # #133 — edited quote → re-verify + sync flag supporting_quote: str | None = None # #133 — edited quote → re-verify + sync flag
canonical_statement: str | None = None # V41 — updates canonical_halachot.canonical_statement
class HalachaBatchReviewRequest(BaseModel): class HalachaBatchReviewRequest(BaseModel):
@@ -7414,6 +7415,18 @@ async def halacha_equivalents_unlink(halacha_id: str, other_id: str):
return {"ok": await db.unlink_equivalent_halachot(hid, oid)} return {"ok": await db.unlink_equivalent_halachot(hid, oid)}
# ── Canonical halachot — V41 ─────────────────────────────────────────────────
@app.get("/api/canonical-halachot/{canonical_id}/instances")
async def canonical_halacha_instances(canonical_id: str):
"""All halachot instances sharing a canonical_id (V41 — used by the UI accordion)."""
try:
cid = UUID(canonical_id)
except ValueError:
raise HTTPException(400, "canonical_id לא תקין")
return {"instances": await db.list_canonical_instances(cid)}
# ── Gold-set tagging (#81.7 / #81.8) ───────────────────────────────────────── # ── Gold-set tagging (#81.7 / #81.8) ─────────────────────────────────────────
class GoldsetSampleRequest(BaseModel): class GoldsetSampleRequest(BaseModel):
@@ -7490,6 +7503,16 @@ async def halacha_update(halacha_id: str, req: HalachaUpdateRequest):
) )
if not row: if not row:
raise HTTPException(404, "הלכה לא נמצאה") raise HTTPException(404, "הלכה לא נמצאה")
if req.canonical_statement is not None:
# V41: propagate to canonical_halachot; canonical_id not in RETURNING
# so we fetch it separately (one cheap indexed lookup).
_pool = await db.get_pool()
canon_id = await _pool.fetchval(
"SELECT canonical_id FROM halachot WHERE id=$1", hid
)
if canon_id:
from uuid import UUID as _UUID
await db.update_canonical_statement(_UUID(str(canon_id)), req.canonical_statement)
return row return row