feat: link related precedents across court instances (SCHEMA_V11)

Add ability to mark case_law records as related (e.g. same appeal
through ועדת ערר → מנהלי → עליון):
- DB: case_law_relations join table (bidirectional, V11 migration)
- DB CRUD: add/remove/get_case_law_relations
- Service: get_precedent() now returns related_cases[]
- MCP: precedent_link_cases + precedent_unlink_cases tools
- REST: POST/DELETE /api/precedent-library/{id}/relations
- UI: RelatedCasesSection on detail page with search dialog and unlink

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 07:52:29 +00:00
parent 13a8d9e58f
commit 3e14cd6798
8 changed files with 443 additions and 2 deletions

View File

@@ -216,6 +216,22 @@ async def precedent_library_delete(case_law_id: str) -> str:
return await plib.precedent_library_delete(case_law_id)
@mcp.tool()
async def precedent_link_cases(
case_law_id_a: str,
case_law_id_b: str,
relation_type: str = "same_case_chain",
) -> str:
"""קישור שתי פסיקות כקשורות (דו-כיווני, idempotent). relation_type: same_case_chain | overruled_by | distinguished."""
return await plib.precedent_link_cases(case_law_id_a, case_law_id_b, relation_type)
@mcp.tool()
async def precedent_unlink_cases(case_law_id_a: str, case_law_id_b: str) -> str:
"""הסרת קישור בין שתי פסיקות (דו-כיווני)."""
return await plib.precedent_unlink_cases(case_law_id_a, case_law_id_b)
@mcp.tool()
async def precedent_extract_halachot(case_law_id: str) -> str:
"""הרצה מחדש של חילוץ הלכות לפסיקה קיימת. ההלכות הקיימות נמחקות, החדשות חוזרות לסטטוס pending_review."""

View File

@@ -700,6 +700,20 @@ CREATE INDEX IF NOT EXISTS idx_case_law_chair ON case_law(chair_name) WHERE chai
CREATE INDEX IF NOT EXISTS idx_case_law_district ON case_law(district) WHERE district <> '';
"""
SCHEMA_V11_SQL = """
CREATE TABLE IF NOT EXISTS case_law_relations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
case_law_id UUID NOT NULL REFERENCES case_law(id) ON DELETE CASCADE,
related_id UUID NOT NULL REFERENCES case_law(id) ON DELETE CASCADE,
relation_type TEXT NOT NULL DEFAULT 'same_case_chain',
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(case_law_id, related_id),
CHECK (case_law_id <> related_id)
);
CREATE INDEX IF NOT EXISTS idx_clr_a ON case_law_relations(case_law_id);
CREATE INDEX IF NOT EXISTS idx_clr_b ON case_law_relations(related_id);
"""
async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
async with pool.acquire() as conn:
@@ -714,7 +728,8 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
await conn.execute(SCHEMA_V8_SQL)
await conn.execute(SCHEMA_V9_SQL)
await conn.execute(SCHEMA_V10_SQL)
logger.info("Database schema initialized (v1-v10)")
await conn.execute(SCHEMA_V11_SQL)
logger.info("Database schema initialized (v1-v11)")
async def init_schema() -> None:
@@ -1735,6 +1750,59 @@ async def get_case_law(case_law_id: UUID) -> dict | None:
return _row_to_case_law(row) if row else None
async def add_case_law_relation(
a_id: UUID, b_id: UUID, relation_type: str = "same_case_chain"
) -> None:
"""Link two case_law records bidirectionally. Idempotent (ON CONFLICT DO NOTHING)."""
pool = await get_pool()
async with pool.acquire() as conn:
await conn.executemany(
"""
INSERT INTO case_law_relations(case_law_id, related_id, relation_type)
VALUES($1, $2, $3)
ON CONFLICT (case_law_id, related_id) DO NOTHING
""",
[(a_id, b_id, relation_type), (b_id, a_id, relation_type)],
)
async def remove_case_law_relation(a_id: UUID, b_id: UUID) -> None:
"""Remove a bidirectional link between two case_law records."""
pool = await get_pool()
await pool.execute(
"""
DELETE FROM case_law_relations
WHERE (case_law_id = $1 AND related_id = $2)
OR (case_law_id = $2 AND related_id = $1)
""",
a_id,
b_id,
)
async def get_case_law_relations(case_law_id: UUID) -> list[dict]:
"""Return all case_law records linked to case_law_id, ordered by date asc."""
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT cl.*, r.relation_type
FROM case_law_relations r
JOIN case_law cl ON cl.id = r.related_id
WHERE r.case_law_id = $1
ORDER BY cl.date ASC NULLS LAST
""",
case_law_id,
)
results = []
for row in rows:
d = dict(row)
relation_type = d.pop("relation_type")
normalized = _row_to_case_law(d)
normalized["relation_type"] = relation_type
results.append(normalized)
return results
async def get_case_law_by_citation(case_number: str) -> dict | None:
pool = await get_pool()
row = await pool.fetchrow(

View File

@@ -438,13 +438,14 @@ async def delete_precedent(case_law_id: UUID | str) -> bool:
async def get_precedent(case_law_id: UUID | str) -> dict | None:
"""Get a precedent with its halachot attached."""
"""Get a precedent with its halachot and related cases attached."""
if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id)
record = await db.get_case_law(case_law_id)
if not record:
return None
record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500)
record["related_cases"] = await db.get_case_law_relations(case_law_id)
return record

