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>
This commit is contained in:
119
web/graph_api.py
119
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:<id>`` 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,
|
||||
|
||||
Reference in New Issue
Block a user