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:
111
web-ui/src/lib/api/graph.ts
Normal file
111
web-ui/src/lib/api/graph.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Corpus graph hooks — feed the /graph page (the in-app, Obsidian-graph-view-
|
||||
* like network of the precedent corpus).
|
||||
*
|
||||
* The types below mirror web/graph_api.py (CorpusGraph / GraphNode / GraphEdge).
|
||||
* They are hand-declared for now because `npm run api:types` reads the PROD
|
||||
* OpenAPI schema, which won't expose /api/graph/* until this PR is deployed.
|
||||
* After deploy, run `npm run api:types` and these can be swapped for the
|
||||
* generated types (UI1) — same chicken-and-egg pattern documented in cases.ts.
|
||||
*
|
||||
* Read-only projection of the live corpus (G2): no parallel store, no drift.
|
||||
*/
|
||||
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
export type GraphNodeType = "precedent" | "halacha" | "topic" | "practice_area";
|
||||
|
||||
export type GraphEdgeType =
|
||||
| "cites"
|
||||
| "same_chain"
|
||||
| "tagged"
|
||||
| "in_area"
|
||||
| "corroborates"
|
||||
| "equivalent";
|
||||
|
||||
export type GraphNode = {
|
||||
id: string;
|
||||
type: GraphNodeType;
|
||||
label: string;
|
||||
size: number;
|
||||
practice_area: string | null;
|
||||
source_kind: string | null;
|
||||
precedent_level: string | null;
|
||||
case_law_id: string | null;
|
||||
};
|
||||
|
||||
export type GraphEdge = {
|
||||
source: string;
|
||||
target: string;
|
||||
type: GraphEdgeType;
|
||||
treatment: string | null;
|
||||
weight: number | null;
|
||||
};
|
||||
|
||||
export type CorpusGraph = {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
truncated: boolean;
|
||||
total_available: number;
|
||||
};
|
||||
|
||||
export type GraphFilters = {
|
||||
practice_area?: string;
|
||||
source?: string;
|
||||
node_types?: string;
|
||||
min_citations?: number;
|
||||
limit?: number;
|
||||
q?: string;
|
||||
};
|
||||
|
||||
export const graphKeys = {
|
||||
all: ["graph"] as const,
|
||||
corpus: (f: GraphFilters) => [...graphKeys.all, "corpus", f] as const,
|
||||
neighborhood: (id: string, depth: number, nodeTypes: string) =>
|
||||
[...graphKeys.all, "neighborhood", id, depth, nodeTypes] as const,
|
||||
};
|
||||
|
||||
function buildParams(f: GraphFilters): string {
|
||||
const p = new URLSearchParams();
|
||||
if (f.practice_area) p.set("practice_area", f.practice_area);
|
||||
if (f.source) p.set("source", f.source);
|
||||
if (f.node_types) p.set("node_types", f.node_types);
|
||||
if (f.min_citations != null) p.set("min_citations", String(f.min_citations));
|
||||
if (f.limit != null) p.set("limit", String(f.limit));
|
||||
if (f.q) p.set("q", f.q.trim());
|
||||
return p.toString();
|
||||
}
|
||||
|
||||
/** Full corpus graph under the given filters. Disabled while a node is focused. */
|
||||
export function useCorpusGraph(filters: GraphFilters, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: graphKeys.corpus(filters),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<CorpusGraph>(`/api/graph/corpus?${buildParams(filters)}`, { signal }),
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
/** Local-graph: the focused node + its neighbors out to `depth` (1-2). */
|
||||
export function useNodeNeighborhood(
|
||||
nodeId: string | null,
|
||||
depth = 1,
|
||||
nodeTypes = "",
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: graphKeys.neighborhood(nodeId ?? "", depth, nodeTypes),
|
||||
queryFn: ({ signal }) => {
|
||||
const p = new URLSearchParams({ depth: String(depth) });
|
||||
if (nodeTypes) p.set("node_types", nodeTypes);
|
||||
return apiRequest<CorpusGraph>(
|
||||
`/api/graph/node/${encodeURIComponent(nodeId as string)}/neighborhood?${p.toString()}`,
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
enabled: !!nodeId,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user