feat: add the relationship graph with extraction, corrections and explorer
Phase 11. Entities are mind-scoped nodes with canonical matching,
aliases and deterministic kind guessing (hostnames and hyphenated ids
are systems, capitalised names people, file suffixes documents).
Relationships carry a relation from the shared vocabulary, confidence,
source, reinforcement count and the event that produced them; links tie
entities to memories, beliefs, events, goals and predictions.
The perceive and attend handlers upsert the entities a message or
observation mentions and record co-mention edges cited to the event, so
the graph grows only from what was actually seen. Operators can merge
("these are the same": edges and links move, the old name becomes an
alias, the old id still resolves), record that two entities are
different, correct kinds and add typed edges; every correction is an
operator action and a corrections row.
The explorer renders a bounded subgraph on a canvas with a small
force-directed layout in plain JavaScript, with kind filters, focus on
a neighbourhood and a side panel per entity. API, entity page, CLI and
docs/graph.md.
This commit is contained in:
@@ -209,3 +209,10 @@ tr.unread td { font-weight: 500; }
|
||||
.narrative { font-size: 1rem; line-height: 1.6; white-space: pre-wrap; }
|
||||
.warnlist li { padding: .3rem 0; border-bottom: 1px solid var(--border); }
|
||||
.topbar .navcount { background: var(--danger); color: #fff; border-radius: 999px; font-size: .7rem; padding: 0 .4rem; margin-left: .3rem; }
|
||||
|
||||
/* Graph */
|
||||
.graph-card { padding: .5rem; }
|
||||
canvas.graph { width: 100%; height: 560px; display: block; border-radius: 8px; background: var(--surface-2); cursor: grab; }
|
||||
.legend { display: flex; flex-wrap: wrap; gap: .6rem; padding: .4rem .3rem 0; font-size: .78rem; color: var(--muted); }
|
||||
.legend-item i { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: .3rem; vertical-align: middle; }
|
||||
.legend-item i.k-person { background: #2f6fed; } .legend-item i.k-system { background: #0ea5a4; } .legend-item i.k-project { background: #16a34a; } .legend-item i.k-document { background: #d97706; } .legend-item i.k-concept { background: #8b5cf6; } .legend-item i.k-repository { background: #64748b; } .legend-item i.k-organisation { background: #db2777; } .legend-item i.k-place { background: #0891b2; } .legend-item i.k-other { background: #9ca3af; }
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/* Relationship explorer: a small force-directed layout on canvas. No library.
|
||||
Data comes from the graph API as {nodes:[{id,name,kind,mention_count}], edges:[{from_entity,to_entity,relation,confidence,count}]}.
|
||||
Bounded by the server (node cap), so the simulation stays cheap. */
|
||||
(function () {
|
||||
"use strict";
|
||||
const KIND_COLOURS = { person: "#2f6fed", system: "#0ea5a4", project: "#16a34a", document: "#d97706", concept: "#8b5cf6", repository: "#64748b", organisation: "#db2777", place: "#0891b2", other: "#9ca3af" };
|
||||
|
||||
function explorer(canvas, opts) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
let nodes = [], edges = [], byId = {}, selected = null, hover = null, dragging = null, running = true;
|
||||
let scale = 1, ox = 0, oy = 0;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
function resize() {
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
canvas.width = w * dpr; canvas.height = h * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
function load(data) {
|
||||
const prev = byId; byId = {};
|
||||
nodes = (data.nodes || []).map((n, i) => {
|
||||
const p = prev[n.id];
|
||||
const angle = (i / Math.max(1, data.nodes.length)) * Math.PI * 2;
|
||||
const node = Object.assign({}, n, { x: p ? p.x : canvas.clientWidth / 2 + Math.cos(angle) * 120, y: p ? p.y : canvas.clientHeight / 2 + Math.sin(angle) * 120, vx: 0, vy: 0, r: 6 + Math.min(14, Math.sqrt(n.mention_count || 1) * 2) });
|
||||
byId[n.id] = node; return node;
|
||||
});
|
||||
edges = (data.edges || []).filter((e) => byId[e.from_entity] && byId[e.to_entity]);
|
||||
running = true; energy = 1;
|
||||
}
|
||||
let energy = 1;
|
||||
function step() {
|
||||
if (!running) return;
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
// repulsion
|
||||
for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) {
|
||||
const a = nodes[i], b = nodes[j]; let dx = b.x - a.x, dy = b.y - a.y; let d2 = dx * dx + dy * dy + 0.01; const d = Math.sqrt(d2);
|
||||
const f = 1800 / d2; dx /= d; dy /= d; a.vx -= dx * f; a.vy -= dy * f; b.vx += dx * f; b.vy += dy * f;
|
||||
}
|
||||
// springs
|
||||
for (const e of edges) {
|
||||
const a = byId[e.from_entity], b = byId[e.to_entity]; const dx = b.x - a.x, dy = b.y - a.y; const d = Math.sqrt(dx * dx + dy * dy) + 0.01;
|
||||
const target = 90 - 20 * Math.min(1, (e.count || 1) / 5); const f = (d - target) * 0.02; a.vx += (dx / d) * f; a.vy += (dy / d) * f; b.vx -= (dx / d) * f; b.vy -= (dy / d) * f;
|
||||
}
|
||||
// gravity to centre, integrate
|
||||
let motion = 0;
|
||||
for (const n of nodes) {
|
||||
if (n === dragging) continue;
|
||||
n.vx += (w / 2 - n.x) * 0.002; n.vy += (h / 2 - n.y) * 0.002; n.vx *= 0.82; n.vy *= 0.82; n.x += n.vx * energy; n.y += n.vy * energy; motion += Math.abs(n.vx) + Math.abs(n.vy);
|
||||
}
|
||||
energy = Math.max(0.05, energy * 0.995);
|
||||
if (motion < 0.02 * nodes.length) running = false;
|
||||
}
|
||||
function draw() {
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight; ctx.clearRect(0, 0, w, h);
|
||||
ctx.save(); ctx.translate(ox, oy); ctx.scale(scale, scale);
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const muted = cs.getPropertyValue("--muted").trim() || "#888", text = cs.getPropertyValue("--text").trim() || "#111", surface = cs.getPropertyValue("--surface").trim() || "#fff";
|
||||
for (const e of edges) {
|
||||
const a = byId[e.from_entity], b = byId[e.to_entity]; ctx.strokeStyle = muted; ctx.globalAlpha = 0.25 + 0.6 * (e.confidence || 0.3); ctx.lineWidth = 1 + Math.min(3, (e.count || 1) / 3);
|
||||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
||||
if (scale > 0.9 && (e === hoverEdge || selected && (selected.id === a.id || selected.id === b.id))) {
|
||||
ctx.globalAlpha = 1; ctx.fillStyle = muted; ctx.font = "10px sans-serif"; ctx.fillText(e.relation.replace(/_/g, " "), (a.x + b.x) / 2 + 4, (a.y + b.y) / 2 - 4);
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
for (const n of nodes) {
|
||||
ctx.beginPath(); ctx.arc(n.x, n.y, n.r, 0, Math.PI * 2); ctx.fillStyle = KIND_COLOURS[n.kind] || KIND_COLOURS.other; ctx.fill();
|
||||
if (n === selected || n === hover) { ctx.lineWidth = 3; ctx.strokeStyle = text; ctx.stroke(); }
|
||||
else { ctx.lineWidth = 1.5; ctx.strokeStyle = surface; ctx.stroke(); }
|
||||
ctx.fillStyle = text; ctx.font = (n === selected ? "bold " : "") + "12px sans-serif"; ctx.fillText(n.name, n.x + n.r + 4, n.y + 4);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
let hoverEdge = null;
|
||||
function loop() { step(); draw(); requestAnimationFrame(loop); }
|
||||
function toWorld(ev) { const rect = canvas.getBoundingClientRect(); return { x: (ev.clientX - rect.left - ox) / scale, y: (ev.clientY - rect.top - oy) / scale }; }
|
||||
function nodeAt(p) { for (let i = nodes.length - 1; i >= 0; i--) { const n = nodes[i]; if ((p.x - n.x) ** 2 + (p.y - n.y) ** 2 <= (n.r + 4) ** 2) return n; } return null; }
|
||||
canvas.addEventListener("mousemove", (ev) => { const p = toWorld(ev); if (dragging) { dragging.x = p.x; dragging.y = p.y; running = true; energy = Math.max(energy, 0.3); return; } hover = nodeAt(p); canvas.style.cursor = hover ? "pointer" : "grab"; });
|
||||
canvas.addEventListener("mousedown", (ev) => { const p = toWorld(ev); dragging = nodeAt(p); if (!dragging) { panStart = { x: ev.clientX - ox, y: ev.clientY - oy }; } });
|
||||
let panStart = null;
|
||||
window.addEventListener("mousemove", (ev) => { if (panStart) { ox = ev.clientX - panStart.x; oy = ev.clientY - panStart.y; } });
|
||||
window.addEventListener("mouseup", () => { dragging = null; panStart = null; });
|
||||
canvas.addEventListener("click", (ev) => { const n = nodeAt(toWorld(ev)); selected = n; if (opts.onSelect) opts.onSelect(n); });
|
||||
canvas.addEventListener("dblclick", (ev) => { const n = nodeAt(toWorld(ev)); if (n && opts.onFocus) opts.onFocus(n); });
|
||||
canvas.addEventListener("wheel", (ev) => { ev.preventDefault(); const f = ev.deltaY < 0 ? 1.1 : 0.9; scale = Math.min(3, Math.max(0.3, scale * f)); }, { passive: false });
|
||||
window.addEventListener("resize", resize);
|
||||
resize(); loop();
|
||||
return { load, select: (id) => { selected = byId[id] || null; }, colours: KIND_COLOURS };
|
||||
}
|
||||
window.noemaGraph = { explorer };
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const canvas = document.querySelector("canvas[data-graph]");
|
||||
if (!canvas) return;
|
||||
const panel = document.getElementById("entity-panel");
|
||||
const form = document.querySelector("[data-graph-filter]");
|
||||
const base = canvas.dataset.graph;
|
||||
const mindSlug = canvas.dataset.mind;
|
||||
const ex = explorer(canvas, {
|
||||
onSelect: (n) => { if (!n) { panel.innerHTML = '<p class="muted">Click a node.</p>'; return; } fetchEntity(n.id); },
|
||||
onFocus: (n) => { if (n) { const u = new URL(location.href); u.searchParams.set("focus", n.id); location.href = u.toString(); } },
|
||||
});
|
||||
function fetchEntity(id) {
|
||||
fetch(`/minds/${mindSlug}/entities/${id}?partial=1`, { credentials: "same-origin" }).then((r) => r.text()).then((html) => { panel.innerHTML = html; }).catch(() => { panel.textContent = "Could not load entity."; });
|
||||
}
|
||||
function loadGraph() {
|
||||
const params = new URLSearchParams(form ? new FormData(form) : {});
|
||||
fetch(base + (params.toString() ? "?" + params.toString() : ""), { credentials: "same-origin" }).then((r) => r.json()).then((data) => {
|
||||
ex.load(data);
|
||||
const info = document.querySelector("[data-graph-info]");
|
||||
if (info) info.textContent = `${data.nodes.length} of ${data.total_entities} entities · ${data.edges.length} relationships`;
|
||||
const focus = params.get("focus"); if (focus) { ex.select(focus); fetchEntity(focus); }
|
||||
});
|
||||
}
|
||||
if (form) form.addEventListener("change", loadGraph);
|
||||
loadGraph();
|
||||
});
|
||||
})();
|
||||
@@ -8,6 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||
<script src="/static/app.js" defer></script>
|
||||
{{if hasSuffix .Path "/graph"}}<script src="/static/graph.js" defer></script>{{end}}
|
||||
</head>
|
||||
<body class="{{if .Principal}}app{{else}}bare{{end}}">
|
||||
{{if .Principal}}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{{define "content"}}
|
||||
{{$m := .Data.Mind}}{{$e := .Data.Entity}}
|
||||
<div class="pagehead"><p class="muted small"><a href="/minds/{{$m.Slug}}">{{$m.DisplayName}}</a> › <a href="/minds/{{$m.Slug}}/graph">relationships</a></p><h1><span class="badge">{{$e.Kind}}</span> {{$e.Name}}</h1><p class="muted">{{$e.MentionCount}} mentions · first {{date $e.FirstSeen}} · last {{ago $e.LastSeen}}{{if $e.Aliases}} · also known as {{join $e.Aliases ", "}}{{end}}</p></div>
|
||||
<section class="grid cols-3-1">
|
||||
<div class="stack">{{template "entity_panel" .}}</div>
|
||||
<div class="stack">
|
||||
{{if .Principal.User.Role.AtLeast "operator"}}
|
||||
<div class="card"><h2>Correct</h2>
|
||||
<form method="post" action="/minds/{{$m.Slug}}/entities/{{$e.ID}}/merge" class="stack"><input type="hidden" name="_csrf" value="{{.CSRF}}"><label>This is the same as <input name="other" placeholder="other entity name or id" required></label><div><button class="btn small">Merge into this</button></div></form>
|
||||
<form method="post" action="/minds/{{$m.Slug}}/entities/{{$e.ID}}/distinct" class="stack"><input type="hidden" name="_csrf" value="{{.CSRF}}"><label>This is different from <input name="other" placeholder="other entity name" required></label><div><button class="btn small">Record as different</button></div></form>
|
||||
<form method="post" action="/minds/{{$m.Slug}}/entities/{{$e.ID}}/update" class="stack"><input type="hidden" name="_csrf" value="{{.CSRF}}"><label>Kind <select name="kind">{{range entityKinds}}<option value="{{.}}" {{if eq . $e.Kind}}selected{{end}}>{{.}}</option>{{end}}</select></label><label>Description <input name="description" value="{{$e.Description}}"></label><div><button class="btn small">Save</button></div></form>
|
||||
</div>{{end}}
|
||||
<div class="card small"><span class="muted">ID</span> <span class="mono">{{$e.ID}}</span> · <a href="/minds/{{$m.Slug}}/graph?focus={{$e.ID}}">neighbourhood</a></div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,18 @@
|
||||
{{define "content"}}
|
||||
{{$m := .Data.Mind}}
|
||||
<div class="pagehead mindhead"><div><h1>{{$m.DisplayName}} <span class="muted small">relationships</span></h1><p class="muted">{{.Data.Stats.Entities}} entities · {{.Data.Stats.Edges}} relationships · {{range $k, $v := .Data.Stats.ByKind}}<span class="badge">{{$k}} {{$v}}</span> {{end}}</p></div>
|
||||
<span class="muted small" data-graph-info></span></div>
|
||||
{{template "mind_tabs" (dict "M" $m "Active" "graph")}}
|
||||
<form class="card filterbar" data-graph-filter onsubmit="return false">
|
||||
<label>Kind <select name="kind"><option value="">all</option>{{range entityKinds}}<option value="{{.}}" {{if eq (print .) $.Data.Kind}}selected{{end}}>{{.}}</option>{{end}}</select></label>
|
||||
<label>Limit <select name="limit"><option>40</option><option selected>80</option><option>150</option><option>300</option></select></label>
|
||||
{{if .Data.Focus}}<input type="hidden" name="focus" value="{{.Data.Focus}}"><a class="btn small" href="/minds/{{$m.Slug}}/graph">Clear focus</a>{{end}}
|
||||
<span class="muted small">Drag to pan, scroll to zoom, click a node for details, double-click to focus on its neighbourhood.</span>
|
||||
</form>
|
||||
<section class="grid cols-3-1">
|
||||
<div class="card graph-card"><canvas data-graph="/api/v1/minds/{{$m.Slug}}/graph" data-mind="{{$m.Slug}}" class="graph" aria-label="Relationship graph"></canvas>
|
||||
<div class="legend">{{range entityKinds}}<span class="legend-item"><i class="k-{{.}}"></i>{{.}}</span>{{end}}</div></div>
|
||||
<div class="card" id="entity-panel"><p class="muted">Click a node.</p></div>
|
||||
</section>
|
||||
{{if .Data.Entities}}<div class="card"><h2>Most mentioned</h2><table class="table compact"><tbody>{{range .Data.Entities}}<tr><td><span class="badge">{{.Kind}}</span></td><td><a href="/minds/{{$m.Slug}}/entities/{{.ID}}">{{.Name}}</a>{{if .Aliases}} <span class="muted small">also {{join .Aliases ", "}}</span>{{end}}</td><td class="num">{{.MentionCount}}×</td><td class="small muted">{{ago .LastSeen}}</td></tr>{{end}}</tbody></table></div>{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,10 @@
|
||||
{{define "entity_panel"}}{{$m := .Data.Mind}}{{$e := .Data.Entity}}
|
||||
<div class="card">
|
||||
<h2><span class="badge">{{$e.Kind}}</span> <a href="/minds/{{$m.Slug}}/entities/{{$e.ID}}">{{$e.Name}}</a></h2>
|
||||
{{with $e.Description}}<p>{{.}}</p>{{end}}
|
||||
<h3>Relationships</h3>
|
||||
{{if .Data.Edges}}<ul class="plain small">{{range .Data.Edges}}<li>{{if eq .From.ID $e.ID}}<em>{{.Relation}}</em> → <a href="/minds/{{$m.Slug}}/entities/{{.To.ID}}">{{.To.Name}}</a>{{else}}<a href="/minds/{{$m.Slug}}/entities/{{.From.ID}}">{{.From.Name}}</a> <em>{{.Relation}}</em> →{{end}} <span class="muted">{{conf .Confidence}} · ×{{.Count}} · {{.Source}}{{if .EventID}} · <a href="/events/{{.EventID}}">event</a>{{end}}</span></li>{{end}}</ul>{{else}}<p class="muted small">None.</p>{{end}}
|
||||
<h3>Involved in</h3>
|
||||
{{if .Data.Links}}<ul class="plain small">{{range .Data.Links}}<li><span class="badge">{{.Kind}}</span> {{if eq .Kind "memory"}}<a href="/minds/{{$m.Slug}}/memory/{{.RefID}}">memory</a>{{else if eq .Kind "belief"}}<a href="/minds/{{$m.Slug}}/beliefs/{{.RefID}}">belief</a>{{else if eq .Kind "event"}}<a href="/events/{{.RefID}}">event</a>{{else}}{{.RefID}}{{end}} <span class="muted">{{ago .CreatedAt}}</span></li>{{end}}</ul>{{else}}<p class="muted small">Nothing linked yet.</p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user