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
+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}}