Files
legal-ai/web-ui/src/components/graph/graph-node-panel.tsx
Chaim c80e4ce8ff feat(graph): in-app corpus citation graph (/graph) — Phase 1
Native, Obsidian-graph-view-like network of the precedent corpus, rendered
in web-ui from a read-only projection of the live DB. Replaces the idea of
exporting to an external Obsidian vault (which would be a parallel, drifting
copy of the corpus — the exact root cause G2 forbids).

The graph edges already existed in the data model; this only surfaces them:
nodes = precedents (case_law) + synthesized topic/practice-area hubs;
edges = cites (precedent_internal_citations) + same_chain (case_law_relations)
+ tagged/in_area (subject_tags / practice_area membership). Node size =
incoming-citation count (index-backed GROUP BY on idx_pic_target). Click a
node → local-graph neighborhood focus; panel deep-links to /precedents/[id].

Backend (read-only, SELECT only — G2):
- web/graph_api.py — Pydantic models (CorpusGraph/GraphNode/GraphEdge, so
  OpenAPI emits real types — UI2) + SQL assembly over the shared db.get_pool().
- web/app.py — GET /api/graph/corpus, GET /api/graph/node/{id}/neighborhood,
  both with explicit response_model. practice_area validated against the
  closed enum (G5); both endpoints write nothing.

Frontend:
- react-force-graph-2d (canvas/d3-force), loaded via next/dynamic ssr:false.
- /graph page + nav entry; graph.ts TanStack hooks; filter panel (practice_area
  / source / min-citations / search / node-type toggles), node detail panel,
  hover+selection neighborhood highlight. Explicit error handling (UI4).

Not a retrieval path (03-retrieval): returns graph topology, never ranked
search results. Halacha nodes + corroboration/equivalence edges are Phase 2,
already gated behind the node_types param (no contract change needed).

SQL validated read-only against the live DB (142 precedents, 85 resolved
citations, JSONB tag expansion, ANY(uuid[]) edge + BFS queries). web-ui lint
+ build pass; /graph in the route table.

Invariants: keeps G2 (single source of truth — live projection, no parallel
store), G5 (corpus separation filtered server-side), UI2 (response models),
UI4 (no swallowed UI errors).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:50:56 +00:00

106 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* Side panel shown when a node is selected. For precedent/halacha nodes it
* deep-links into the existing precedent library (/precedents/[id]) so the
* graph is a navigation surface, not a dead-end visualization.
*/
import Link from "next/link";
import { ExternalLink, X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import type { GraphNode } from "@/lib/api/graph";
const TYPE_LABELS: Record<string, string> = {
precedent: "פסיקה",
halacha: "הלכה",
topic: "נושא",
practice_area: "תחום",
};
const PA_LABELS: Record<string, string> = {
rishuy_uvniya: "רישוי ובנייה",
betterment_levy: "היטל השבחה",
compensation_197: "פיצויים (ס׳ 197)",
appeals_committee: "ועדת ערר",
};
const SOURCE_LABELS: Record<string, string> = {
external_upload: "פסיקה חיצונית",
internal_committee: "החלטת ועדה",
cited_only: "מוזכר בלבד",
nevo_seed: "נבו",
};
export function GraphNodePanel({
node,
onClose,
}: {
node: GraphNode;
onClose: () => void;
}) {
const isPrecedentLike = node.type === "precedent" || node.type === "halacha";
return (
<Card className="bg-surface border-rule shadow-sm w-80 shrink-0 overflow-y-auto">
<CardContent className="space-y-4 p-4">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1">
<Badge variant="outline" className="text-[0.65rem]">
{TYPE_LABELS[node.type] ?? node.type}
</Badge>
<h2 className="text-navy text-base leading-snug m-0">{node.label}</h2>
</div>
<Button
variant="ghost"
size="icon"
onClick={onClose}
aria-label="סגור"
className="shrink-0"
>
<X className="size-4" />
</Button>
</div>
<dl className="space-y-2 text-sm">
{isPrecedentLike && (
<Row label="ציטוטים נכנסים" value={String(node.size)} />
)}
{node.practice_area && (
<Row label="תחום" value={PA_LABELS[node.practice_area] ?? node.practice_area} />
)}
{node.source_kind && (
<Row label="מקור" value={SOURCE_LABELS[node.source_kind] ?? node.source_kind} />
)}
{node.precedent_level && <Row label="דרגה" value={node.precedent_level} />}
{!isPrecedentLike && (
<p className="text-ink-muted text-xs leading-relaxed m-0">
לחיצה על נקודה זו מתמקדת בשכניה כל הפסיקות המשויכות אליה.
</p>
)}
</dl>
{isPrecedentLike && node.case_law_id && (
<Button asChild variant="outline" className="w-full">
<Link href={`/precedents/${node.case_law_id}`}>
<ExternalLink className="size-4 me-2" />
פתח בספריית הפסיקה
</Link>
</Button>
)}
</CardContent>
</Card>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-baseline justify-between gap-3">
<dt className="text-ink-muted text-xs shrink-0">{label}</dt>
<dd className="text-ink text-end m-0">{value}</dd>
</div>
);
}