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
+118
View File
@@ -0,0 +1,118 @@
package ui
import (
"bytes"
"net/http"
"github.com/andydixon/noema/internal/api"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/graph"
"github.com/andydixon/noema/pkg/ids"
)
func (u *UI) graphRoutes(m *http.ServeMux) {
m.Handle("GET /minds/{mind}/graph", u.require(auth.RoleObserver, u.graphPage))
m.Handle("GET /minds/{mind}/entities/{id}", u.require(auth.RoleObserver, u.entityPage))
m.Handle("POST /minds/{mind}/entities/{id}/merge", u.require(auth.RoleOperator, u.entityMerge))
m.Handle("POST /minds/{mind}/entities/{id}/distinct", u.require(auth.RoleOperator, u.entityDistinct))
m.Handle("POST /minds/{mind}/entities/{id}/update", u.require(auth.RoleOperator, u.entityUpdate))
}
func (u *UI) graphPage(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
st, _ := u.deps.Graph.Stats(r.Context(), m.ID)
top, _ := u.deps.Graph.Search(r.Context(), m.ID, "", graph.Kind(r.URL.Query().Get("kind")), 25)
u.render(w, r, "graph", m.DisplayName+" · Relationships", map[string]any{"Mind": m, "Stats": st, "Entities": top, "Kind": r.URL.Query().Get("kind"), "Focus": r.URL.Query().Get("focus")})
}
func (u *UI) entityPage(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
u.errorPage(w, r, 404, "No such entity.")
return
}
d, err := u.entF(r, m.ID, id)
if err != nil {
u.errorPage(w, r, 404, "No such entity.")
return
}
data := map[string]any{"Mind": m, "Entity": d.Entity, "Edges": d.Edges, "Links": d.Links}
if r.URL.Query().Get("partial") == "1" {
// Rendered into the graph page's side panel by graph.js.
t := u.pages["entity"]
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, "entity_panel", View{Data: data}); err != nil {
http.Error(w, "render failed", 500)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf.WriteTo(w) //nolint:errcheck
return
}
u.render(w, r, "entity", d.Entity.Name, data)
}
func (u *UI) entity(w http.ResponseWriter, r *http.Request) (api.EntityDetail, bool) {
m, ok := u.mind(w, r)
if !ok {
return api.EntityDetail{}, false
}
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
u.errorPage(w, r, 404, "No such entity.")
return api.EntityDetail{}, false
}
d, err := u.entF(r, m.ID, id)
if err != nil {
u.errorPage(w, r, 404, "No such entity.")
return api.EntityDetail{}, false
}
return d, true
}
func (u *UI) entityMerge(w http.ResponseWriter, r *http.Request) {
m, _ := u.mind(w, r)
d, ok := u.entity(w, r)
if !ok {
return
}
if err := u.mergeF(r, m.ID, d.Entity, r.PostFormValue("other")); err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/entities/"+d.Entity.ID.String())
return
}
u.flash(w, r, "ok", "Entities merged; the old name is now an alias.", "/minds/"+m.Slug+"/entities/"+d.Entity.ID.String())
}
func (u *UI) entityDistinct(w http.ResponseWriter, r *http.Request) {
m, _ := u.mind(w, r)
d, ok := u.entity(w, r)
if !ok {
return
}
if err := u.distinctF(r, m.ID, d.Entity, r.PostFormValue("other")); err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/entities/"+d.Entity.ID.String())
return
}
u.flash(w, r, "ok", "Recorded as different entities.", "/minds/"+m.Slug+"/entities/"+d.Entity.ID.String())
}
func (u *UI) entityUpdate(w http.ResponseWriter, r *http.Request) {
m, _ := u.mind(w, r)
d, ok := u.entity(w, r)
if !ok {
return
}
if err := u.deps.Graph.Update(r.Context(), m.ID, d.Entity.ID, graph.Kind(r.PostFormValue("kind")), r.PostFormValue("description")); err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/entities/"+d.Entity.ID.String())
return
}
u.deps.Audit.Log(r.Context(), "entity.update", "entity", d.Entity.ID.String(), map[string]any{"kind": r.PostFormValue("kind")})
u.flash(w, r, "ok", "Entity updated.", "/minds/"+m.Slug+"/entities/"+d.Entity.ID.String())
}
+29 -18
View File
@@ -24,6 +24,7 @@ import (
"github.com/andydixon/noema/internal/consolidation"
"github.com/andydixon/noema/internal/conversation"
"github.com/andydixon/noema/internal/goals"
"github.com/andydixon/noema/internal/graph"
"github.com/andydixon/noema/internal/llm"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/motivation"
@@ -37,23 +38,26 @@ import (
// UI holds handlers and the parsed template set.
type UI struct {
deps app.Deps
pages map[string]*template.Template
static fs.FS
statusF func(r *http.Request) any // system status provider injected by api
ctxF func(r *http.Request, id ids.ID) (api.EventContext, error) // event forensic context, shared with api
wsF func(r *http.Request, m minds.Mind) (api.WorkspaceView, error)
memF func(r *http.Request, mind, id ids.ID) (api.MemoryDetail, error)
belF func(r *http.Request, mind, id ids.ID) (api.BeliefDetail, error)
emitF func() beliefs.Emitter
postF func(r *http.Request, mind ids.ID, conv conversation.Conversation, text, who string) (conversation.Message, error)
goalF func(r *http.Request, n goals.New) (goals.Goal, error)
goalUpdF func(r *http.Request, mind, id ids.ID, u goals.Update) (goals.Goal, error)
predF func(r *http.Request, n predictions.New) (predictions.Prediction, error)
resolveF func(r *http.Request, mind, id ids.ID, outcome predictions.Status, reason string) (predictions.Result, error)
resetF func(r *http.Request, mind ids.ID) (motivation.State, error)
consolF func(r *http.Request, mind ids.ID) (consolidation.Job, error)
reflF func(r *http.Request, mind ids.ID) (reflection.Reflection, error)
deps app.Deps
pages map[string]*template.Template
static fs.FS
statusF func(r *http.Request) any // system status provider injected by api
ctxF func(r *http.Request, id ids.ID) (api.EventContext, error) // event forensic context, shared with api
wsF func(r *http.Request, m minds.Mind) (api.WorkspaceView, error)
memF func(r *http.Request, mind, id ids.ID) (api.MemoryDetail, error)
belF func(r *http.Request, mind, id ids.ID) (api.BeliefDetail, error)
emitF func() beliefs.Emitter
postF func(r *http.Request, mind ids.ID, conv conversation.Conversation, text, who string) (conversation.Message, error)
goalF func(r *http.Request, n goals.New) (goals.Goal, error)
goalUpdF func(r *http.Request, mind, id ids.ID, u goals.Update) (goals.Goal, error)
predF func(r *http.Request, n predictions.New) (predictions.Prediction, error)
resolveF func(r *http.Request, mind, id ids.ID, outcome predictions.Status, reason string) (predictions.Result, error)
resetF func(r *http.Request, mind ids.ID) (motivation.State, error)
consolF func(r *http.Request, mind ids.ID) (consolidation.Job, error)
reflF func(r *http.Request, mind ids.ID) (reflection.Reflection, error)
entF func(r *http.Request, mind, id ids.ID) (api.EntityDetail, error)
mergeF func(r *http.Request, mind ids.ID, e graph.Entity, other string) error
distinctF func(r *http.Request, mind ids.ID, e graph.Entity, other string) error
}
// Actions groups the server-side operations the UI delegates to the API layer
@@ -73,6 +77,9 @@ type Actions struct {
ResetState func(r *http.Request, mind ids.ID) (motivation.State, error)
Consolidate func(r *http.Request, mind ids.ID) (consolidation.Job, error)
Reflect func(r *http.Request, mind ids.ID) (reflection.Reflection, error)
Entity func(r *http.Request, mind, id ids.ID) (api.EntityDetail, error)
Merge func(r *http.Request, mind ids.ID, e graph.Entity, other string) error
Distinct func(r *http.Request, mind ids.ID, e graph.Entity, other string) error
}
// View is the data passed to every template.
@@ -102,7 +109,7 @@ func New(deps app.Deps, a Actions) (*UI, error) {
deps.Log.Info("ui: serving templates and static files from ./web (development)")
}
}
u := &UI{deps: deps, static: staticFS, statusF: a.Status, ctxF: a.Context, wsF: a.Workspace, memF: a.Memory, belF: a.Belief, emitF: a.Emitter, postF: a.Post, goalF: a.Goal, goalUpdF: a.GoalUpdate, predF: a.Predict, resolveF: a.Resolve, resetF: a.ResetState, consolF: a.Consolidate, reflF: a.Reflect}
u := &UI{deps: deps, static: staticFS, statusF: a.Status, ctxF: a.Context, wsF: a.Workspace, memF: a.Memory, belF: a.Belief, emitF: a.Emitter, postF: a.Post, goalF: a.Goal, goalUpdF: a.GoalUpdate, predF: a.Predict, resolveF: a.Resolve, resetF: a.ResetState, consolF: a.Consolidate, reflF: a.Reflect, entF: a.Entity, mergeF: a.Merge, distinctF: a.Distinct}
if err := u.parse(tmplFS); err != nil {
return nil, err
}
@@ -112,6 +119,7 @@ func New(deps app.Deps, a Actions) (*UI, error) {
func (u *UI) parse(fsys fs.FS) error {
funcs := template.FuncMap{
"hasPrefix": strings.HasPrefix,
"hasSuffix": strings.HasSuffix,
"roles": auth.AllRoles,
"date": func(t time.Time) string { return t.Local().Format("2006-01-02 15:04") },
"ago": ago,
@@ -155,6 +163,8 @@ func (u *UI) parse(fsys fs.FS) error {
"relations": func() []concepts.Relation { return concepts.AllRelations },
"llmRoles": func() []llm.RoleInfo { return llm.Roles },
"pretty": prettyRaw,
"entityKinds": func() []graph.Kind { return graph.AllKinds },
"join": strings.Join,
"outputs": func(raw json.RawMessage) []consolidation.Output {
var out []consolidation.Output
json.Unmarshal(raw, &out) //nolint:errcheck
@@ -358,6 +368,7 @@ func (u *UI) Register(m *http.ServeMux) {
u.conversationRoutes(m)
u.motivationRoutes(m)
u.cognitionRoutes(m)
u.graphRoutes(m)
for _, p := range []struct{ path, title, note string }{
{"/approvals", "Approvals", "The approval centre arrives with the capability system."},