Files
legal-ai/web-ui/src/components/graph/graph-node-panel.tsx
Chaim ef21cb93e5 feat(graph): halacha (rule) layer (corpus graph — closes Phase 2)
Enables the previously-disabled "הלכות" toggle. Each approved/published halacha
of a displayed precedent becomes a hal:<id> node linked to its parent
precedent (extracted_from); two cross-rule edges when both endpoints are in
view: corroborates (a later ruling cites the rule —
halacha_citation_corroboration) and equivalent (same principle from another
committee — equivalent_halachot). Node size = corroboration in-degree.

Backend (web/graph_api.py — read-only, G2):
- _halacha_nodes_and_edges(): halachot WHERE case_law_id in view AND
  review_status IN (approved, published), LIMIT 600; rule_type carried in the
  source_kind slot, rule_statement in note. Wired into both build functions
  (gated via node_types). Metrics still exclude halacha edges (only cites/
  precedent-typed feed PageRank). Validated: 185 halachot on the top-30
  precedents; 20 corroboration + 5 equivalent edges in the corpus.

Frontend:
- graph.ts: GraphEdgeType += extracted_from.
- graph-filter-panel: "הלכות" toggle enabled (was disabled "שלב ב׳").
- graph-canvas: amber halacha nodes; edge colours — extracted_from (faint
  amber), corroborates (amber), equivalent (violet).
- graph-node-panel: halacha branch — אזכורים + סוג כלל + rule text; "open in
  library" deep-links to the parent precedent.
- graph-view: halacha added to node + edge legends.

web-ui build + lint pass. Invariants: G2 (SELECT-only), UI2 (no model change —
reuses note/source_kind/case_law_id slots).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 05:13:09 +00:00

207 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 { Skeleton } from "@/components/ui/skeleton";
import type { GraphNode } from "@/lib/api/graph";
import { usePrecedent } from "@/lib/api/precedent-library";
const TYPE_LABELS: Record<string, string> = {
precedent: "פסיקה",
halacha: "הלכה",
topic: "נושא",
practice_area: "תחום",
gap: "פסיקה חסרה",
digest: "יומון",
};
const RULE_TYPE_LABELS: Record<string, string> = {
binding: "מחייב",
interpretive: "פרשני",
procedural: "דיוני",
obiter: "אמרת אגב",
application: "יישום",
};
const GAP_STATUS_LABELS: Record<string, string> = {
open: "ממתינה לקליטה",
uploaded: "הועלתה",
closed: "טופלה",
irrelevant: "לא רלוונטית",
};
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";
const isPrecedent = node.type === "precedent";
const isHalacha = node.type === "halacha";
const isGap = node.type === "gap";
const isDigest = node.type === "digest";
// Rich detail (summary/headnote) for precedents — reuses the library hook.
const detailId = isPrecedent ? node.case_law_id : null;
const detail = usePrecedent(detailId);
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">
{isPrecedent && <Row label="ציטוטים נכנסים" value={String(node.size)} />}
{isHalacha && (
<>
<Row label="אזכורים" value={String(node.size)} />
{node.source_kind && (
<Row
label="סוג כלל"
value={RULE_TYPE_LABELS[node.source_kind] ?? node.source_kind}
/>
)}
{node.note && (
<p className="text-ink text-sm leading-relaxed m-0">{node.note}</p>
)}
</>
)}
{node.practice_area && (
<Row label="תחום" value={PA_LABELS[node.practice_area] ?? node.practice_area} />
)}
{isPrecedent && node.source_kind && (
<Row label="מקור" value={SOURCE_LABELS[node.source_kind] ?? node.source_kind} />
)}
{node.precedent_level && <Row label="דרגה" value={node.precedent_level} />}
{isGap && (
<>
<Row label="מצוטטת ע״י" value={`${node.size} פסיקות בקורפוס`} />
{node.gap_status && (
<Row
label="סטטוס"
value={GAP_STATUS_LABELS[node.gap_status] ?? node.gap_status}
/>
)}
<p className="text-ink-muted text-xs leading-relaxed m-0">
פסיקה זו מצוטטת בקורפוס אך אינה קיימת בו מועמדת לקליטה.
</p>
</>
)}
{isDigest && (
<>
{node.note && (
<p className="text-ink text-sm leading-relaxed m-0">{node.note}</p>
)}
{node.court && <Row label="ערכאה" value={node.court} />}
{node.date && <Row label="תאריך" value={node.date.slice(0, 10)} />}
<p className="text-ink-muted text-xs leading-relaxed m-0">
סיכום יומי מ״כל יום״ מצביע על הפסיקה שהוא מנתח.
</p>
</>
)}
{!isPrecedentLike && !isGap && !isDigest && (
<p className="text-ink-muted text-xs leading-relaxed m-0">
לחיצה על נקודה זו מתמקדת בשכניה כל הפסיקות המשויכות אליה.
</p>
)}
</dl>
{detailId && (
<div className="space-y-1.5 border-t border-rule pt-3">
{detail.isPending ? (
<>
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-4/5" />
</>
) : detail.error ? (
<p className="text-ink-muted text-xs m-0">לא ניתן לטעון תקציר.</p>
) : detail.data?.headnote || detail.data?.summary ? (
<p className="text-ink text-sm leading-relaxed m-0">
{detail.data.headnote || detail.data.summary}
</p>
) : (
<p className="text-ink-muted text-xs m-0">אין תקציר.</p>
)}
</div>
)}
{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>
)}
{isGap && (
<Button asChild variant="outline" className="w-full">
<Link href="/missing-precedents">
<ExternalLink className="size-4 me-2" />
לרשימת הפסיקה החסרה
</Link>
</Button>
)}
{isDigest && (
<Button asChild variant="outline" className="w-full">
<Link href="/digests">
<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>
);
}