Merge pull request 'feat(graph): daily-digest (יומון) discovery layer (corpus graph PR E)' (#132) from worktree-graph-digests into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 52s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 52s
This commit was merged in pull request #132.
This commit is contained in:
@@ -51,6 +51,7 @@ const NODE_COLORS: Record<string, string> = {
|
|||||||
topic: "#a97d3a", // gold — hubs stand out
|
topic: "#a97d3a", // gold — hubs stand out
|
||||||
practice_area: "#475569", // slate
|
practice_area: "#475569", // slate
|
||||||
gap: "#94a3b8", // faint slate — research gap (not in corpus)
|
gap: "#94a3b8", // faint slate — research gap (not in corpus)
|
||||||
|
digest: "#2f6f7a", // teal — daily-digest (יומון) discovery layer
|
||||||
};
|
};
|
||||||
|
|
||||||
const TREATMENT_COLORS: Record<string, string> = {
|
const TREATMENT_COLORS: Record<string, string> = {
|
||||||
@@ -114,6 +115,7 @@ export function colorForNode(n: GraphNode, colorBy: ColorBy): string {
|
|||||||
|
|
||||||
export function radiusForNode(n: GraphNode, sizeBy: SizeBy): number {
|
export function radiusForNode(n: GraphNode, sizeBy: SizeBy): number {
|
||||||
if (n.type === "topic" || n.type === "practice_area") return 5;
|
if (n.type === "topic" || n.type === "practice_area") return 5;
|
||||||
|
if (n.type === "digest") return 4;
|
||||||
if (sizeBy === "pagerank" && n.pagerank != null) {
|
if (sizeBy === "pagerank" && n.pagerank != null) {
|
||||||
return 3 + Math.sqrt(n.pagerank) * 18;
|
return 3 + Math.sqrt(n.pagerank) * 18;
|
||||||
}
|
}
|
||||||
@@ -301,6 +303,9 @@ export function GraphCanvas({
|
|||||||
if (link.type === "tagged" || link.type === "in_area") {
|
if (link.type === "tagged" || link.type === "in_area") {
|
||||||
return "rgba(169,125,58,0.16)";
|
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)";
|
return "rgba(80,90,110,0.22)";
|
||||||
},
|
},
|
||||||
[activeId],
|
[activeId],
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export type GraphControls = {
|
|||||||
showPracticeAreas: boolean;
|
showPracticeAreas: boolean;
|
||||||
showHalachot: boolean;
|
showHalachot: boolean;
|
||||||
showGaps: boolean;
|
showGaps: boolean;
|
||||||
|
showDigests: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALL = "__all__";
|
const ALL = "__all__";
|
||||||
@@ -265,6 +266,11 @@ export function GraphFilterPanel({
|
|||||||
checked={controls.showGaps}
|
checked={controls.showGaps}
|
||||||
onCheckedChange={(v) => onChange({ showGaps: v })}
|
onCheckedChange={(v) => onChange({ showGaps: v })}
|
||||||
/>
|
/>
|
||||||
|
<ToggleRow
|
||||||
|
label="יומונים (כל יום)"
|
||||||
|
checked={controls.showDigests}
|
||||||
|
onCheckedChange={(v) => onChange({ showDigests: v })}
|
||||||
|
/>
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
label="הלכות (שלב ב׳)"
|
label="הלכות (שלב ב׳)"
|
||||||
checked={controls.showHalachot}
|
checked={controls.showHalachot}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const TYPE_LABELS: Record<string, string> = {
|
|||||||
topic: "נושא",
|
topic: "נושא",
|
||||||
practice_area: "תחום",
|
practice_area: "תחום",
|
||||||
gap: "פסיקה חסרה",
|
gap: "פסיקה חסרה",
|
||||||
|
digest: "יומון",
|
||||||
};
|
};
|
||||||
|
|
||||||
const GAP_STATUS_LABELS: Record<string, string> = {
|
const GAP_STATUS_LABELS: Record<string, string> = {
|
||||||
@@ -52,6 +53,7 @@ export function GraphNodePanel({
|
|||||||
}) {
|
}) {
|
||||||
const isPrecedentLike = node.type === "precedent" || node.type === "halacha";
|
const isPrecedentLike = node.type === "precedent" || node.type === "halacha";
|
||||||
const isGap = node.type === "gap";
|
const isGap = node.type === "gap";
|
||||||
|
const isDigest = node.type === "digest";
|
||||||
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">
|
||||||
<CardContent className="space-y-4 p-4">
|
<CardContent className="space-y-4 p-4">
|
||||||
@@ -98,7 +100,19 @@ export function GraphNodePanel({
|
|||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isPrecedentLike && !isGap && (
|
{isDigest && (
|
||||||
|
<>
|
||||||
|
{node.note && (
|
||||||
|
<p className="text-ink text-sm leading-relaxed m-0">{node.note}</p>
|
||||||
|
)}
|
||||||
|
{node.court && <Row label="ערכאה" value={node.court} />}
|
||||||
|
{node.date && <Row label="תאריך" value={node.date.slice(0, 10)} />}
|
||||||
|
<p className="text-ink-muted text-xs leading-relaxed m-0">
|
||||||
|
סיכום יומי מ״כל יום״ — מצביע על הפסיקה שהוא מנתח.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isPrecedentLike && !isGap && !isDigest && (
|
||||||
<p className="text-ink-muted text-xs leading-relaxed m-0">
|
<p className="text-ink-muted text-xs leading-relaxed m-0">
|
||||||
לחיצה על נקודה זו מתמקדת בשכניה — כל הפסיקות המשויכות אליה.
|
לחיצה על נקודה זו מתמקדת בשכניה — כל הפסיקות המשויכות אליה.
|
||||||
</p>
|
</p>
|
||||||
@@ -122,6 +136,15 @@ export function GraphNodePanel({
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isDigest && (
|
||||||
|
<Button asChild variant="outline" className="w-full">
|
||||||
|
<Link href="/digests">
|
||||||
|
<ExternalLink className="size-4 me-2" />
|
||||||
|
לעמוד היומונים
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export function GraphView() {
|
|||||||
showPracticeAreas: true,
|
showPracticeAreas: true,
|
||||||
showHalachot: false,
|
showHalachot: false,
|
||||||
showGaps: false,
|
showGaps: false,
|
||||||
|
showDigests: false,
|
||||||
});
|
});
|
||||||
const facets = useGraphFacets().data;
|
const facets = useGraphFacets().data;
|
||||||
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
||||||
@@ -80,12 +81,14 @@ export function GraphView() {
|
|||||||
if (controls.showPracticeAreas) t.push("practice_area");
|
if (controls.showPracticeAreas) t.push("practice_area");
|
||||||
if (controls.showHalachot) t.push("halacha");
|
if (controls.showHalachot) t.push("halacha");
|
||||||
if (controls.showGaps) t.push("gap");
|
if (controls.showGaps) t.push("gap");
|
||||||
|
if (controls.showDigests) t.push("digest");
|
||||||
return t.join(",");
|
return t.join(",");
|
||||||
}, [
|
}, [
|
||||||
controls.showTopics,
|
controls.showTopics,
|
||||||
controls.showPracticeAreas,
|
controls.showPracticeAreas,
|
||||||
controls.showHalachot,
|
controls.showHalachot,
|
||||||
controls.showGaps,
|
controls.showGaps,
|
||||||
|
controls.showDigests,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Metrics are needed when colouring by cluster or sizing by a centrality.
|
// Metrics are needed when colouring by cluster or sizing by a centrality.
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ export type GraphNodeType =
|
|||||||
| "halacha"
|
| "halacha"
|
||||||
| "topic"
|
| "topic"
|
||||||
| "practice_area"
|
| "practice_area"
|
||||||
| "gap";
|
| "gap"
|
||||||
|
| "digest";
|
||||||
|
|
||||||
export type GraphEdgeType =
|
export type GraphEdgeType =
|
||||||
| "cites"
|
| "cites"
|
||||||
@@ -27,7 +28,8 @@ export type GraphEdgeType =
|
|||||||
| "tagged"
|
| "tagged"
|
||||||
| "in_area"
|
| "in_area"
|
||||||
| "corroborates"
|
| "corroborates"
|
||||||
| "equivalent";
|
| "equivalent"
|
||||||
|
| "covers";
|
||||||
|
|
||||||
export type GraphNode = {
|
export type GraphNode = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -45,6 +47,8 @@ export type GraphNode = {
|
|||||||
community: number | null; // dense cluster id, 0 = largest
|
community: number | null; // dense cluster id, 0 = largest
|
||||||
gap_status: string | null; // gap nodes only — open|uploaded|closed|irrelevant
|
gap_status: string | null; // gap nodes only — open|uploaded|closed|irrelevant
|
||||||
missing_precedent_id: string | null; // gap nodes only
|
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 = {
|
export type GraphFacets = {
|
||||||
|
|||||||
106
web/graph_api.py
106
web/graph_api.py
@@ -38,7 +38,7 @@ from pydantic import BaseModel
|
|||||||
from web import graph_metrics
|
from web import graph_metrics
|
||||||
|
|
||||||
# ── Node-type vocabulary ─────────────────────────────────────────────
|
# ── 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")
|
DEFAULT_NODE_TYPES = ("precedent", "topic", "practice_area")
|
||||||
NODE_CAP_DEFAULT = 400
|
NODE_CAP_DEFAULT = 400
|
||||||
NODE_CAP_MAX = 1500
|
NODE_CAP_MAX = 1500
|
||||||
@@ -72,6 +72,9 @@ class GraphNode(BaseModel):
|
|||||||
# Gap nodes only — research-gap status from missing_precedents (best-effort).
|
# Gap nodes only — research-gap status from missing_precedents (best-effort).
|
||||||
gap_status: str | None = None # open | uploaded | closed | irrelevant
|
gap_status: str | None = None # open | uploaded | closed | irrelevant
|
||||||
missing_precedent_id: str | None = None
|
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):
|
class GraphFacets(BaseModel):
|
||||||
@@ -306,6 +309,97 @@ async def _gap_nodes_and_edges(
|
|||||||
return nodes, 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 ────────────────────────────────────────────
|
# ── Endpoints' core logic ────────────────────────────────────────────
|
||||||
async def build_corpus_graph(
|
async def build_corpus_graph(
|
||||||
pool: asyncpg.Pool,
|
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
|
total_available = int(prec_rows[0]["total_available"]) if prec_rows else 0
|
||||||
nodes = [_precedent_node(r) for r in prec_rows]
|
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)
|
hub_nodes, edges = await _edges_and_hubs(conn, prec_rows, types)
|
||||||
nodes.extend(hub_nodes)
|
nodes.extend(hub_nodes)
|
||||||
if "gap" in types:
|
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)
|
nodes.extend(gap_nodes)
|
||||||
edges.extend(gap_edges)
|
edges.extend(gap_edges)
|
||||||
|
if "digest" in types:
|
||||||
|
await _add_digests(conn, prec_id_list, nodes, edges)
|
||||||
|
|
||||||
if metrics:
|
if metrics:
|
||||||
_stamp_metrics(nodes, edges)
|
_stamp_metrics(nodes, edges)
|
||||||
@@ -516,12 +613,15 @@ async def build_node_neighborhood(
|
|||||||
ids,
|
ids,
|
||||||
)
|
)
|
||||||
nodes = [_precedent_node(r) for r in prec_rows]
|
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)
|
hub_nodes, edges = await _edges_and_hubs(conn, prec_rows, forced_types)
|
||||||
nodes.extend(hub_nodes)
|
nodes.extend(hub_nodes)
|
||||||
if "gap" in forced_types:
|
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)
|
nodes.extend(gap_nodes)
|
||||||
edges.extend(gap_edges)
|
edges.extend(gap_edges)
|
||||||
|
if "digest" in forced_types:
|
||||||
|
await _add_digests(conn, prec_id_list, nodes, edges)
|
||||||
|
|
||||||
return CorpusGraph(
|
return CorpusGraph(
|
||||||
nodes=nodes,
|
nodes=nodes,
|
||||||
|
|||||||
Reference in New Issue
Block a user