Files
legal-ai/web-ui/src/lib/api/graph.ts
Chaim ef21cb93e5 feat(graph): halacha (rule) layer (corpus graph — closes Phase 2)
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>
2026-06-08 05:13:09 +00:00

159 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 01, only when metrics requested
betweenness: number | null; // normalized 01
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,
});
}