feat(graph): metadata filters + facets (corpus graph PR A)

Adds legal-metadata filtering and the payload to color by it (foundation for
the color-by selector in the analytics PR).

Backend (web/graph_api.py, web/app.py) — read-only, G2:
- GraphNode += court, date (ISO) — precedents carry them for filter/color-by.
- build_corpus_graph += server-side WHERE filters (G5): court, precedent_level,
  chair, district, year_from, year_to (EXTRACT(YEAR FROM date)). Neighborhood
  query also selects court/date.
- New GET /api/graph/facets (response_model GraphFacets, UI2) → distinct
  courts/levels/chairs/districts so the UI doesn't hardcode Hebrew strings.

Frontend:
- graph.ts: GraphNode += court/date; GraphFilters += the six params;
  buildParams; useGraphFacets() hook.
- graph-filter-panel: an "advanced" Accordion with court/precedent_level/chair/
  district Selects (from facets) + year-from/year-to Selects.
- graph-view: new controls wired into filters; facets fetched and passed down.

Verified read-only against the live DB (precedent_level=עליון&year_from=2015
filters correctly; facets populated: 36 courts / 3 levels / 19 chairs / 4
districts). web-ui build + lint pass.

Invariants: G2 (SELECT-only via db.get_pool), G5 (filters server-side),
UI2 (explicit response_models). api:types to be regenerated post-deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 20:52:13 +00:00
parent bcd5fd5f8d
commit 8258f09228
5 changed files with 267 additions and 3 deletions

View File

@@ -32,9 +32,18 @@ export type GraphNode = {
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;
};
export type GraphFacets = {
courts: string[];
precedent_levels: string[];
chairs: string[];
districts: string[];
};
export type GraphEdge = {
source: string;
target: string;
@@ -57,6 +66,12 @@ export type GraphFilters = {
min_citations?: number;
limit?: number;
q?: string;
court?: string;
precedent_level?: string;
chair?: string;
district?: string;
year_from?: number;
year_to?: number;
};
export const graphKeys = {
@@ -74,6 +89,12 @@ function buildParams(f: GraphFilters): string {
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));
return p.toString();
}
@@ -109,3 +130,12 @@ export function useNodeNeighborhood(
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,
});
}