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,179 @@
"use client";
/**
* Corpus graph orchestrator. Owns filter + selection state, decides whether to
* render the full graph or a focused node neighborhood (the Obsidian "local
* graph"), and wires the filter sidebar, canvas, and node detail panel.
*/
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
type CorpusGraph,
type GraphNode,
useCorpusGraph,
useNodeNeighborhood,
} from "@/lib/api/graph";
import {
type GraphControls,
GraphFilterPanel,
} from "@/components/graph/graph-filter-panel";
import { GraphCanvas } from "@/components/graph/graph-canvas";
import { GraphNodePanel } from "@/components/graph/graph-node-panel";
const NODE_LIMIT = 400;
function useDebouncedValue<T>(value: T, ms: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), ms);
return () => clearTimeout(id);
}, [value, ms]);
return debounced;
}
export function GraphView() {
const [controls, setControls] = useState<GraphControls>({
practiceArea: "",
source: "",
minCitations: 0,
q: "",
showTopics: true,
showPracticeAreas: true,
showHalachot: false,
});
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
const onChange = (patch: Partial<GraphControls>) =>
setControls((c) => ({ ...c, ...patch }));
const nodeTypes = useMemo(() => {
const t = ["precedent"];
if (controls.showTopics) t.push("topic");
if (controls.showPracticeAreas) t.push("practice_area");
if (controls.showHalachot) t.push("halacha");
return t.join(",");
}, [controls.showTopics, controls.showPracticeAreas, controls.showHalachot]);
const debouncedQ = useDebouncedValue(controls.q, 350);
const filters = useMemo(
() => ({
practice_area: controls.practiceArea,
source: controls.source,
min_citations: controls.minCitations,
node_types: nodeTypes,
limit: NODE_LIMIT,
q: debouncedQ,
}),
[controls.practiceArea, controls.source, controls.minCitations, nodeTypes, debouncedQ],
);
const isFocused = !!focusNodeId;
const full = useCorpusGraph(filters, !isFocused);
const neighborhood = useNodeNeighborhood(focusNodeId, 1, nodeTypes);
const active = isFocused ? neighborhood : full;
const data: CorpusGraph | undefined = active.data;
const error = active.error as Error | undefined;
const handleNodeClick = (node: GraphNode) => {
setSelectedNode(node);
setFocusNodeId(node.id);
};
const backToFull = () => {
setFocusNodeId(null);
setSelectedNode(null);
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 text-xs text-ink-muted">
<span>
{data
? `${data.nodes.length} נקודות · ${data.edges.length} קשרים`
: "—"}
</span>
{!isFocused && full.data?.truncated && (
<span className="text-gold-deep">
מוצגות {full.data.nodes.length} הנקודות המצוטטות ביותר מתוך{" "}
{full.data.total_available} צמצמו את הסינון כדי לראות פחות
</span>
)}
</div>
<div className="flex gap-4 h-[calc(100vh-320px)] min-h-[560px]">
<GraphFilterPanel controls={controls} onChange={onChange} />
<div className="relative flex-1 rounded-lg border border-rule bg-surface overflow-hidden">
{error ? (
<div className="grid h-full place-items-center p-6 text-center">
<div className="space-y-2">
<p className="text-red-700 font-medium m-0">שגיאה בטעינת הגרף</p>
<p className="text-ink-muted text-sm m-0">{error.message}</p>
<Button variant="outline" size="sm" onClick={() => active.refetch()}>
נסה שוב
</Button>
</div>
</div>
) : active.isLoading && !data ? (
<div className="grid h-full place-items-center text-sm text-ink-muted">
טוען גרף
</div>
) : data && data.nodes.length === 0 ? (
<div className="grid h-full place-items-center text-sm text-ink-muted">
אין נקודות התואמות לסינון.
</div>
) : (
<GraphCanvas
data={data}
selectedId={selectedNode?.id ?? null}
onNodeClick={handleNodeClick}
/>
)}
{isFocused && (
<Button
variant="outline"
size="sm"
onClick={backToFull}
className="absolute top-3 start-3 bg-surface/90 backdrop-blur"
>
חזרה לגרף המלא
</Button>
)}
<Legend />
</div>
{selectedNode && (
<GraphNodePanel node={selectedNode} onClose={() => setSelectedNode(null)} />
)}
</div>
</div>
);
}
function Legend() {
const items = [
{ color: "#1e3a5f", label: "פסיקה" },
{ color: "#a97d3a", label: "נושא" },
{ color: "#475569", label: "תחום" },
];
return (
<div className="absolute bottom-3 end-3 flex flex-col gap-1 rounded-md bg-surface/85 backdrop-blur px-3 py-2 text-xs text-ink-muted">
{items.map((i) => (
<div key={i.label} className="flex items-center gap-2">
<span
className="inline-block size-2.5 rounded-full"
style={{ backgroundColor: i.color }}
/>
{i.label}
</div>
))}
</div>
);
}