feat(graph): research-gap (ghost) nodes (corpus graph PR C)

Turns the graph into a gap-finder: the 247 unresolved internal citations
(a corpus precedent cites a ruling NOT in the corpus) collapse to 230 distinct
"gap" nodes — each sized by how many corpus precedents cite it, i.e. the
most-wanted missing precedent.

Backend (web/graph_api.py — read-only, G2):
- "gap" added to VALID_NODE_TYPES (NOT default → off unless requested).
- New _gap_nodes_and_edges(): gap:<normalized citation> nodes from
  precedent_internal_citations WHERE cited_case_law_id IS NULL, sized by global
  in-degree; cites edges only from precedents present in the view (dangling-edge
  invariant holds). Best-effort enrichment from missing_precedents via exact
  normalized-citation match → gap_status + missing_precedent_id. Validated:
  230 gaps, top ע"א 3213/97 (cited 5×), 230/230 matched to missing_precedents.
- GraphNode += gap_status, missing_precedent_id. Metrics correctly exclude gap
  edges (target not a precedent). No app.py change (gated via node_types).

Frontend:
- graph.ts: GraphNodeType += "gap"; node fields.
- graph-filter-panel: toggle "חוסרי מחקר (פסיקה חסרה)" (off by default).
- graph-canvas: gaps render as faint hollow dashed circles, never recoloured
  by color-by; sized by citation count.
- graph-node-panel: gap branch — "מצוטטת ע״י N פסיקות" + status badge + link
  to /missing-precedents.

web-ui build + lint pass. Invariants: G2 (SELECT-only), UI2 (model grows on
explicit Pydantic). api:types post-deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 21:21:53 +00:00
parent ecd9e46bb9
commit 9a126f7c36
6 changed files with 151 additions and 7 deletions

View File

@@ -19,6 +19,14 @@ const TYPE_LABELS: Record<string, string> = {
halacha: "הלכה",
topic: "נושא",
practice_area: "תחום",
gap: "פסיקה חסרה",
};
const GAP_STATUS_LABELS: Record<string, string> = {
open: "ממתינה לקליטה",
uploaded: "הועלתה",
closed: "טופלה",
irrelevant: "לא רלוונטית",
};
const PA_LABELS: Record<string, string> = {
@@ -43,6 +51,7 @@ export function GraphNodePanel({
onClose: () => void;
}) {
const isPrecedentLike = node.type === "precedent" || node.type === "halacha";
const isGap = node.type === "gap";
return (
<Card className="bg-surface border-rule shadow-sm w-80 shrink-0 overflow-y-auto">
<CardContent className="space-y-4 p-4">
@@ -75,7 +84,21 @@ export function GraphNodePanel({
<Row label="מקור" value={SOURCE_LABELS[node.source_kind] ?? node.source_kind} />
)}
{node.precedent_level && <Row label="דרגה" value={node.precedent_level} />}
{!isPrecedentLike && (
{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>
</>
)}
{!isPrecedentLike && !isGap && (
<p className="text-ink-muted text-xs leading-relaxed m-0">
לחיצה על נקודה זו מתמקדת בשכניה כל הפסיקות המשויכות אליה.
</p>
@@ -90,6 +113,15 @@ export function GraphNodePanel({
</Link>
</Button>
)}
{isGap && (
<Button asChild variant="outline" className="w-full">
<Link href="/missing-precedents">
<ExternalLink className="size-4 me-2" />
לרשימת הפסיקה החסרה
</Link>
</Button>
)}
</CardContent>
</Card>
);