From ef21cb93e53d79cc4c36f89acaea2906bba0c77e Mon Sep 17 00:00:00 2001 From: Chaim Date: Mon, 8 Jun 2026 05:13:09 +0000 Subject: [PATCH] =?UTF-8?q?feat(graph):=20halacha=20(rule)=20layer=20(corp?= =?UTF-8?q?us=20graph=20=E2=80=94=20closes=20Phase=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables the previously-disabled "הלכות" toggle. Each approved/published halacha of a displayed precedent becomes a hal: 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) --- web-ui/src/components/graph/graph-canvas.tsx | 9 ++ .../components/graph/graph-filter-panel.tsx | 3 +- .../src/components/graph/graph-node-panel.tsx | 30 ++++- web-ui/src/components/graph/graph-view.tsx | 2 + web-ui/src/lib/api/graph.ts | 3 +- web/graph_api.py | 119 ++++++++++++++++++ 6 files changed, 159 insertions(+), 7 deletions(-) diff --git a/web-ui/src/components/graph/graph-canvas.tsx b/web-ui/src/components/graph/graph-canvas.tsx index 017389a..2b81bf2 100644 --- a/web-ui/src/components/graph/graph-canvas.tsx +++ b/web-ui/src/components/graph/graph-canvas.tsx @@ -306,6 +306,15 @@ export function GraphCanvas({ if (link.type === "covers") { return "rgba(47,111,122,0.45)"; // teal — digest → ruling } + if (link.type === "extracted_from") { + return "rgba(180,83,9,0.22)"; // faint amber — rule → its precedent + } + if (link.type === "corroborates") { + return "rgba(180,83,9,0.55)"; // amber — ruling cites this rule + } + if (link.type === "equivalent") { + return "rgba(124,58,173,0.45)"; // violet — same principle, parallel + } 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 d1c43da..3112990 100644 --- a/web-ui/src/components/graph/graph-filter-panel.tsx +++ b/web-ui/src/components/graph/graph-filter-panel.tsx @@ -272,10 +272,9 @@ export function GraphFilterPanel({ onCheckedChange={(v) => onChange({ showDigests: v })} /> onChange({ showHalachot: v })} - disabled /> diff --git a/web-ui/src/components/graph/graph-node-panel.tsx b/web-ui/src/components/graph/graph-node-panel.tsx index 4efe982..69b1adb 100644 --- a/web-ui/src/components/graph/graph-node-panel.tsx +++ b/web-ui/src/components/graph/graph-node-panel.tsx @@ -25,6 +25,14 @@ const TYPE_LABELS: Record = { digest: "יומון", }; +const RULE_TYPE_LABELS: Record = { + binding: "מחייב", + interpretive: "פרשני", + procedural: "דיוני", + obiter: "אמרת אגב", + application: "יישום", +}; + const GAP_STATUS_LABELS: Record = { open: "ממתינה לקליטה", uploaded: "הועלתה", @@ -54,10 +62,12 @@ export function GraphNodePanel({ 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 = node.type === "precedent" ? node.case_law_id : null; + const detailId = isPrecedent ? node.case_law_id : null; const detail = usePrecedent(detailId); return ( @@ -81,13 +91,25 @@ export function GraphNodePanel({
- {isPrecedentLike && ( - + {isPrecedent && } + {isHalacha && ( + <> + + {node.source_kind && ( + + )} + {node.note && ( +

{node.note}

+ )} + )} {node.practice_area && ( )} - {node.source_kind && ( + {isPrecedent && node.source_kind && ( )} {node.precedent_level && } diff --git a/web-ui/src/components/graph/graph-view.tsx b/web-ui/src/components/graph/graph-view.tsx index 0a728f0..cbb496a 100644 --- a/web-ui/src/components/graph/graph-view.tsx +++ b/web-ui/src/components/graph/graph-view.tsx @@ -401,6 +401,7 @@ function RankList({ const LEGENDS: Record = { type: [ { color: "#1e3a5f", label: "פסיקה" }, + { color: "#b45309", label: "הלכה" }, { color: "#a97d3a", label: "נושא" }, { color: "#475569", label: "תחום" }, ], @@ -429,6 +430,7 @@ const EDGE_LEGEND: { color: string; label: string }[] = [ { color: "rgba(80,90,110,0.7)", label: "ציטוט" }, { color: "rgba(169,125,58,0.5)", label: "נושא/תחום" }, { color: "rgba(47,111,122,0.7)", label: "יומון מסכם" }, + { color: "rgba(180,83,9,0.6)", label: "כלל/אזכור" }, ]; function Legend({ colorBy }: { colorBy: ColorBy }) { diff --git a/web-ui/src/lib/api/graph.ts b/web-ui/src/lib/api/graph.ts index 7239a03..f517b59 100644 --- a/web-ui/src/lib/api/graph.ts +++ b/web-ui/src/lib/api/graph.ts @@ -29,7 +29,8 @@ export type GraphEdgeType = | "in_area" | "corroborates" | "equivalent" - | "covers"; + | "covers" + | "extracted_from"; export type GraphNode = { id: string; diff --git a/web/graph_api.py b/web/graph_api.py index 2378942..bc7c841 100644 --- a/web/graph_api.py +++ b/web/graph_api.py @@ -400,6 +400,117 @@ async def _add_digests( edges.extend(dig_edges) +async def _halacha_nodes_and_edges( + conn: asyncpg.Connection, + prec_ids: list, +) -> tuple[list[GraphNode], list[GraphEdge]]: + """Halacha (rule) layer. Each approved/published halacha of a displayed + precedent becomes a ``hal:`` node linked to its parent precedent + (``extracted_from``). Two cross-rule edges when both endpoints are present: + ``corroborates`` (a later ruling cites the rule — + ``halacha_citation_corroboration``) and ``equivalent`` (same principle from + a different committee — ``equivalent_halachot``). Node size = how many + rulings corroborate the rule (global). Off by default (gated via + node_types).""" + nodes: list[GraphNode] = [] + edges: list[GraphEdge] = [] + if not prec_ids: + return nodes, edges + + hal_rows = await conn.fetch( + """ + SELECT id, case_law_id, rule_statement, rule_type + FROM halachot + WHERE case_law_id = ANY($1::uuid[]) + AND review_status IN ('approved', 'published') + ORDER BY case_law_id, halacha_index + LIMIT 600 + """, + prec_ids, + ) + if not hal_rows: + return nodes, edges + hal_ids = [r["id"] for r in hal_rows] + prec_set = {str(x) for x in prec_ids} + + # Global corroboration in-degree per halacha (importance). + indeg_rows = await conn.fetch( + """ + SELECT halacha_id AS id, COUNT(*) AS n + FROM halacha_citation_corroboration + WHERE halacha_id = ANY($1::uuid[]) + GROUP BY halacha_id + """, + hal_ids, + ) + indeg = {r["id"]: int(r["n"]) for r in indeg_rows} + + for r in hal_rows: + stmt = (r["rule_statement"] or "").strip() + nodes.append( + GraphNode( + id=f"hal:{r['id']}", + type="halacha", + label=(stmt[:60] or "הלכה"), + size=indeg.get(r["id"], 0), + note=(stmt or None), + practice_area=None, + source_kind=(r["rule_type"] or None), # reuse slot for rule_type + case_law_id=(str(r["case_law_id"]) if r["case_law_id"] else None), + ) + ) + if r["case_law_id"] is not None: + edges.append( + GraphEdge( + source=f"hal:{r['id']}", + target=f"cl:{r['case_law_id']}", + type="extracted_from", + ) + ) + + # corroborates — a displayed precedent cites one of these rules. + corr_rows = await conn.fetch( + """ + SELECT halacha_id, citing_case_law_id, treatment + FROM halacha_citation_corroboration + WHERE halacha_id = ANY($1::uuid[]) AND citing_case_law_id IS NOT NULL + """, + hal_ids, + ) + for r in corr_rows: + if str(r["citing_case_law_id"]) in prec_set: + edges.append( + GraphEdge( + source=f"cl:{r['citing_case_law_id']}", + target=f"hal:{r['halacha_id']}", + type="corroborates", + treatment=(r["treatment"] or None), + ) + ) + + # equivalent — both rules present (undirected). + hal_set = set(hal_ids) + eq_rows = await conn.fetch( + """ + SELECT halacha_a, halacha_b, cosine + FROM equivalent_halachot + WHERE halacha_a = ANY($1::uuid[]) AND halacha_b = ANY($1::uuid[]) + """, + hal_ids, + ) + for r in eq_rows: + if r["halacha_a"] in hal_set and r["halacha_b"] in hal_set: + edges.append( + GraphEdge( + source=f"hal:{r['halacha_a']}", + target=f"hal:{r['halacha_b']}", + type="equivalent", + weight=float(r["cosine"]) if r["cosine"] is not None else None, + ) + ) + return nodes, edges + + # ── Endpoints' core logic ──────────────────────────────────────────── async def build_corpus_graph( pool: asyncpg.Pool, @@ -482,6 +593,10 @@ async def build_corpus_graph( edges.extend(gap_edges) if "digest" in types: await _add_digests(conn, prec_id_list, nodes, edges) + if "halacha" in types: + hal_nodes, hal_edges = await _halacha_nodes_and_edges(conn, prec_id_list) + nodes.extend(hal_nodes) + edges.extend(hal_edges) if metrics: _stamp_metrics(nodes, edges) @@ -622,6 +737,10 @@ async def build_node_neighborhood( edges.extend(gap_edges) if "digest" in forced_types: await _add_digests(conn, prec_id_list, nodes, edges) + if "halacha" in forced_types: + hal_nodes, hal_edges = await _halacha_nodes_and_edges(conn, prec_id_list) + nodes.extend(hal_nodes) + edges.extend(hal_edges) return CorpusGraph( nodes=nodes, -- 2.49.1