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:
179
web-ui/src/components/graph/graph-view.tsx
Normal file
179
web-ui/src/components/graph/graph-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user