View File

@@ -116,6 +116,54 @@ async def precedent_library_get(case_law_id: str) -> str:
return _ok(record)
async def precedent_link_cases(
case_law_id_a: str,
case_law_id_b: str,
relation_type: str = "same_case_chain",
) -> str:
"""קישור שתי פסיקות כקשורות זו לזו (דו-כיווני). idempotent.
Args:
case_law_id_a: UUID של פסיקה ראשונה.
case_law_id_b: UUID של פסיקה שנייה.
relation_type: same_case_chain | overruled_by | distinguished
"""
try:
a = UUID(case_law_id_a)
b = UUID(case_law_id_b)
except ValueError:
return _err("case_law_id לא תקין")
rec_a = await db.get_case_law(a)
rec_b = await db.get_case_law(b)
if not rec_a:
return _err(f"פסיקה {case_law_id_a} לא נמצאה")
if not rec_b:
return _err(f"פסיקה {case_law_id_b} לא נמצאה")
await db.add_case_law_relation(a, b, relation_type)
return _ok({
"linked": True,
"relation_type": relation_type,
"a": {"id": case_law_id_a, "case_number": rec_a.get("case_number"), "court": rec_a.get("court")},
"b": {"id": case_law_id_b, "case_number": rec_b.get("case_number"), "court": rec_b.get("court")},
})
async def precedent_unlink_cases(case_law_id_a: str, case_law_id_b: str) -> str:
"""הסרת קישור בין שתי פסיקות (דו-כיווני).
Args:
case_law_id_a: UUID של פסיקה ראשונה.
case_law_id_b: UUID של פסיקה שנייה.
"""
try:
a = UUID(case_law_id_a)
b = UUID(case_law_id_b)
except ValueError:
return _err("case_law_id לא תקין")
await db.remove_case_law_relation(a, b)
return _ok({"unlinked": True, "a": case_law_id_a, "b": case_law_id_b})
async def precedent_library_delete(case_law_id: str) -> str:
"""מחיקת פסיקה מהקורפוס. cascade: chunks + halachot."""
try:

View File

