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:
Andy Dixon
2026-06-29 19:28:55 +01:00
parent 703d080d13
commit 99a89ba847
25 changed files with 1455 additions and 22 deletions
+1
View File
@@ -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}}
+16
View File
@@ -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}}
+18
View File
@@ -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}}
+10
View File
@@ -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}}