/** * 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" | "gap" | "digest"; export type GraphEdgeType = | "cites" | "same_chain" | "tagged" | "in_area" | "corroborates" | "equivalent" | "covers" | "extracted_from"; export type GraphNode = { id: string; type: GraphNodeType; label: string; size: number; practice_area: string | null; source_kind: string | null; precedent_level: string | null; court: string | null; date: string | null; // ISO date case_law_id: string | null; pagerank: number | null; // normalized 0–1, only when metrics requested betweenness: number | null; // normalized 0–1 community: number | null; // dense cluster id, 0 = largest gap_status: string | null; // gap nodes only — open|uploaded|closed|irrelevant missing_precedent_id: string | null; // gap nodes only note: string | null; // digest nodes only — the holding line digest_id: string | null; // digest nodes only }; export type GraphFacets = { courts: string[]; precedent_levels: string[]; chairs: string[]; districts: string[]; }; 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; court?: string; precedent_level?: string; chair?: string; district?: string; year_from?: number; year_to?: number; metrics?: boolean; }; 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()); if (f.court) p.set("court", f.court); if (f.precedent_level) p.set("precedent_level", f.precedent_level); if (f.chair) p.set("chair", f.chair); if (f.district) p.set("district", f.district); if (f.year_from) p.set("year_from", String(f.year_from)); if (f.year_to) p.set("year_to", String(f.year_to)); if (f.metrics) p.set("metrics", "true"); 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(`/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( `/api/graph/node/${encodeURIComponent(nodeId as string)}/neighborhood?${p.toString()}`, { signal }, ); }, enabled: !!nodeId, staleTime: 30_000, }); } /** Distinct filter values (courts / levels / chairs / districts) for dropdowns. */ export function useGraphFacets() { return useQuery({ queryKey: [...graphKeys.all, "facets"] as const, queryFn: ({ signal }) => apiRequest("/api/graph/facets", { signal }), staleTime: 300_000, }); }