feat(graph): in-app corpus citation graph (/graph) — Phase 1
Native, Obsidian-graph-view-like network of the precedent corpus, rendered
in web-ui from a read-only projection of the live DB. Replaces the idea of
exporting to an external Obsidian vault (which would be a parallel, drifting
copy of the corpus — the exact root cause G2 forbids).
The graph edges already existed in the data model; this only surfaces them:
nodes = precedents (case_law) + synthesized topic/practice-area hubs;
edges = cites (precedent_internal_citations) + same_chain (case_law_relations)
+ tagged/in_area (subject_tags / practice_area membership). Node size =
incoming-citation count (index-backed GROUP BY on idx_pic_target). Click a
node → local-graph neighborhood focus; panel deep-links to /precedents/[id].
Backend (read-only, SELECT only — G2):
- web/graph_api.py — Pydantic models (CorpusGraph/GraphNode/GraphEdge, so
OpenAPI emits real types — UI2) + SQL assembly over the shared db.get_pool().
- web/app.py — GET /api/graph/corpus, GET /api/graph/node/{id}/neighborhood,
both with explicit response_model. practice_area validated against the
closed enum (G5); both endpoints write nothing.
Frontend:
- react-force-graph-2d (canvas/d3-force), loaded via next/dynamic ssr:false.
- /graph page + nav entry; graph.ts TanStack hooks; filter panel (practice_area
/ source / min-citations / search / node-type toggles), node detail panel,
hover+selection neighborhood highlight. Explicit error handling (UI4).
Not a retrieval path (03-retrieval): returns graph topology, never ranked
search results. Halacha nodes + corroboration/equivalence edges are Phase 2,
already gated behind the node_types param (no contract change needed).
SQL validated read-only against the live DB (142 precedents, 85 resolved
citations, JSONB tag expansion, ANY(uuid[]) edge + BFS queries). web-ui lint
+ build pass; /graph in the route table.
Invariants: keeps G2 (single source of truth — live projection, no parallel
store), G5 (corpus separation filtered server-side), UI2 (response models),
UI4 (no swallowed UI errors).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
105
web-ui/src/components/graph/graph-node-panel.tsx
Normal file
105
web-ui/src/components/graph/graph-node-panel.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Side panel shown when a node is selected. For precedent/halacha nodes it
|
||||
* deep-links into the existing precedent library (/precedents/[id]) so the
|
||||
* graph is a navigation surface, not a dead-end visualization.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { ExternalLink, X } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import type { GraphNode } from "@/lib/api/graph";
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
precedent: "פסיקה",
|
||||
halacha: "הלכה",
|
||||
topic: "נושא",
|
||||
practice_area: "תחום",
|
||||
};
|
||||
|
||||
const PA_LABELS: Record<string, string> = {
|
||||
rishuy_uvniya: "רישוי ובנייה",
|
||||
betterment_levy: "היטל השבחה",
|
||||
compensation_197: "פיצויים (ס׳ 197)",
|
||||
appeals_committee: "ועדת ערר",
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
external_upload: "פסיקה חיצונית",
|
||||
internal_committee: "החלטת ועדה",
|
||||
cited_only: "מוזכר בלבד",
|
||||
nevo_seed: "נבו",
|
||||
};
|
||||
|
||||
export function GraphNodePanel({
|
||||
node,
|
||||
onClose,
|
||||
}: {
|
||||
node: GraphNode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const isPrecedentLike = node.type === "precedent" || node.type === "halacha";
|
||||
return (
|
||||
<Card className="bg-surface border-rule shadow-sm w-80 shrink-0 overflow-y-auto">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<Badge variant="outline" className="text-[0.65rem]">
|
||||
{TYPE_LABELS[node.type] ?? node.type}
|
||||
</Badge>
|
||||
<h2 className="text-navy text-base leading-snug m-0">{node.label}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
aria-label="סגור"
|
||||
className="shrink-0"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<dl className="space-y-2 text-sm">
|
||||
{isPrecedentLike && (
|
||||
<Row label="ציטוטים נכנסים" value={String(node.size)} />
|
||||
)}
|
||||
{node.practice_area && (
|
||||
<Row label="תחום" value={PA_LABELS[node.practice_area] ?? node.practice_area} />
|
||||
)}
|
||||
{node.source_kind && (
|
||||
<Row label="מקור" value={SOURCE_LABELS[node.source_kind] ?? node.source_kind} />
|
||||
)}
|
||||
{node.precedent_level && <Row label="דרגה" value={node.precedent_level} />}
|
||||
{!isPrecedentLike && (
|
||||
<p className="text-ink-muted text-xs leading-relaxed m-0">
|
||||
לחיצה על נקודה זו מתמקדת בשכניה — כל הפסיקות המשויכות אליה.
|
||||
</p>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{isPrecedentLike && node.case_law_id && (
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href={`/precedents/${node.case_law_id}`}>
|
||||
<ExternalLink className="size-4 me-2" />
|
||||
פתח בספריית הפסיקה
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="text-ink-muted text-xs shrink-0">{label}</dt>
|
||||
<dd className="text-ink text-end m-0">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user