From fc5d69902f98816a6997cd03b64598c63156943d Mon Sep 17 00:00:00 2001 From: Chaim Date: Sun, 7 Jun 2026 21:31:04 +0000 Subject: [PATCH] =?UTF-8?q?feat(graph):=20daily-digest=20(=D7=99=D7=95?= =?UTF-8?q?=D7=9E=D7=95=D7=9F)=20discovery=20layer=20(corpus=20graph=20PR?= =?UTF-8?q?=20E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chaim's idea: surface the downloaded "כל יום" digests in the graph. Each digest COVERS the ruling it analyses — a corpus precedent when we have it (16), or a synthesized gap node from its underlying_citation when we don't (269). So the digest layer doubles as a discovery signal: it makes visible that the daily feed overwhelmingly covers rulings NOT yet in the corpus. Backend (web/graph_api.py — read-only, G2): - "digest" added to VALID_NODE_TYPES (off by default). - _digest_nodes_and_edges(): dig: nodes from completed digests, `covers` edge → cl:precedent (linked_case_law_id in view) or → gap: (synthesized, deduped against the gap layer — real in-degree wins). Carries concept_tag (label), headline_holding (note), underlying_court/date. - _add_digests() appends the layer with gap dedup. Wired into both build functions. GraphNode += note, digest_id. Gated via node_types (no app.py change). Validated: 16 covers→precedent, 269 covers→gap. Frontend: - graph.ts: GraphNodeType += "digest"; GraphEdgeType += "covers"; node fields. - graph-filter-panel: toggle "יומונים (כל יום)" (off by default). - graph-canvas: digest = teal node (r=4); `covers` edges teal. - graph-node-panel: digest branch — concept + holding + court/date + link to /digests. web-ui build + lint pass. Invariants: G2 (SELECT-only), UI2. api:types post-deploy. Co-Authored-By: Claude Opus 4.8 (1M context) --- web-ui/src/components/graph/graph-canvas.tsx | 5 + .../components/graph/graph-filter-panel.tsx | 6 + .../src/components/graph/graph-node-panel.tsx | 25 ++++- web-ui/src/components/graph/graph-view.tsx | 3 + web-ui/src/lib/api/graph.ts | 8 +- web/graph_api.py | 106 +++++++++++++++++- 6 files changed, 147 insertions(+), 6 deletions(-) diff --git a/web-ui/src/components/graph/graph-canvas.tsx b/web-ui/src/components/graph/graph-canvas.tsx index 5a13b50..017389a 100644 --- a/web-ui/src/components/graph/graph-canvas.tsx +++ b/web-ui/src/components/graph/graph-canvas.tsx @@ -51,6 +51,7 @@ const NODE_COLORS: Record = { topic: "#a97d3a", // gold — hubs stand out practice_area: "#475569", // slate gap: "#94a3b8", // faint slate — research gap (not in corpus) + digest: "#2f6f7a", // teal — daily-digest (יומון) discovery layer }; const TREATMENT_COLORS: Record = { @@ -114,6 +115,7 @@ export function colorForNode(n: GraphNode, colorBy: ColorBy): string { export function radiusForNode(n: GraphNode, sizeBy: SizeBy): number { if (n.type === "topic" || n.type === "practice_area") return 5; + if (n.type === "digest") return 4; if (sizeBy === "pagerank" && n.pagerank != null) { return 3 + Math.sqrt(n.pagerank) * 18; } @@ -301,6 +303,9 @@ export function GraphCanvas({ if (link.type === "tagged" || link.type === "in_area") { return "rgba(169,125,58,0.16)"; } + if (link.type === "covers") { + return "rgba(47,111,122,0.45)"; // teal — digest → ruling + } return "rgba(80,90,110,0.22)"; }, [activeId], diff --git a/web-ui/src/components/graph/graph-filter-panel.tsx b/web-ui/src/components/graph/graph-filter-panel.tsx index c48489d..d1c43da 100644 --- a/web-ui/src/components/graph/graph-filter-panel.tsx +++ b/web-ui/src/components/graph/graph-filter-panel.tsx @@ -44,6 +44,7 @@ export type GraphControls = { showPracticeAreas: boolean; showHalachot: boolean; showGaps: boolean; + showDigests: boolean; }; const ALL = "__all__"; @@ -265,6 +266,11 @@ export function GraphFilterPanel({ checked={controls.showGaps} onCheckedChange={(v) => onChange({ showGaps: v })} /> + onChange({ showDigests: v })} + /> = { topic: "נושא", practice_area: "תחום", gap: "פסיקה חסרה", + digest: "יומון", }; const GAP_STATUS_LABELS: Record = { @@ -52,6 +53,7 @@ export function GraphNodePanel({ }) { const isPrecedentLike = node.type === "precedent" || node.type === "halacha"; const isGap = node.type === "gap"; + const isDigest = node.type === "digest"; return ( @@ -98,7 +100,19 @@ export function GraphNodePanel({

)} - {!isPrecedentLike && !isGap && ( + {isDigest && ( + <> + {node.note && ( +

{node.note}

+ )} + {node.court && } + {node.date && } +

+ סיכום יומי מ״כל יום״ — מצביע על הפסיקה שהוא מנתח. +

+ + )} + {!isPrecedentLike && !isGap && !isDigest && (

לחיצה על נקודה זו מתמקדת בשכניה — כל הפסיקות המשויכות אליה.

@@ -122,6 +136,15 @@ export function GraphNodePanel({ )} + + {isDigest && ( + + )}
); diff --git a/web-ui/src/components/graph/graph-view.tsx b/web-ui/src/components/graph/graph-view.tsx index 14706cd..f67c68d 100644 --- a/web-ui/src/components/graph/graph-view.tsx +++ b/web-ui/src/components/graph/graph-view.tsx @@ -66,6 +66,7 @@ export function GraphView() { showPracticeAreas: true, showHalachot: false, showGaps: false, + showDigests: false, }); const facets = useGraphFacets().data; const [selectedNode, setSelectedNode] = useState(null); @@ -80,12 +81,14 @@ export function GraphView() { if (controls.showPracticeAreas) t.push("practice_area"); if (controls.showHalachot) t.push("halacha"); if (controls.showGaps) t.push("gap"); + if (controls.showDigests) t.push("digest"); return t.join(","); }, [ controls.showTopics, controls.showPracticeAreas, controls.showHalachot, controls.showGaps, + controls.showDigests, ]); // Metrics are needed when colouring by cluster or sizing by a centrality. diff --git a/web-ui/src/lib/api/graph.ts b/web-ui/src/lib/api/graph.ts index ed9a4ac..7239a03 100644 --- a/web-ui/src/lib/api/graph.ts +++ b/web-ui/src/lib/api/graph.ts @@ -19,7 +19,8 @@ export type GraphNodeType = | "halacha" | "topic" | "practice_area" - | "gap"; + | "gap" + | "digest"; export type GraphEdgeType = | "cites" @@ -27,7 +28,8 @@ export type GraphEdgeType = | "tagged" | "in_area" | "corroborates" - | "equivalent"; + | "equivalent" + | "covers"; export type GraphNode = { id: string; @@ -45,6 +47,8 @@ export type GraphNode = { 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 + note: string | null; // digest nodes only — the holding line + digest_id: string | null; // digest nodes only }; export type GraphFacets = { diff --git a/web/graph_api.py b/web/graph_api.py index 21d627d..5f89b1b 100644 --- a/web/graph_api.py +++ b/web/graph_api.py @@ -38,7 +38,7 @@ from pydantic import BaseModel from web import graph_metrics # ── Node-type vocabulary ───────────────────────────────────────────── -VALID_NODE_TYPES = {"precedent", "halacha", "topic", "practice_area", "gap"} +VALID_NODE_TYPES = {"precedent", "halacha", "topic", "practice_area", "gap", "digest"} DEFAULT_NODE_TYPES = ("precedent", "topic", "practice_area") NODE_CAP_DEFAULT = 400 NODE_CAP_MAX = 1500 @@ -72,6 +72,9 @@ class GraphNode(BaseModel): # Gap nodes only — research-gap status from missing_precedents (best-effort). gap_status: str | None = None # open | uploaded | closed | irrelevant missing_precedent_id: str | None = None + # Digest nodes only — the holding line from the daily יומון. + note: str | None = None + digest_id: str | None = None # for deep-link to /digests class GraphFacets(BaseModel): @@ -306,6 +309,97 @@ async def _gap_nodes_and_edges( return nodes, edges +async def _digest_nodes_and_edges( + conn: asyncpg.Connection, + prec_ids: list, +) -> tuple[list[GraphNode], list[GraphEdge], list[GraphNode]]: + """Daily-digest (יומון) discovery layer. Each digest ``covers`` the ruling + it analyses: a corpus precedent (``linked_case_law_id``) when we have it, or + a ``gap`` node synthesized from ``underlying_citation`` when we don't — so + the digest doubles as a research signal ("the feed flagged this ruling"). + + Returns (digest_nodes, covers_edges, gap_target_nodes). The caller dedups + gap nodes against the gap layer (real in-degree there wins over size=1).""" + digest_nodes: list[GraphNode] = [] + edges: list[GraphEdge] = [] + gap_nodes: list[GraphNode] = [] + if not prec_ids: + return digest_nodes, edges, gap_nodes + prec_set = {str(x) for x in prec_ids} + + rows = await conn.fetch( + """ + SELECT id, yomon_number, concept_tag, headline_holding, + underlying_citation, underlying_court, underlying_date, + digest_date, practice_area, linked_case_law_id, + regexp_replace(btrim(underlying_citation), '\\s+', ' ', 'g') AS u_num + FROM digests + WHERE extraction_status = 'completed' + AND (linked_case_law_id = ANY($1::uuid[]) + OR (linked_case_law_id IS NULL AND btrim(underlying_citation) <> '')) + ORDER BY digest_date DESC NULLS LAST + LIMIT 400 + """, + prec_ids, + ) + seen_gap: set[str] = set() + for r in rows: + did = f"dig:{r['id']}" + linked = r["linked_case_law_id"] + if linked is not None and str(linked) in prec_set: + target = f"cl:{linked}" + elif r["u_num"]: + target = f"gap:{r['u_num']}" + if r["u_num"] not in seen_gap: + seen_gap.add(r["u_num"]) + gap_nodes.append( + GraphNode( + id=target, + type="gap", + label=(r["underlying_citation"] or "").strip() or r["u_num"], + size=1, + ) + ) + else: + continue + label = (r["concept_tag"] or "").strip() or ( + f"יומון {r['yomon_number']}" if r["yomon_number"] else "יומון" + ) + d = r["underlying_date"] or r["digest_date"] + digest_nodes.append( + GraphNode( + id=did, + type="digest", + label=label[:48], + note=((r["headline_holding"] or "").strip()[:160] or None), + court=(r["underlying_court"] or None), + date=(d.isoformat() if d else None), + practice_area=(r["practice_area"] or None), + digest_id=str(r["id"]), + ) + ) + edges.append(GraphEdge(source=did, target=target, type="covers")) + return digest_nodes, edges, gap_nodes + + +async def _add_digests( + conn: asyncpg.Connection, + prec_ids: list, + nodes: list[GraphNode], + edges: list[GraphEdge], +) -> None: + """Append the digest layer in place, adding digest-target gap nodes only if + they aren't already present (the gap layer's real in-degree wins).""" + dig_nodes, dig_edges, gap_targets = await _digest_nodes_and_edges(conn, prec_ids) + existing = {n.id for n in nodes} + for g in gap_targets: + if g.id not in existing: + nodes.append(g) + existing.add(g.id) + nodes.extend(dig_nodes) + edges.extend(dig_edges) + + # ── Endpoints' core logic ──────────────────────────────────────────── async def build_corpus_graph( pool: asyncpg.Pool, @@ -379,12 +473,15 @@ async def build_corpus_graph( total_available = int(prec_rows[0]["total_available"]) if prec_rows else 0 nodes = [_precedent_node(r) for r in prec_rows] + prec_id_list = [r["id"] for r in prec_rows] hub_nodes, edges = await _edges_and_hubs(conn, prec_rows, types) nodes.extend(hub_nodes) if "gap" in types: - gap_nodes, gap_edges = await _gap_nodes_and_edges(conn, [r["id"] for r in prec_rows]) + gap_nodes, gap_edges = await _gap_nodes_and_edges(conn, prec_id_list) nodes.extend(gap_nodes) edges.extend(gap_edges) + if "digest" in types: + await _add_digests(conn, prec_id_list, nodes, edges) if metrics: _stamp_metrics(nodes, edges) @@ -516,12 +613,15 @@ async def build_node_neighborhood( ids, ) nodes = [_precedent_node(r) for r in prec_rows] + prec_id_list = [r["id"] for r in prec_rows] hub_nodes, edges = await _edges_and_hubs(conn, prec_rows, forced_types) nodes.extend(hub_nodes) if "gap" in forced_types: - gap_nodes, gap_edges = await _gap_nodes_and_edges(conn, [r["id"] for r in prec_rows]) + gap_nodes, gap_edges = await _gap_nodes_and_edges(conn, prec_id_list) nodes.extend(gap_nodes) edges.extend(gap_edges) + if "digest" in forced_types: + await _add_digests(conn, prec_id_list, nodes, edges) return CorpusGraph( nodes=nodes,