Merge pull request 'feat(graph): metadata filters + facets (corpus graph PR A)' (#126) from worktree-graph-metadata into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m1s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m1s
This commit was merged in pull request #126.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
19
web/app.py
19
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)."""
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user