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).
|
* 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 { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -18,18 +24,26 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import type { GraphFacets } from "@/lib/api/graph";
|
||||||
|
|
||||||
export type GraphControls = {
|
export type GraphControls = {
|
||||||
practiceArea: string;
|
practiceArea: string;
|
||||||
source: string;
|
source: string;
|
||||||
minCitations: number;
|
minCitations: number;
|
||||||
q: string;
|
q: string;
|
||||||
|
court: string;
|
||||||
|
precedentLevel: string;
|
||||||
|
chair: string;
|
||||||
|
district: string;
|
||||||
|
yearFrom: number;
|
||||||
|
yearTo: number;
|
||||||
showTopics: boolean;
|
showTopics: boolean;
|
||||||
showPracticeAreas: boolean;
|
showPracticeAreas: boolean;
|
||||||
showHalachot: boolean;
|
showHalachot: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALL = "__all__";
|
const ALL = "__all__";
|
||||||
|
const YEARS = Array.from({ length: 2026 - 1994 + 1 }, (_, i) => 2026 - i);
|
||||||
|
|
||||||
const PRACTICE_AREAS: { value: string; label: string }[] = [
|
const PRACTICE_AREAS: { value: string; label: string }[] = [
|
||||||
{ value: "rishuy_uvniya", label: "רישוי ובנייה" },
|
{ value: "rishuy_uvniya", label: "רישוי ובנייה" },
|
||||||
@@ -48,9 +62,11 @@ const MIN_CITATIONS = [0, 1, 2, 3, 5];
|
|||||||
export function GraphFilterPanel({
|
export function GraphFilterPanel({
|
||||||
controls,
|
controls,
|
||||||
onChange,
|
onChange,
|
||||||
|
facets,
|
||||||
}: {
|
}: {
|
||||||
controls: GraphControls;
|
controls: GraphControls;
|
||||||
onChange: (patch: Partial<GraphControls>) => void;
|
onChange: (patch: Partial<GraphControls>) => void;
|
||||||
|
facets?: GraphFacets;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className="bg-surface border-rule shadow-sm w-72 shrink-0 overflow-y-auto">
|
<Card className="bg-surface border-rule shadow-sm w-72 shrink-0 overflow-y-auto">
|
||||||
@@ -126,6 +142,52 @@ export function GraphFilterPanel({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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 />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-3">
|
<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({
|
function ToggleRow({
|
||||||
label,
|
label,
|
||||||
checked,
|
checked,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type CorpusGraph,
|
type CorpusGraph,
|
||||||
type GraphNode,
|
type GraphNode,
|
||||||
useCorpusGraph,
|
useCorpusGraph,
|
||||||
|
useGraphFacets,
|
||||||
useNodeNeighborhood,
|
useNodeNeighborhood,
|
||||||
} from "@/lib/api/graph";
|
} from "@/lib/api/graph";
|
||||||
import {
|
import {
|
||||||
@@ -39,10 +40,17 @@ export function GraphView() {
|
|||||||
source: "",
|
source: "",
|
||||||
minCitations: 0,
|
minCitations: 0,
|
||||||
q: "",
|
q: "",
|
||||||
|
court: "",
|
||||||
|
precedentLevel: "",
|
||||||
|
chair: "",
|
||||||
|
district: "",
|
||||||
|
yearFrom: 0,
|
||||||
|
yearTo: 0,
|
||||||
showTopics: true,
|
showTopics: true,
|
||||||
showPracticeAreas: true,
|
showPracticeAreas: true,
|
||||||
showHalachot: false,
|
showHalachot: false,
|
||||||
});
|
});
|
||||||
|
const facets = useGraphFacets().data;
|
||||||
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
||||||
const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
|
const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -67,8 +75,26 @@ export function GraphView() {
|
|||||||
node_types: nodeTypes,
|
node_types: nodeTypes,
|
||||||
limit: NODE_LIMIT,
|
limit: NODE_LIMIT,
|
||||||
q: debouncedQ,
|
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;
|
const isFocused = !!focusNodeId;
|
||||||
@@ -106,7 +132,7 @@ export function GraphView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 h-[calc(100vh-320px)] min-h-[560px]">
|
<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">
|
<div className="relative flex-1 rounded-lg border border-rule bg-surface overflow-hidden">
|
||||||
{error ? (
|
{error ? (
|
||||||
|
|||||||
@@ -32,9 +32,18 @@ export type GraphNode = {
|
|||||||
practice_area: string | null;
|
practice_area: string | null;
|
||||||
source_kind: string | null;
|
source_kind: string | null;
|
||||||
precedent_level: string | null;
|
precedent_level: string | null;
|
||||||
|
court: string | null;
|
||||||
|
date: string | null; // ISO date
|
||||||
case_law_id: string | null;
|
case_law_id: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GraphFacets = {
|
||||||
|
courts: string[];
|
||||||
|
precedent_levels: string[];
|
||||||
|
chairs: string[];
|
||||||
|
districts: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type GraphEdge = {
|
export type GraphEdge = {
|
||||||
source: string;
|
source: string;
|
||||||
target: string;
|
target: string;
|
||||||
@@ -57,6 +66,12 @@ export type GraphFilters = {
|
|||||||
min_citations?: number;
|
min_citations?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
q?: string;
|
q?: string;
|
||||||
|
court?: string;
|
||||||
|
precedent_level?: string;
|
||||||
|
chair?: string;
|
||||||
|
district?: string;
|
||||||
|
year_from?: number;
|
||||||
|
year_to?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const graphKeys = {
|
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.min_citations != null) p.set("min_citations", String(f.min_citations));
|
||||||
if (f.limit != null) p.set("limit", String(f.limit));
|
if (f.limit != null) p.set("limit", String(f.limit));
|
||||||
if (f.q) p.set("q", f.q.trim());
|
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();
|
return p.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,3 +130,12 @@ export function useNodeNeighborhood(
|
|||||||
staleTime: 30_000,
|
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,
|
min_citations: int = 0,
|
||||||
limit: int = graph_api.NODE_CAP_DEFAULT,
|
limit: int = graph_api.NODE_CAP_DEFAULT,
|
||||||
q: str = "",
|
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)."""
|
"""Full corpus graph under the given filters (most-cited nodes survive the cap)."""
|
||||||
if practice_area and practice_area not in _PRACTICE_AREAS:
|
if practice_area and practice_area not in _PRACTICE_AREAS:
|
||||||
@@ -5787,9 +5793,22 @@ async def graph_corpus(
|
|||||||
min_citations=min_citations,
|
min_citations=min_citations,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
q=q,
|
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)
|
@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 = ""):
|
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)."""
|
"""Local-graph focus: the node + its neighbors out to ``depth`` (1-2)."""
|
||||||
|
|||||||
@@ -60,9 +60,20 @@ class GraphNode(BaseModel):
|
|||||||
practice_area: str | None = None
|
practice_area: str | None = None
|
||||||
source_kind: str | None = None # precedents only
|
source_kind: str | None = None # precedents only
|
||||||
precedent_level: 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)
|
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):
|
class GraphEdge(BaseModel):
|
||||||
source: str
|
source: str
|
||||||
target: str
|
target: str
|
||||||
@@ -110,6 +121,8 @@ def _precedent_node(row: asyncpg.Record) -> GraphNode:
|
|||||||
practice_area=(row["practice_area"] or None),
|
practice_area=(row["practice_area"] or None),
|
||||||
source_kind=(row["source_kind"] or None),
|
source_kind=(row["source_kind"] or None),
|
||||||
precedent_level=(row["precedent_level"] 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"]),
|
case_law_id=str(row["id"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -224,12 +237,19 @@ async def build_corpus_graph(
|
|||||||
min_citations: int = 0,
|
min_citations: int = 0,
|
||||||
limit: int = NODE_CAP_DEFAULT,
|
limit: int = NODE_CAP_DEFAULT,
|
||||||
q: str = "",
|
q: str = "",
|
||||||
|
court: str = "",
|
||||||
|
precedent_level: str = "",
|
||||||
|
chair: str = "",
|
||||||
|
district: str = "",
|
||||||
|
year_from: int = 0,
|
||||||
|
year_to: int = 0,
|
||||||
) -> CorpusGraph:
|
) -> CorpusGraph:
|
||||||
"""Assemble the full corpus graph under the given filters.
|
"""Assemble the full corpus graph under the given filters.
|
||||||
|
|
||||||
The most-cited precedents always survive the cap (``ORDER BY size DESC``),
|
The most-cited precedents always survive the cap (``ORDER BY size DESC``),
|
||||||
so clipping never hides the structurally important nodes. ``truncated`` +
|
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)
|
types = normalize_node_types(node_types)
|
||||||
cap = max(1, min(int(limit), NODE_CAP_MAX))
|
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,
|
SELECT c.id, c.case_number, c.case_name,
|
||||||
c.practice_area, c.source_kind, c.precedent_level,
|
c.practice_area, c.source_kind, c.precedent_level,
|
||||||
|
c.court, c.date,
|
||||||
COALESCE(p.n, 0) AS size,
|
COALESCE(p.n, 0) AS size,
|
||||||
COUNT(*) OVER () AS total_available
|
COUNT(*) OVER () AS total_available
|
||||||
FROM case_law c
|
FROM case_law c
|
||||||
@@ -250,6 +271,12 @@ async def build_corpus_graph(
|
|||||||
AND COALESCE(p.n, 0) >= $3
|
AND COALESCE(p.n, 0) >= $3
|
||||||
AND ($4 = '' OR c.case_number ILIKE '%' || $4 || '%'
|
AND ($4 = '' OR c.case_number ILIKE '%' || $4 || '%'
|
||||||
OR c.case_name 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
|
ORDER BY COALESCE(p.n, 0) DESC, c.case_number
|
||||||
LIMIT $5
|
LIMIT $5
|
||||||
""",
|
""",
|
||||||
@@ -258,6 +285,12 @@ async def build_corpus_graph(
|
|||||||
min_cit,
|
min_cit,
|
||||||
q.strip(),
|
q.strip(),
|
||||||
cap,
|
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
|
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,
|
SELECT c.id, c.case_number, c.case_name,
|
||||||
c.practice_area, c.source_kind, c.precedent_level,
|
c.practice_area, c.source_kind, c.precedent_level,
|
||||||
|
c.court, c.date,
|
||||||
COALESCE(p.n, 0) AS size
|
COALESCE(p.n, 0) AS size
|
||||||
FROM case_law c
|
FROM case_law c
|
||||||
LEFT JOIN prec_indeg p ON p.id = c.id
|
LEFT JOIN prec_indeg p ON p.id = c.id
|
||||||
@@ -383,3 +417,33 @@ async def build_node_neighborhood(
|
|||||||
truncated=truncated,
|
truncated=truncated,
|
||||||
total_available=len(nodes),
|
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