Merge pull request 'feat(graph): in-app corpus citation graph (/graph) — Phase 1' (#113) from worktree-corpus-graph into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m44s

This commit was merged in pull request #113.
This commit is contained in:
2026-06-07 18:52:01 +00:00
11 changed files with 1651 additions and 0 deletions

111
web-ui/src/lib/api/graph.ts Normal file
View 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,
});
}