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:
@@ -50,6 +50,7 @@ const NODE_COLORS: Record<string, string> = {
|
||||
halacha: "#b45309", // amber
|
||||
topic: "#a97d3a", // gold — hubs stand out
|
||||
practice_area: "#475569", // slate
|
||||
gap: "#94a3b8", // faint slate — research gap (not in corpus)
|
||||
};
|
||||
|
||||
const TREATMENT_COLORS: Record<string, string> = {
|
||||
@@ -215,13 +216,23 @@ export function GraphCanvas({
|
||||
(node: FGNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const r = radiusForNode(node, sizeBy);
|
||||
const dimmed = isDimmed(node.id);
|
||||
const isGap = node.type === "gap";
|
||||
const color = colorForNode(node, colorBy);
|
||||
ctx.globalAlpha = dimmed ? 0.18 : 1;
|
||||
ctx.globalAlpha = dimmed ? 0.18 : isGap ? 0.55 : 1;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x ?? 0, node.y ?? 0, r, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
if (isGap) {
|
||||
// Hollow dashed circle — a ruling cited but absent from the corpus.
|
||||
ctx.setLineDash([3 / globalScale, 2 / globalScale]);
|
||||
ctx.lineWidth = 1.3 / globalScale;
|
||||
ctx.strokeStyle = NODE_COLORS.gap;
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
} else {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
}
|
||||
if (node.id === activeId) {
|
||||
ctx.lineWidth = 2 / globalScale;
|
||||
ctx.strokeStyle = "#a97d3a";
|
||||
|
||||
@@ -43,6 +43,7 @@ export type GraphControls = {
|
||||
showTopics: boolean;
|
||||
showPracticeAreas: boolean;
|
||||
showHalachot: boolean;
|
||||
showGaps: boolean;
|
||||
};
|
||||
|
||||
const ALL = "__all__";
|
||||
@@ -259,6 +260,11 @@ export function GraphFilterPanel({
|
||||
checked={controls.showPracticeAreas}
|
||||
onCheckedChange={(v) => onChange({ showPracticeAreas: v })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="חוסרי מחקר (פסיקה חסרה)"
|
||||
checked={controls.showGaps}
|
||||
onCheckedChange={(v) => onChange({ showGaps: v })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="הלכות (שלב ב׳)"
|
||||
checked={controls.showHalachot}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -65,6 +65,7 @@ export function GraphView() {
|
||||
showTopics: true,
|
||||
showPracticeAreas: true,
|
||||
showHalachot: false,
|
||||
showGaps: false,
|
||||
});
|
||||
const facets = useGraphFacets().data;
|
||||
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
||||
@@ -78,8 +79,14 @@ export function GraphView() {
|
||||
if (controls.showTopics) t.push("topic");
|
||||
if (controls.showPracticeAreas) t.push("practice_area");
|
||||
if (controls.showHalachot) t.push("halacha");
|
||||
if (controls.showGaps) t.push("gap");
|
||||
return t.join(",");
|
||||
}, [controls.showTopics, controls.showPracticeAreas, controls.showHalachot]);
|
||||
}, [
|
||||
controls.showTopics,
|
||||
controls.showPracticeAreas,
|
||||
controls.showHalachot,
|
||||
controls.showGaps,
|
||||
]);
|
||||
|
||||
// Metrics are needed when colouring by cluster or sizing by a centrality.
|
||||
const metricsOn =
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
export type GraphNodeType = "precedent" | "halacha" | "topic" | "practice_area";
|
||||
export type GraphNodeType =
|
||||
| "precedent"
|
||||
| "halacha"
|
||||
| "topic"
|
||||
| "practice_area"
|
||||
| "gap";
|
||||
|
||||
export type GraphEdgeType =
|
||||
| "cites"
|
||||
@@ -38,6 +43,8 @@ export type GraphNode = {
|
||||
pagerank: number | null; // normalized 0–1, only when metrics requested
|
||||
betweenness: number | null; // normalized 0–1
|
||||
community: number | null; // dense cluster id, 0 = largest
|
||||
gap_status: string | null; // gap nodes only — open|uploaded|closed|irrelevant
|
||||
missing_precedent_id: string | null; // gap nodes only
|
||||
};
|
||||
|
||||
export type GraphFacets = {
|
||||
|
||||
Reference in New Issue
Block a user