@@ -11,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { usePrecedent } from "@/lib/api/precedent-library";
import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet";
import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot";
import { RelatedCasesSection } from "@/components/precedents/link-related-dialog";
const PRACTICE_AREA_LABELS: Record<string, string> = {
rishuy_uvniya: "רישוי ובנייה",
@@ -152,6 +153,15 @@ export default function PrecedentDetailPage({
</CardContent>
</Card>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<RelatedCasesSection
caseId={id}
related={data.related_cases ?? []}
/>
</CardContent>
</Card>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<ExtractedHalachotSection halachot={data.halachot ?? []} />

View File

@@ -0,0 +1,222 @@
"use client";
import { useState } from "react";
import { Link2, Loader2, X } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
usePrecedents,
useLinkRelatedCase,
useUnlinkRelatedCase,
RelatedCase,
} from "@/lib/api/precedent-library";
const LEVEL_LABELS: Record<string, string> = {
"עליון": "עליון",
"מנהלי": "מנהלי",
"ועדת_ערר_ארצית": "ארצי",
"ועדת_ערר_מחוזית": "מחוזי",
};
const LEVEL_COLORS: Record<string, string> = {
"עליון": "bg-red-50 text-red-700 border-red-200",
"מנהלי": "bg-orange-50 text-orange-700 border-orange-200",
"ועדת_ערר_ארצית": "bg-blue-50 text-blue-700 border-blue-200",
"ועדת_ערר_מחוזית": "bg-green-50 text-green-700 border-green-200",
};
// ── Search Dialog ────────────────────────────────────────────────────
type DialogProps = {
caseId: string;
currentRelated: RelatedCase[];
open: boolean;
onOpenChange: (open: boolean) => void;
};
function LinkDialog({ caseId, currentRelated, open, onOpenChange }: DialogProps) {
const [query, setQuery] = useState("");
const { mutateAsync: linkCase, isPending } = useLinkRelatedCase(caseId);
const alreadyLinkedIds = new Set([...currentRelated.map((r) => r.id), caseId]);
const { data, isPending: searching } = usePrecedents(
query.length >= 2 ? { search: query, limit: 10 } : {},
);
const candidates = (data?.items ?? []).filter((p) => !alreadyLinkedIds.has(p.id));
async function handleLink(relatedId: string) {
try {
await linkCase({ relatedId });
toast.success("הפסיקות קושרו");
setQuery("");
} catch {
toast.error("שגיאה בקישור");
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg" dir="rtl">
<DialogHeader>
<DialogTitle className="text-navy">קשר החלטה קשורה</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<Input
placeholder="חפש לפי מספר תיק, שם, ערכאה..."
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
dir="rtl"
/>
{query.length >= 2 && (
<div className="space-y-1 max-h-72 overflow-y-auto">
{searching ? (
<div className="flex items-center gap-2 text-ink-muted text-sm py-3">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> מחפש...
</div>
) : candidates.length === 0 ? (
<p className="text-ink-muted text-sm py-3 text-center">לא נמצאו תוצאות</p>
) : (
candidates.map((p) => (
<button
key={p.id}
onClick={() => handleLink(p.id)}
disabled={isPending}
className="w-full text-right flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-rule hover:bg-surface/60 transition-colors disabled:opacity-50"
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-navy truncate">
{p.case_name || p.case_number}
</div>
<div className="text-[0.72rem] text-ink-muted font-mono" dir="ltr">
{p.case_number}
</div>
</div>
{p.precedent_level && (
<Badge
variant="outline"
className={`text-[0.62rem] shrink-0 ${LEVEL_COLORS[p.precedent_level] ?? ""}`}
>
{LEVEL_LABELS[p.precedent_level] ?? p.precedent_level}
</Badge>
)}
</button>
))
)}
</div>
)}
{query.length > 0 && query.length < 2 && (
<p className="text-ink-muted text-xs">הקלד לפחות 2 תווים</p>
)}
</div>
</DialogContent>
</Dialog>
);
}
// ── Related Case Card ────────────────────────────────────────────────
function RelatedCaseCard({ caseId, related }: { caseId: string; related: RelatedCase }) {
const { mutateAsync: unlinkCase, isPending } = useUnlinkRelatedCase(caseId);
async function handleUnlink() {
try {
await unlinkCase(related.id);
toast.success("הקישור הוסר");
} catch {
toast.error("שגיאה בהסרת הקישור");
}
}
return (
<div className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-rule bg-surface">
<a
href={`/precedents/${related.id}`}
className="min-w-0 flex-1 hover:opacity-80 transition-opacity"
>
<div className="text-sm font-medium text-navy truncate">
{related.case_name || related.case_number}
</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{related.precedent_level && (
<Badge
variant="outline"
className={`text-[0.62rem] ${LEVEL_COLORS[related.precedent_level] ?? ""}`}
>
{LEVEL_LABELS[related.precedent_level] ?? related.precedent_level}
</Badge>
)}
{related.court && (
<span className="text-[0.7rem] text-ink-muted truncate">{related.court}</span>
)}
{related.date && (
<span className="text-[0.7rem] text-ink-muted tabular-nums" dir="ltr">
{related.date.slice(0, 10)}
</span>
)}
</div>
</a>
<button
onClick={handleUnlink}
disabled={isPending}
className="p-1 rounded hover:bg-danger-bg hover:text-danger transition-colors text-ink-muted disabled:opacity-40 shrink-0"
title="הסר קישור"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
}
// ── Public section component ─────────────────────────────────────────
type SectionProps = {
caseId: string;
related: RelatedCase[];
};
export function RelatedCasesSection({ caseId, related }: SectionProps) {
const [dialogOpen, setDialogOpen] = useState(false);
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-navy text-sm font-semibold">
החלטות קשורות{related.length > 0 ? ` (${related.length})` : ""}
</h3>
<Button variant="outline" size="sm" onClick={() => setDialogOpen(true)}>
<Link2 className="w-3.5 h-3.5 me-1" /> קשר החלטה
</Button>
</div>
{related.length === 0 ? (
<p className="text-ink-muted text-sm">אין החלטות קשורות עדיין</p>
) : (
<div className="space-y-1.5">
{related.map((r) => (
<RelatedCaseCard key={r.id} caseId={caseId} related={r} />
))}
</div>
)}
<LinkDialog
caseId={caseId}
currentRelated={related}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</div>
);
}

View File

@@ -84,9 +84,20 @@ export type Halacha = {
precedent_level?: string;
};
export type RelatedCase = {
id: string;
case_number: string;
case_name: string;
court: string;
precedent_level: string;
date: string | null;
relation_type: string;
};
export type PrecedentDetail = Precedent & {
full_text: string;
halachot: Halacha[];
related_cases: RelatedCase[];
};
export type SearchHit =
@@ -357,6 +368,40 @@ export function useDeletePrecedent() {
});
}
export function useLinkRelatedCase(caseId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (vars: { relatedId: string; relationType?: string }) =>
apiRequest<{ linked: boolean }>(
`/api/precedent-library/${encodeURIComponent(caseId)}/relations`,
{
method: "POST",
body: {
related_id: vars.relatedId,
relation_type: vars.relationType ?? "same_case_chain",
},
},
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: libraryKeys.detail(caseId) });
},
});
}
export function useUnlinkRelatedCase(caseId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (relatedId: string) =>
apiRequest<{ unlinked: boolean }>(
`/api/precedent-library/${encodeURIComponent(caseId)}/relations/${encodeURIComponent(relatedId)}`,
{ method: "DELETE" },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: libraryKeys.detail(caseId) });
},
});
}
export type PrecedentPatch = Partial<{
case_name: string;
court: string;

View File

@@ -4380,6 +4380,37 @@ async def precedent_library_delete(case_law_id: str):
return {"deleted": True, "case_law_id": case_law_id}
class PrecedentRelationRequest(BaseModel):
related_id: str
relation_type: str = "same_case_chain"
@app.post("/api/precedent-library/{case_law_id}/relations")
async def precedent_add_relation(case_law_id: str, req: PrecedentRelationRequest):
try:
a = UUID(case_law_id)
b = UUID(req.related_id)
except ValueError:
raise HTTPException(400, "case_law_id לא תקין")
if not await db.get_case_law(a):
raise HTTPException(404, "פסיקה לא נמצאה")
if not await db.get_case_law(b):
raise HTTPException(404, f"פסיקה קשורה {req.related_id} לא נמצאה")
await db.add_case_law_relation(a, b, req.relation_type)
return {"linked": True, "case_law_id": case_law_id, "related_id": req.related_id}
@app.delete("/api/precedent-library/{case_law_id}/relations/{related_id}")
async def precedent_remove_relation(case_law_id: str, related_id: str):
try:
a = UUID(case_law_id)
b = UUID(related_id)
except ValueError:
raise HTTPException(400, "case_law_id לא תקין")
await db.remove_case_law_relation(a, b)
return {"unlinked": True, "case_law_id": case_law_id, "related_id": related_id}
# Halacha and metadata extraction are LLM-driven and rely on the local
# `claude` CLI via mcp-server/services/claude_session.py — they CANNOT run
# from this container (no CLI, no claude.ai session). The endpoints below