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:
@@ -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
@@ -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."},
|
||||
|
||||
Reference in New Issue
Block a user