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

@@ -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 ? (