Merge pull request 'feat(graph): halacha (rule) layer — closes Phase 2' (#137) from worktree-graph-halacha into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 50s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 50s
This commit was merged in pull request #137.
This commit is contained in:
@@ -306,6 +306,15 @@ export function GraphCanvas({
|
|||||||
if (link.type === "covers") {
|
if (link.type === "covers") {
|
||||||
return "rgba(47,111,122,0.45)"; // teal — digest → ruling
|
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)";
|
return "rgba(80,90,110,0.22)";
|
||||||
},
|
},
|
||||||
[activeId],
|
[activeId],
|
||||||
|
|||||||
@@ -272,10 +272,9 @@ export function GraphFilterPanel({
|
|||||||
onCheckedChange={(v) => onChange({ showDigests: v })}
|
onCheckedChange={(v) => onChange({ showDigests: v })}
|
||||||
/>
|
/>
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
label="הלכות (שלב ב׳)"
|
label="הלכות"
|
||||||
checked={controls.showHalachot}
|
checked={controls.showHalachot}
|
||||||
onCheckedChange={(v) => onChange({ showHalachot: v })}
|
onCheckedChange={(v) => onChange({ showHalachot: v })}
|
||||||
disabled
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ const TYPE_LABELS: Record<string, string> = {
|
|||||||
digest: "יומון",
|
digest: "יומון",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RULE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
binding: "מחייב",
|
||||||
|
interpretive: "פרשני",
|
||||||
|
procedural: "דיוני",
|
||||||
|
obiter: "אמרת אגב",
|
||||||
|
application: "יישום",
|
||||||
|
};
|
||||||
|
|
||||||
const GAP_STATUS_LABELS: Record<string, string> = {
|
const GAP_STATUS_LABELS: Record<string, string> = {
|
||||||
open: "ממתינה לקליטה",
|
open: "ממתינה לקליטה",
|
||||||
uploaded: "הועלתה",
|
uploaded: "הועלתה",
|
||||||
@@ -54,10 +62,12 @@ export function GraphNodePanel({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const isPrecedentLike = node.type === "precedent" || node.type === "halacha";
|
const isPrecedentLike = node.type === "precedent" || node.type === "halacha";
|
||||||
|
const isPrecedent = node.type === "precedent";
|
||||||
|
const isHalacha = node.type === "halacha";
|
||||||
const isGap = node.type === "gap";
|
const isGap = node.type === "gap";
|
||||||
const isDigest = node.type === "digest";
|
const isDigest = node.type === "digest";
|
||||||
// Rich detail (summary/headnote) for precedents — reuses the library hook.
|
// 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);
|
const detail = usePrecedent(detailId);
|
||||||
return (
|
return (
|
||||||
<Card className="bg-surface border-rule shadow-sm w-80 shrink-0 overflow-y-auto">
|
<Card className="bg-surface border-rule shadow-sm w-80 shrink-0 overflow-y-auto">
|
||||||
@@ -81,13 +91,25 @@ export function GraphNodePanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl className="space-y-2 text-sm">
|
<dl className="space-y-2 text-sm">
|
||||||
{isPrecedentLike && (
|
{isPrecedent && <Row label="ציטוטים נכנסים" value={String(node.size)} />}
|
||||||
<Row label="ציטוטים נכנסים" value={String(node.size)} />
|
{isHalacha && (
|
||||||
|
<>
|
||||||
|
<Row label="אזכורים" value={String(node.size)} />
|
||||||
|
{node.source_kind && (
|
||||||
|
<Row
|
||||||
|
label="סוג כלל"
|
||||||
|
value={RULE_TYPE_LABELS[node.source_kind] ?? node.source_kind}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{node.note && (
|
||||||
|
<p className="text-ink text-sm leading-relaxed m-0">{node.note}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{node.practice_area && (
|
{node.practice_area && (
|
||||||
<Row label="תחום" value={PA_LABELS[node.practice_area] ?? node.practice_area} />
|
<Row label="תחום" value={PA_LABELS[node.practice_area] ?? node.practice_area} />
|
||||||
)}
|
)}
|
||||||
{node.source_kind && (
|
{isPrecedent && node.source_kind && (
|
||||||
<Row label="מקור" value={SOURCE_LABELS[node.source_kind] ?? node.source_kind} />
|
<Row label="מקור" value={SOURCE_LABELS[node.source_kind] ?? node.source_kind} />
|
||||||
)}
|
)}
|
||||||
{node.precedent_level && <Row label="דרגה" value={node.precedent_level} />}
|
{node.precedent_level && <Row label="דרגה" value={node.precedent_level} />}
|
||||||
|
|||||||
@@ -401,6 +401,7 @@ function RankList({
|
|||||||
const LEGENDS: Record<ColorBy, { color: string; label: string }[]> = {
|
const LEGENDS: Record<ColorBy, { color: string; label: string }[]> = {
|
||||||
type: [
|
type: [
|
||||||
{ color: "#1e3a5f", label: "פסיקה" },
|
{ color: "#1e3a5f", label: "פסיקה" },
|
||||||
|
{ color: "#b45309", label: "הלכה" },
|
||||||
{ color: "#a97d3a", label: "נושא" },
|
{ color: "#a97d3a", label: "נושא" },
|
||||||
{ color: "#475569", 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(80,90,110,0.7)", label: "ציטוט" },
|
||||||
{ color: "rgba(169,125,58,0.5)", label: "נושא/תחום" },
|
{ color: "rgba(169,125,58,0.5)", label: "נושא/תחום" },
|
||||||
{ color: "rgba(47,111,122,0.7)", label: "יומון מסכם" },
|
{ color: "rgba(47,111,122,0.7)", label: "יומון מסכם" },
|
||||||
|
{ color: "rgba(180,83,9,0.6)", label: "כלל/אזכור" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function Legend({ colorBy }: { colorBy: ColorBy }) {
|
function Legend({ colorBy }: { colorBy: ColorBy }) {
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export type GraphEdgeType =
|
|||||||
| "in_area"
|
| "in_area"
|
||||||
| "corroborates"
|
| "corroborates"
|
||||||
| "equivalent"
|
| "equivalent"
|
||||||
| "covers";
|
| "covers"
|
||||||
|
| "extracted_from";
|
||||||
|
|
||||||
export type GraphNode = {
|
export type GraphNode = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
119
web/graph_api.py
119
web/graph_api.py
@@ -400,6 +400,117 @@ async def _add_digests(
|
|||||||
edges.extend(dig_edges)
|
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 ────────────────────────────────────────────
|
# ── Endpoints' core logic ────────────────────────────────────────────
|
||||||
async def build_corpus_graph(
|
async def build_corpus_graph(
|
||||||
pool: asyncpg.Pool,
|
pool: asyncpg.Pool,
|
||||||
@@ -482,6 +593,10 @@ async def build_corpus_graph(
|
|||||||
edges.extend(gap_edges)
|
edges.extend(gap_edges)
|
||||||
if "digest" in types:
|
if "digest" in types:
|
||||||
await _add_digests(conn, prec_id_list, nodes, edges)
|
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:
|
if metrics:
|
||||||
_stamp_metrics(nodes, edges)
|
_stamp_metrics(nodes, edges)
|
||||||
@@ -622,6 +737,10 @@ async def build_node_neighborhood(
|
|||||||
edges.extend(gap_edges)
|
edges.extend(gap_edges)
|
||||||
if "digest" in forced_types:
|
if "digest" in forced_types:
|
||||||
await _add_digests(conn, prec_id_list, nodes, edges)
|
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(
|
return CorpusGraph(
|
||||||
nodes=nodes,
|
nodes=nodes,
|
||||||
|
|||||||
Reference in New Issue
Block a user