diff --git a/mcp-server/src/legal_mcp/server.py b/mcp-server/src/legal_mcp/server.py index 20f7ff2..abb72fc 100644 --- a/mcp-server/src/legal_mcp/server.py +++ b/mcp-server/src/legal_mcp/server.py @@ -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.""" diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 630c3b0..71adb4c 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -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( diff --git a/mcp-server/src/legal_mcp/services/precedent_library.py b/mcp-server/src/legal_mcp/services/precedent_library.py index 792e18c..8e929e6 100644 --- a/mcp-server/src/legal_mcp/services/precedent_library.py +++ b/mcp-server/src/legal_mcp/services/precedent_library.py @@ -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 diff --git a/mcp-server/src/legal_mcp/tools/precedent_library.py b/mcp-server/src/legal_mcp/tools/precedent_library.py index 2b01b97..f8b470e 100644 --- a/mcp-server/src/legal_mcp/tools/precedent_library.py +++ b/mcp-server/src/legal_mcp/tools/precedent_library.py @@ -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: diff --git a/web-ui/src/app/precedents/[id]/page.tsx b/web-ui/src/app/precedents/[id]/page.tsx index 71e0bbf..d4379bf 100644 --- a/web-ui/src/app/precedents/[id]/page.tsx +++ b/web-ui/src/app/precedents/[id]/page.tsx @@ -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 = { rishuy_uvniya: "רישוי ובנייה", @@ -152,6 +153,15 @@ export default function PrecedentDetailPage({ + + + + + + diff --git a/web-ui/src/components/precedents/link-related-dialog.tsx b/web-ui/src/components/precedents/link-related-dialog.tsx new file mode 100644 index 0000000..f5d0a00 --- /dev/null +++ b/web-ui/src/components/precedents/link-related-dialog.tsx @@ -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 = { + "עליון": "עליון", + "מנהלי": "מנהלי", + "ועדת_ערר_ארצית": "ארצי", + "ועדת_ערר_מחוזית": "מחוזי", +}; + +const LEVEL_COLORS: Record = { + "עליון": "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 ( + + + + קשר החלטה קשורה + + +
+ setQuery(e.target.value)} + autoFocus + dir="rtl" + /> + + {query.length >= 2 && ( +
+ {searching ? ( +
+ מחפש... +
+ ) : candidates.length === 0 ? ( +

לא נמצאו תוצאות

+ ) : ( + candidates.map((p) => ( + + )) + )} +
+ )} + + {query.length > 0 && query.length < 2 && ( +

הקלד לפחות 2 תווים

+ )} +
+
+
+ ); +} + +// ── 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 ( + + ); +} + +// ── Public section component ───────────────────────────────────────── + +type SectionProps = { + caseId: string; + related: RelatedCase[]; +}; + +export function RelatedCasesSection({ caseId, related }: SectionProps) { + const [dialogOpen, setDialogOpen] = useState(false); + + return ( +
+
+

+ החלטות קשורות{related.length > 0 ? ` (${related.length})` : ""} +

+ +
+ + {related.length === 0 ? ( +

אין החלטות קשורות עדיין

+ ) : ( +
+ {related.map((r) => ( + + ))} +
+ )} + + +
+ ); +} diff --git a/web-ui/src/lib/api/precedent-library.ts b/web-ui/src/lib/api/precedent-library.ts index c1e2803..a91eac5 100644 --- a/web-ui/src/lib/api/precedent-library.ts +++ b/web-ui/src/lib/api/precedent-library.ts @@ -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; diff --git a/web/app.py b/web/app.py index bb7eb33..1f07e36 100644 --- a/web/app.py +++ b/web/app.py @@ -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