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>
This commit is contained in:
2026-06-07 18:50:56 +00:00
parent acb8e2c206
commit c80e4ce8ff
11 changed files with 1651 additions and 0 deletions

View File

@@ -0,0 +1,233 @@
"use client";
/**
* Force-directed canvas for the corpus graph (Obsidian-graph-view-like).
*
* Uses react-force-graph-2d (HTML5 canvas + d3-force) — the live physics give
* the "alive" Obsidian feel, and `nodeCanvasObject` gives full control over
* node radius (size = citation count) and Hebrew/RTL label rendering. Loaded
* via next/dynamic with ssr:false because the canvas needs the DOM (Next 16).
*/
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CorpusGraph, GraphNode } from "@/lib/api/graph";
// react-force-graph-2d's default export is a forwardRef component; it touches
// `window` at module load, so it must be client-only. Cast to a loose type:
// next/dynamic's wrapper doesn't surface the lib's ref/prop types cleanly, and
// the node/link callbacks below are independently typed (FGNode/FGLink).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ForceGraph2D: any = dynamic(() => import("react-force-graph-2d"), {
ssr: false,
loading: () => (
<div className="grid h-full w-full place-items-center text-sm text-ink-muted">
טוען גרף
</div>
),
});
// Internal shapes the canvas works with (force-graph mutates these).
type FGNode = GraphNode & { x?: number; y?: number };
type FGLink = {
source: string;
target: string;
type: string;
treatment: string | null;
};
const NODE_COLORS: Record<string, string> = {
precedent: "#1e3a5f", // navy
halacha: "#b45309", // amber
topic: "#a97d3a", // gold — hubs stand out
practice_area: "#475569", // slate
};
const TREATMENT_COLORS: Record<string, string> = {
overrule: "#b91c1c",
overruled: "#b91c1c",
distinguish: "#d97706",
distinguished: "#d97706",
};
function nodeRadius(n: GraphNode): number {
if (n.type === "topic" || n.type === "practice_area") return 5;
return Math.min(22, 3 + Math.sqrt(Math.max(0, n.size)) * 1.7);
}
function useElementSize<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const r = entries[0]?.contentRect;
if (r) setSize({ width: Math.floor(r.width), height: Math.floor(r.height) });
});
ro.observe(el);
return () => ro.disconnect();
}, []);
return { ref, size };
}
export function GraphCanvas({
data,
selectedId,
onNodeClick,
}: {
data: CorpusGraph | undefined;
selectedId: string | null;
onNodeClick: (node: GraphNode) => void;
}) {
const { ref, size } = useElementSize<HTMLDivElement>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fgRef = useRef<any>(null);
const [hoverId, setHoverId] = useState<string | null>(null);
// Fresh objects each time `data` changes so force-graph can attach x/y
// without mutating the TanStack Query cache.
const graphData = useMemo(() => {
if (!data) return { nodes: [] as FGNode[], links: [] as FGLink[] };
return {
nodes: data.nodes.map((n) => ({ ...n })) as FGNode[],
links: data.edges.map((e) => ({
source: e.source,
target: e.target,
type: e.type,
treatment: e.treatment,
})) as FGLink[],
};
}, [data]);
// Adjacency for hover/selection highlighting (computed once per data change).
const adjacency = useMemo(() => {
const map = new Map<string, Set<string>>();
for (const e of graphData.links) {
if (!map.has(e.source)) map.set(e.source, new Set());
if (!map.has(e.target)) map.set(e.target, new Set());
map.get(e.source)!.add(e.target);
map.get(e.target)!.add(e.source);
}
return map;
}, [graphData]);
const activeId = hoverId ?? selectedId;
const activeNeighbors = activeId ? adjacency.get(activeId) : undefined;
const isDimmed = useCallback(
(id: string) => {
if (!activeId) return false;
if (id === activeId) return false;
return !(activeNeighbors && activeNeighbors.has(id));
},
[activeId, activeNeighbors],
);
// Zoom-to-fit once physics settle.
const handleEngineStop = useCallback(() => {
fgRef.current?.zoomToFit?.(400, 60);
}, []);
const drawNode = useCallback(
(node: FGNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
const r = nodeRadius(node);
const dimmed = isDimmed(node.id);
const color = NODE_COLORS[node.type] ?? "#64748b";
ctx.globalAlpha = dimmed ? 0.18 : 1;
ctx.beginPath();
ctx.arc(node.x ?? 0, node.y ?? 0, r, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
if (node.id === activeId) {
ctx.lineWidth = 2 / globalScale;
ctx.strokeStyle = "#a97d3a";
ctx.stroke();
}
// Labels: hubs always; precedents when zoomed in, important, or active.
const isHub = node.type === "topic" || node.type === "practice_area";
const showLabel =
!dimmed &&
(isHub || node.id === activeId || node.size >= 3 || globalScale >= 1.6);
if (showLabel && node.label) {
const fontSize = Math.max(2.5, (isHub ? 4.5 : 3.6) / Math.sqrt(globalScale)) +
(isHub ? 1 : 0);
ctx.font = `${fontSize + 6}px Heebo, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.direction = "rtl";
ctx.fillStyle = isHub ? "#7a5a26" : "#1a1a2e";
const label =
node.label.length > 28 ? `${node.label.slice(0, 27)}` : node.label;
ctx.fillText(label, node.x ?? 0, (node.y ?? 0) + r + 1);
}
ctx.globalAlpha = 1;
},
[activeId, isDimmed],
);
const drawPointerArea = useCallback(
(node: FGNode, color: string, ctx: CanvasRenderingContext2D) => {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(node.x ?? 0, node.y ?? 0, nodeRadius(node) + 2, 0, 2 * Math.PI);
ctx.fill();
},
[],
);
const linkColor = useCallback(
(link: FGLink) => {
const s = typeof link.source === "object" ? (link.source as FGNode).id : link.source;
const t = typeof link.target === "object" ? (link.target as FGNode).id : link.target;
const active = activeId && (s === activeId || t === activeId);
if (active) return "rgba(169,125,58,0.85)";
if (activeId) return "rgba(120,130,150,0.06)";
if (link.treatment && TREATMENT_COLORS[link.treatment]) {
return TREATMENT_COLORS[link.treatment];
}
if (link.type === "tagged" || link.type === "in_area") {
return "rgba(169,125,58,0.16)";
}
return "rgba(80,90,110,0.22)";
},
[activeId],
);
if (!size.width && !data) {
return <div ref={ref} className="h-full w-full" />;
}
return (
<div ref={ref} className="h-full w-full">
{size.width > 0 && (
<ForceGraph2D
ref={fgRef}
width={size.width}
height={size.height}
graphData={graphData}
nodeId="id"
nodeLabel={(n: FGNode) => n.label}
nodeCanvasObject={drawNode}
nodePointerAreaPaint={drawPointerArea}
linkColor={linkColor}
linkWidth={(l: FGLink) => {
const s = typeof l.source === "object" ? (l.source as FGNode).id : l.source;
const t = typeof l.target === "object" ? (l.target as FGNode).id : l.target;
return activeId && (s === activeId || t === activeId) ? 1.6 : 0.6;
}}
linkDirectionalArrowLength={(l: FGLink) => (l.type === "cites" ? 2.4 : 0)}
linkDirectionalArrowRelPos={1}
onNodeClick={(n: FGNode) => onNodeClick(n)}
onNodeHover={(n: FGNode | null) => setHoverId(n?.id ?? null)}
onEngineStop={handleEngineStop}
cooldownTicks={120}
warmupTicks={20}
/>
)}
</div>
);
}