diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 4543fe4..a6d5528 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -5494,12 +5494,15 @@ async def list_halachot( h.cites, h.confidence, h.quote_verified, h.quality_flags, h.review_status, 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.precedent_level, COALESCE(cor.corroboration_count, 0)::int AS corroboration_count, COALESCE(cor.corroboration_negative, false) AS corroboration_negative, pr.verdict AS panel_verdict 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 ( SELECT halacha_id, @@ -5936,11 +5939,21 @@ def _equiv_order(a: UUID, b: UUID) -> tuple[UUID, UUID]: async def link_equivalent_halachot( a: UUID, b: UUID, *, cosine: float = 0.0, note: str = "", created_by: str = "", ) -> 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 - the two belong to the SAME precedent (parallel authority is cross-precedent - by definition; within-precedent sameness is the dedup/cluster concern).""" + the two belong to the SAME precedent.""" + 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: return False pool = await get_pool() @@ -6134,6 +6147,21 @@ async def update_canonical_statement( 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: """Attach an `equivalents` list to each row (#84.2) — parallel-authority links. diff --git a/scripts/halacha_batch_reconcile.py b/scripts/halacha_batch_reconcile.py index 31313f2..4a9fd4e 100644 --- a/scripts/halacha_batch_reconcile.py +++ b/scripts/halacha_batch_reconcile.py @@ -93,20 +93,20 @@ async def main(args: argparse.Namespace) -> int: w.writerows(pairs) print(f"\nreport: {out}", flush=True) - if args.link and pairs: - # #84.2 — record each pair as parallel authority (equivalent_halachot). - # Non-destructive: links only, never merges/deletes. Idempotent. - linked = 0 - for p in pairs: - if await db.link_equivalent_halachot( - p["id_a"], p["id_b"], cosine=p["cosine"], - note="cross-precedent parallel authority (halacha_batch_reconcile)", - created_by="batch_reconcile", - ): - linked += 1 - print(f"linked {linked}/{len(pairs)} pairs as equivalent_halachot", flush=True) - elif pairs: - print("(review-only — pass --link to record them as equivalent_halachot)", flush=True) + if args.link: + # V41 (canonical_halachot): equivalent_halachot is FROZEN — no new links. + # Use backfill_canonical_halachot.py --apply instead. + print( + "\nERROR: --link is deprecated since V41 (canonical_halachot model).\n" + " equivalent_halachot is read-only and frozen post-backfill.\n" + " Cross-precedent dedup is now handled by the canonical model:\n" + " mcp-server/.venv/bin/python scripts/backfill_canonical_halachot.py --apply\n" + " Exiting without writing any links.", + flush=True, + ) + return 1 + if pairs: + print("(review-only — pair report saved above)", flush=True) return 0 @@ -118,6 +118,6 @@ if __name__ == "__main__": ap.add_argument("--include-pending", action="store_true", help="also scan pending_review halachot (default: approved/published only)") 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() sys.exit(asyncio.run(main(args))) diff --git a/web-ui/src/components/precedents/halacha-review-panel.tsx b/web-ui/src/components/precedents/halacha-review-panel.tsx index 331f4a3..c4aa39c 100644 --- a/web-ui/src/components/precedents/halacha-review-panel.tsx +++ b/web-ui/src/components/precedents/halacha-review-panel.tsx @@ -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 = { + original: "עיקרון מקורי", + citation: "ציטוט", + application: "יישום", +}; +const INSTANCE_TYPE_CLS: Record = { + 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; +}) { + 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 ( +
+
+ כ + ניסוח קנוני — העיקרון הרחב (V41) + + {instanceCount > 1 ? `מאחד ${instanceCount} פסיקות` : "instance יחיד"} + {h.review_status && ` · ${h.review_status}`} + +
+ + {editingCanon ? ( + <> +