feat(halachot): Phase 5+6 — canonical panel UI + equivalent_halachot deprecation #300
@@ -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,
|
||||||
@@ -5936,11 +5939,21 @@ def _equiv_order(a: UUID, b: UUID) -> tuple[UUID, UUID]:
|
|||||||
async def link_equivalent_halachot(
|
async def link_equivalent_halachot(
|
||||||
a: UUID, b: UUID, *, cosine: float = 0.0, note: str = "", created_by: str = "",
|
a: UUID, b: UUID, *, cosine: float = 0.0, note: str = "", created_by: str = "",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Record that two halachot (different precedents) state the same principle.
|
"""[DEPRECATED since V41] Record a parallel-authority link in equivalent_halachot.
|
||||||
|
|
||||||
|
The canonical_halachot model (V41) supersedes this table — cross-precedent
|
||||||
|
equivalence is now expressed via halachot.canonical_id. This function is kept
|
||||||
|
for historical callers only; no new code should call it. Use
|
||||||
|
``create_canonical_halacha`` + ``nearest_canonical_halacha`` instead.
|
||||||
|
|
||||||
Idempotent (symmetric UNIQUE). Returns False and does nothing if a == b or
|
Idempotent (symmetric UNIQUE). Returns False and does nothing if a == b or
|
||||||
the two belong to the SAME precedent (parallel authority is cross-precedent
|
the two belong to the SAME precedent."""
|
||||||
by definition; within-precedent sameness is the dedup/cluster concern)."""
|
import warnings
|
||||||
|
warnings.warn(
|
||||||
|
"link_equivalent_halachot is deprecated since V41 (canonical_halachot). "
|
||||||
|
"Use create_canonical_halacha / nearest_canonical_halacha instead.",
|
||||||
|
DeprecationWarning, stacklevel=2,
|
||||||
|
)
|
||||||
if a == b:
|
if a == b:
|
||||||
return False
|
return False
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
@@ -6134,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.
|
||||||
|
|
||||||
|
|||||||
@@ -93,20 +93,20 @@ async def main(args: argparse.Namespace) -> int:
|
|||||||
w.writerows(pairs)
|
w.writerows(pairs)
|
||||||
print(f"\nreport: {out}", flush=True)
|
print(f"\nreport: {out}", flush=True)
|
||||||
|
|
||||||
if args.link and pairs:
|
if args.link:
|
||||||
# #84.2 — record each pair as parallel authority (equivalent_halachot).
|
# V41 (canonical_halachot): equivalent_halachot is FROZEN — no new links.
|
||||||
# Non-destructive: links only, never merges/deletes. Idempotent.
|
# Use backfill_canonical_halachot.py --apply instead.
|
||||||
linked = 0
|
print(
|
||||||
for p in pairs:
|
"\nERROR: --link is deprecated since V41 (canonical_halachot model).\n"
|
||||||
if await db.link_equivalent_halachot(
|
" equivalent_halachot is read-only and frozen post-backfill.\n"
|
||||||
p["id_a"], p["id_b"], cosine=p["cosine"],
|
" Cross-precedent dedup is now handled by the canonical model:\n"
|
||||||
note="cross-precedent parallel authority (halacha_batch_reconcile)",
|
" mcp-server/.venv/bin/python scripts/backfill_canonical_halachot.py --apply\n"
|
||||||
created_by="batch_reconcile",
|
" Exiting without writing any links.",
|
||||||
):
|
flush=True,
|
||||||
linked += 1
|
)
|
||||||
print(f"linked {linked}/{len(pairs)} pairs as equivalent_halachot", flush=True)
|
return 1
|
||||||
elif pairs:
|
if pairs:
|
||||||
print("(review-only — pass --link to record them as equivalent_halachot)", flush=True)
|
print("(review-only — pair report saved above)", flush=True)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -118,6 +118,6 @@ if __name__ == "__main__":
|
|||||||
ap.add_argument("--include-pending", action="store_true",
|
ap.add_argument("--include-pending", action="store_true",
|
||||||
help="also scan pending_review halachot (default: approved/published only)")
|
help="also scan pending_review halachot (default: approved/published only)")
|
||||||
ap.add_argument("--link", action="store_true",
|
ap.add_argument("--link", action="store_true",
|
||||||
help="record found pairs as equivalent_halachot (parallel authority, #84.2)")
|
help="[DEPRECATED since V41] refused at runtime — use backfill_canonical_halachot.py")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
sys.exit(asyncio.run(main(args)))
|
sys.exit(asyncio.run(main(args)))
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
23
web/app.py
23
web/app.py
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user