From 8258f092281fcf52fb275863e71881a9167d1936 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sun, 7 Jun 2026 20:52:13 +0000 Subject: [PATCH] feat(graph): metadata filters + facets (corpus graph PR A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../components/graph/graph-filter-panel.tsx | 125 ++++++++++++++++++ web-ui/src/components/graph/graph-view.tsx | 30 ++++- web-ui/src/lib/api/graph.ts | 30 +++++ web/app.py | 19 +++ web/graph_api.py | 66 ++++++++- 5 files changed, 267 insertions(+), 3 deletions(-) diff --git a/web-ui/src/components/graph/graph-filter-panel.tsx b/web-ui/src/components/graph/graph-filter-panel.tsx index 50cc115..feeb008 100644 --- a/web-ui/src/components/graph/graph-filter-panel.tsx +++ b/web-ui/src/components/graph/graph-filter-panel.tsx @@ -6,6 +6,12 @@ * always on; halacha is Phase 2 and shown disabled to telegraph the roadmap). */ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -18,18 +24,26 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import type { GraphFacets } from "@/lib/api/graph"; export type GraphControls = { practiceArea: string; source: string; minCitations: number; q: string; + court: string; + precedentLevel: string; + chair: string; + district: string; + yearFrom: number; + yearTo: number; showTopics: boolean; showPracticeAreas: boolean; showHalachot: boolean; }; const ALL = "__all__"; +const YEARS = Array.from({ length: 2026 - 1994 + 1 }, (_, i) => 2026 - i); const PRACTICE_AREAS: { value: string; label: string }[] = [ { value: "rishuy_uvniya", label: "רישוי ובנייה" }, @@ -48,9 +62,11 @@ const MIN_CITATIONS = [0, 1, 2, 3, 5]; export function GraphFilterPanel({ controls, onChange, + facets, }: { controls: GraphControls; onChange: (patch: Partial) => void; + facets?: GraphFacets; }) { return ( @@ -126,6 +142,52 @@ export function GraphFilterPanel({ + + + + סינון מתקדם + + + onChange({ court: v })} + /> + onChange({ precedentLevel: v })} + /> + onChange({ chair: v })} + /> + onChange({ district: v })} + /> +
+ onChange({ yearFrom: v })} + /> + onChange({ yearTo: v })} + /> +
+
+
+
+
@@ -152,6 +214,69 @@ export function GraphFilterPanel({ ); } +function FacetSelect({ + label, + value, + options, + onChange, +}: { + label: string; + value: string; + options: string[]; + onChange: (v: string) => void; +}) { + return ( +
+ + +
+ ); +} + +function YearSelect({ + label, + value, + onChange, +}: { + label: string; + value: number; + onChange: (v: number) => void; +}) { + return ( +
+ + +
+ ); +} + function ToggleRow({ label, checked, diff --git a/web-ui/src/components/graph/graph-view.tsx b/web-ui/src/components/graph/graph-view.tsx index dd41104..f714c6b 100644 --- a/web-ui/src/components/graph/graph-view.tsx +++ b/web-ui/src/components/graph/graph-view.tsx @@ -13,6 +13,7 @@ import { type CorpusGraph, type GraphNode, useCorpusGraph, + useGraphFacets, useNodeNeighborhood, } from "@/lib/api/graph"; import { @@ -39,10 +40,17 @@ export function GraphView() { source: "", minCitations: 0, q: "", + court: "", + precedentLevel: "", + chair: "", + district: "", + yearFrom: 0, + yearTo: 0, showTopics: true, showPracticeAreas: true, showHalachot: false, }); + const facets = useGraphFacets().data; const [selectedNode, setSelectedNode] = useState(null); const [focusNodeId, setFocusNodeId] = useState(null); @@ -67,8 +75,26 @@ export function GraphView() { node_types: nodeTypes, limit: NODE_LIMIT, q: debouncedQ, + court: controls.court, + precedent_level: controls.precedentLevel, + chair: controls.chair, + district: controls.district, + year_from: controls.yearFrom, + year_to: controls.yearTo, }), - [controls.practiceArea, controls.source, controls.minCitations, nodeTypes, debouncedQ], + [ + controls.practiceArea, + controls.source, + controls.minCitations, + controls.court, + controls.precedentLevel, + controls.chair, + controls.district, + controls.yearFrom, + controls.yearTo, + nodeTypes, + debouncedQ, + ], ); const isFocused = !!focusNodeId; @@ -106,7 +132,7 @@ export function GraphView() {
- +
{error ? ( diff --git a/web-ui/src/lib/api/graph.ts b/web-ui/src/lib/api/graph.ts index bacbb7f..8bc1f5a 100644 --- a/web-ui/src/lib/api/graph.ts +++ b/web-ui/src/lib/api/graph.ts @@ -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("/api/graph/facets", { signal }), + staleTime: 300_000, + }); +} diff --git a/web/app.py b/web/app.py index c796823..d6d5db5 100644 --- a/web/app.py +++ b/web/app.py @@ -5774,6 +5774,12 @@ async def graph_corpus( min_citations: int = 0, limit: int = graph_api.NODE_CAP_DEFAULT, q: str = "", + court: str = "", + precedent_level: str = "", + chair: str = "", + district: str = "", + year_from: int = 0, + year_to: int = 0, ): """Full corpus graph under the given filters (most-cited nodes survive the cap).""" if practice_area and practice_area not in _PRACTICE_AREAS: @@ -5787,9 +5793,22 @@ async def graph_corpus( min_citations=min_citations, limit=limit, q=q, + court=court, + precedent_level=precedent_level, + chair=chair, + district=district, + year_from=year_from, + year_to=year_to, ) +@app.get("/api/graph/facets", response_model=graph_api.GraphFacets) +async def graph_facets(): + """Distinct filter values (courts / levels / chairs / districts) for the UI.""" + pool = await db.get_pool() + return await graph_api.build_facets(pool) + + @app.get("/api/graph/node/{node_id}/neighborhood", response_model=graph_api.CorpusGraph) async def graph_node_neighborhood(node_id: str, depth: int = 1, node_types: str = ""): """Local-graph focus: the node + its neighbors out to ``depth`` (1-2).""" diff --git a/web/graph_api.py b/web/graph_api.py index 11ee542..1917178 100644 --- a/web/graph_api.py +++ b/web/graph_api.py @@ -60,9 +60,20 @@ class GraphNode(BaseModel): practice_area: str | None = None source_kind: str | None = None # precedents only precedent_level: str | None = None # precedents only + court: str | None = None # precedents only — for color-by / filter + date: str | None = None # precedents only — ISO date, for recency color/filter case_law_id: str | None = None # canonical id for deep-link (precedents) +class GraphFacets(BaseModel): + """Distinct filter values so the UI doesn't hardcode Hebrew enum strings.""" + + courts: list[str] + precedent_levels: list[str] + chairs: list[str] + districts: list[str] + + class GraphEdge(BaseModel): source: str target: str @@ -110,6 +121,8 @@ def _precedent_node(row: asyncpg.Record) -> GraphNode: practice_area=(row["practice_area"] or None), source_kind=(row["source_kind"] or None), precedent_level=(row["precedent_level"] or None), + court=(row["court"] or None), + date=(row["date"].isoformat() if row["date"] else None), case_law_id=str(row["id"]), ) @@ -224,12 +237,19 @@ async def build_corpus_graph( min_citations: int = 0, limit: int = NODE_CAP_DEFAULT, q: str = "", + court: str = "", + precedent_level: str = "", + chair: str = "", + district: str = "", + year_from: int = 0, + year_to: int = 0, ) -> CorpusGraph: """Assemble the full corpus graph under the given filters. The most-cited precedents always survive the cap (``ORDER BY size DESC``), so clipping never hides the structurally important nodes. ``truncated`` + - ``total_available`` let the UI prompt the user to narrow filters. + ``total_available`` let the UI prompt the user to narrow filters. All + filters are applied server-side in the WHERE clause (G5). """ types = normalize_node_types(node_types) cap = max(1, min(int(limit), NODE_CAP_MAX)) @@ -241,6 +261,7 @@ async def build_corpus_graph( + """ SELECT c.id, c.case_number, c.case_name, c.practice_area, c.source_kind, c.precedent_level, + c.court, c.date, COALESCE(p.n, 0) AS size, COUNT(*) OVER () AS total_available FROM case_law c @@ -250,6 +271,12 @@ async def build_corpus_graph( AND COALESCE(p.n, 0) >= $3 AND ($4 = '' OR c.case_number ILIKE '%' || $4 || '%' OR c.case_name ILIKE '%' || $4 || '%') + AND ($6 = '' OR c.court = $6) + AND ($7 = '' OR c.precedent_level = $7) + AND ($8 = '' OR c.chair_name = $8) + AND ($9 = '' OR c.district = $9) + AND ($10 = 0 OR (c.date IS NOT NULL AND EXTRACT(YEAR FROM c.date) >= $10)) + AND ($11 = 0 OR (c.date IS NOT NULL AND EXTRACT(YEAR FROM c.date) <= $11)) ORDER BY COALESCE(p.n, 0) DESC, c.case_number LIMIT $5 """, @@ -258,6 +285,12 @@ async def build_corpus_graph( min_cit, q.strip(), cap, + court, + precedent_level, + chair, + district, + max(0, int(year_from)), + max(0, int(year_to)), ) total_available = int(prec_rows[0]["total_available"]) if prec_rows else 0 @@ -366,6 +399,7 @@ async def build_node_neighborhood( + """ SELECT c.id, c.case_number, c.case_name, c.practice_area, c.source_kind, c.precedent_level, + c.court, c.date, COALESCE(p.n, 0) AS size FROM case_law c LEFT JOIN prec_indeg p ON p.id = c.id @@ -383,3 +417,33 @@ async def build_node_neighborhood( truncated=truncated, total_available=len(nodes), ) + + +async def build_facets(pool: asyncpg.Pool) -> GraphFacets: + """Distinct, non-empty filter values from ``case_law`` for the UI dropdowns. + + Keeps the closed-vs-open-enum problem server-side so the frontend never + hardcodes Hebrew court / chair strings (a UI1 source-of-truth concern). + """ + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT 'court' AS kind, court AS v FROM case_law WHERE court <> '' + UNION + SELECT 'level', precedent_level FROM case_law WHERE precedent_level <> '' + UNION + SELECT 'chair', chair_name FROM case_law WHERE chair_name <> '' + UNION + SELECT 'district', district FROM case_law WHERE district <> '' + ORDER BY 1, 2 + """ + ) + buckets: dict[str, list[str]] = {"court": [], "level": [], "chair": [], "district": []} + for r in rows: + buckets[r["kind"]].append(r["v"]) + return GraphFacets( + courts=buckets["court"], + precedent_levels=buckets["level"], + chairs=buckets["chair"], + districts=buckets["district"], + ) -- 2.49.1