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

Merged
chaim merged 1 commits from worktree-graph-metadata into main 2026-06-07 20:52:36 +00:00
5 changed files with 267 additions and 3 deletions

View File

@@ -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<GraphControls>) => void;
facets?: GraphFacets;
}) {
return (
<Card className="bg-surface border-rule shadow-sm w-72 shrink-0 overflow-y-auto">
@@ -126,6 +142,52 @@ export function GraphFilterPanel({
</Select>
</div>
<Accordion type="single" collapsible className="border-0">
<AccordionItem value="advanced" className="border-0">
<AccordionTrigger className="py-1 text-xs text-ink-muted hover:no-underline">
סינון מתקדם
</AccordionTrigger>
<AccordionContent className="space-y-4 pt-2">
<FacetSelect
label="בית משפט / ערכאה"
value={controls.court}
options={facets?.courts ?? []}
onChange={(v) => onChange({ court: v })}
/>
<FacetSelect
label="דרגת סמכות"
value={controls.precedentLevel}
options={facets?.precedent_levels ?? []}
onChange={(v) => onChange({ precedentLevel: v })}
/>
<FacetSelect
label="יו״ר"
value={controls.chair}
options={facets?.chairs ?? []}
onChange={(v) => onChange({ chair: v })}
/>
<FacetSelect
label="מחוז"
value={controls.district}
options={facets?.districts ?? []}
onChange={(v) => onChange({ district: v })}
/>
<div className="grid grid-cols-2 gap-2">
<YearSelect
label="משנה"
value={controls.yearFrom}
onChange={(v) => onChange({ yearFrom: v })}
/>
<YearSelect
label="עד שנה"
value={controls.yearTo}
onChange={(v) => onChange({ yearTo: v })}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<Separator />
<div className="space-y-3">
@@ -152,6 +214,69 @@ export function GraphFilterPanel({
);
}
function FacetSelect({
label,
value,
options,
onChange,
}: {
label: string;
value: string;
options: string[];
onChange: (v: string) => void;
}) {
return (
<div className="space-y-1.5">
<Label className="text-xs text-ink-muted">{label}</Label>
<Select value={value || ALL} onValueChange={(v) => onChange(v === ALL ? "" : v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>הכל</SelectItem>
{options.map((o) => (
<SelectItem key={o} value={o}>
{o}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function YearSelect({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (v: number) => void;
}) {
return (
<div className="space-y-1.5">
<Label className="text-xs text-ink-muted">{label}</Label>
<Select
value={value ? String(value) : ALL}
onValueChange={(v) => onChange(v === ALL ? 0 : Number(v))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}></SelectItem>
{YEARS.map((y) => (
<SelectItem key={y} value={String(y)}>
{y}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function ToggleRow({
label,
checked,

View File

@@ -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<GraphNode | null>(null);
const [focusNodeId, setFocusNodeId] = useState<string | null>(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() {
</div>
<div className="flex gap-4 h-[calc(100vh-320px)] min-h-[560px]">
<GraphFilterPanel controls={controls} onChange={onChange} />
<GraphFilterPanel controls={controls} onChange={onChange} facets={facets} />
<div className="relative flex-1 rounded-lg border border-rule bg-surface overflow-hidden">
{error ? (

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,
});
}

View File

@@ -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)."""

View File

@@ -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"],
)