Enables the previously-disabled "הלכות" toggle. Each approved/published halacha of a displayed precedent becomes a hal:<id> node linked to its parent precedent (extracted_from); two cross-rule edges when both endpoints are in view: corroborates (a later ruling cites the rule — halacha_citation_corroboration) and equivalent (same principle from another committee — equivalent_halachot). Node size = corroboration in-degree. Backend (web/graph_api.py — read-only, G2): - _halacha_nodes_and_edges(): halachot WHERE case_law_id in view AND review_status IN (approved, published), LIMIT 600; rule_type carried in the source_kind slot, rule_statement in note. Wired into both build functions (gated via node_types). Metrics still exclude halacha edges (only cites/ precedent-typed feed PageRank). Validated: 185 halachot on the top-30 precedents; 20 corroboration + 5 equivalent edges in the corpus. Frontend: - graph.ts: GraphEdgeType += extracted_from. - graph-filter-panel: "הלכות" toggle enabled (was disabled "שלב ב׳"). - graph-canvas: amber halacha nodes; edge colours — extracted_from (faint amber), corroborates (amber), equivalent (violet). - graph-node-panel: halacha branch — אזכורים + סוג כלל + rule text; "open in library" deep-links to the parent precedent. - graph-view: halacha added to node + edge legends. web-ui build + lint pass. Invariants: G2 (SELECT-only), UI2 (no model change — reuses note/source_kind/case_law_id slots). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
4.7 KiB
TypeScript
159 lines
4.7 KiB
TypeScript
/**
|
||
* 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<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,
|
||
});
|
||
}
|
||
|
||
/** Distinct filter values (courts / levels / chairs / districts) for dropdowns. */
|
||
export function useGraphFacets() {
|
||
return useQuery({
|
||
queryKey: [...graphKeys.all, "facets"] as const,
|
||
queryFn: ({ signal }) => apiRequest<GraphFacets>("/api/graph/facets", { signal }),
|
||
staleTime: 300_000,
|
||
});
|
||
}
|