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:
2026-06-08 05:13:09 +00:00
parent cc9adc5c1f
commit ef21cb93e5
6 changed files with 159 additions and 7 deletions

View File

@@ -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],

View File

@@ -272,10 +272,9 @@ export function GraphFilterPanel({
onCheckedChange={(v) => onChange({ showDigests: v })}
/>
<ToggleRow
label="הלכות (שלב ב׳)"
label="הלכות"
checked={controls.showHalachot}
onCheckedChange={(v) => onChange({ showHalachot: v })}
disabled
/>
</div>
</CardContent>

View File

@@ -25,6 +25,14 @@ const TYPE_LABELS: Record<string, string> = {
digest: "יומון",
};
const RULE_TYPE_LABELS: Record<string, string> = {
binding: "מחייב",
interpretive: "פרשני",
procedural: "דיוני",
obiter: "אמרת אגב",
application: "יישום",
};
const GAP_STATUS_LABELS: Record<string, string> = {
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 (
<Card className="bg-surface border-rule shadow-sm w-80 shrink-0 overflow-y-auto">
@@ -81,13 +91,25 @@ export function GraphNodePanel({
</div>
<dl className="space-y-2 text-sm">
{isPrecedentLike && (
<Row label="ציטוטים נכנסים" value={String(node.size)} />
{isPrecedent && <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 && (
<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} />
)}
{node.precedent_level && <Row label="דרגה" value={node.precedent_level} />}

View File

@@ -401,6 +401,7 @@ function RankList({
const LEGENDS: Record<ColorBy, { color: string; label: string }[]> = {
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 }) {