Files
Andy Dixon 99a89ba847 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.
2026-06-29 19:28:55 +01:00

287 lines
8.6 KiB
Go

package api
import (
"errors"
"net/http"
"strconv"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/graph"
"github.com/andydixon/noema/pkg/concepts"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
func (s *Server) graphRoutes() {
m := s.mux
m.Handle("GET /api/v1/minds/{mind}/graph", require(auth.RoleObserver, s.graphGet))
m.Handle("GET /api/v1/minds/{mind}/entities", require(auth.RoleObserver, s.entitiesList))
m.Handle("GET /api/v1/minds/{mind}/entities/{id}", require(auth.RoleObserver, s.entityGet))
m.Handle("POST /api/v1/minds/{mind}/entities/{id}/merge", require(auth.RoleOperator, s.entityMerge))
m.Handle("POST /api/v1/minds/{mind}/entities/{id}/distinct", require(auth.RoleOperator, s.entityDistinct))
m.Handle("PATCH /api/v1/minds/{mind}/entities/{id}", require(auth.RoleOperator, s.entityPatch))
m.Handle("POST /api/v1/minds/{mind}/entities/{id}/relationships", require(auth.RoleOperator, s.entityRelate))
}
func graphErr(err error) error {
if errors.Is(err, graph.ErrInvalid) {
return ValidationError(err.Error())
}
return err
}
func (s *Server) graphGet(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
q := r.URL.Query()
var focus ids.ID
if v := q.Get("focus"); v != "" {
focus, _ = ids.Parse(v)
}
limit, _ := strconv.Atoi(q.Get("limit"))
sg, err := s.deps.Graph.Subgraph(r.Context(), m.ID, focus, graph.Kind(q.Get("kind")), limit)
if err != nil {
fail(w, r, err)
return
}
if sg.Nodes == nil {
sg.Nodes = []graph.Entity{}
}
writeJSON(w, http.StatusOK, sg)
}
func (s *Server) entitiesList(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
list, err := s.deps.Graph.Search(r.Context(), m.ID, r.URL.Query().Get("q"), graph.Kind(r.URL.Query().Get("kind")), limit)
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []graph.Entity{}
}
writeJSON(w, http.StatusOK, page[graph.Entity]{Items: list})
}
// EntityDetail is an entity with its edges and links; shared with the UI.
type EntityDetail struct {
Entity graph.Entity `json:"entity"`
Edges []graph.Edge `json:"edges"`
Links []graph.Link `json:"links"`
}
// EntityDetail assembles the detail view.
func (s *Server) EntityDetail(r *http.Request, mind, id ids.ID) (EntityDetail, error) {
e, err := s.deps.Graph.Get(r.Context(), mind, id)
if err != nil {
return EntityDetail{}, err
}
d := EntityDetail{Entity: e}
d.Edges, _ = s.deps.Graph.Neighbourhood(r.Context(), mind, e.ID, 100)
d.Links, _ = s.deps.Graph.Links(r.Context(), mind, e.ID, 100)
if d.Edges == nil {
d.Edges = []graph.Edge{}
}
if d.Links == nil {
d.Links = []graph.Link{}
}
return d, nil
}
func (s *Server) entityGet(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
d, err := s.EntityDetail(r, m.ID, id)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, d)
}
// resolveEntity accepts an id or a name.
func (s *Server) resolveEntity(r *http.Request, mind ids.ID, ref string) (graph.Entity, error) {
if id, err := ids.Parse(ref); err == nil {
return s.deps.Graph.Get(r.Context(), mind, id)
}
list, err := s.deps.Graph.Search(r.Context(), mind, ref, "", 5)
if err != nil {
return graph.Entity{}, err
}
for _, e := range list {
if e.Canonical == graph.Canonical(ref) {
return e, nil
}
}
if len(list) == 1 {
return list[0], nil
}
return graph.Entity{}, ValidationError("no unique entity matches " + ref)
}
// MergeEntities is the "these two are the same" correction; shared with the UI.
func (s *Server) MergeEntities(r *http.Request, mind ids.ID, survivor graph.Entity, otherRef string) error {
loser, err := s.resolveEntity(r, mind, otherRef)
if err != nil {
return err
}
if err := s.deps.Graph.Merge(r.Context(), mind, survivor.ID, loser.ID); err != nil {
return graphErr(err)
}
evs, _ := s.deps.Events.Append(r.Context(), events.New{MindID: mind, Type: events.OperatorAction, Source: "operator", Summary: "Operator " + actor(r) + " merged entities: " + loser.Name + " is the same as " + survivor.Name,
Salience: 0.4, Confidence: 1, Metadata: map[string]any{"action": "entities.same", "actor": actor(r)}, Payload: map[string]any{"survivor": survivor.ID, "loser": loser.ID}})
if len(evs) > 0 {
s.deps.Beliefs.AddCorrection(r.Context(), s.deps.DB, mind, beliefs.Correction{Kind: "entities_same", TargetKind: "entity", TargetID: survivor.ID.String(), Statement: loser.Name + " = " + survivor.Name, CreatedBy: actor(r), EventID: evs[0].ID}) //nolint:errcheck
}
s.deps.Audit.Log(r.Context(), "entity.merge", "entity", survivor.ID.String(), map[string]any{"loser": loser.ID})
return nil
}
// DistinctEntities records "these are different"; shared with the UI.
func (s *Server) DistinctEntities(r *http.Request, mind ids.ID, e graph.Entity, otherRef string) error {
evs, err := s.deps.Events.Append(r.Context(), events.New{MindID: mind, Type: events.OperatorAction, Source: "operator", Summary: "Operator " + actor(r) + " stated that " + e.Name + " and " + otherRef + " are different entities",
Salience: 0.3, Confidence: 1, Metadata: map[string]any{"action": "entities.different", "actor": actor(r)}, Payload: map[string]any{"entity": e.ID, "other": otherRef}})
if err != nil {
return err
}
_, err = s.deps.Beliefs.AddCorrection(r.Context(), s.deps.DB, mind, beliefs.Correction{Kind: "entities_different", TargetKind: "entity", TargetID: e.ID.String(), Statement: e.Name + " ≠ " + otherRef, CreatedBy: actor(r), EventID: evs[0].ID})
s.deps.Audit.Log(r.Context(), "entity.distinct", "entity", e.ID.String(), map[string]any{"other": otherRef})
return err
}
func (s *Server) entityMerge(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
e, err := s.deps.Graph.Get(r.Context(), m.ID, id)
if err != nil {
fail(w, r, err)
return
}
var in struct {
Other string `json:"other"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
if err := s.MergeEntities(r, m.ID, e, in.Other); err != nil {
fail(w, r, err)
return
}
d, _ := s.EntityDetail(r, m.ID, e.ID)
writeJSON(w, http.StatusOK, d)
}
func (s *Server) entityDistinct(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
e, err := s.deps.Graph.Get(r.Context(), m.ID, id)
if err != nil {
fail(w, r, err)
return
}
var in struct {
Other string `json:"other"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
if err := s.DistinctEntities(r, m.ID, e, in.Other); err != nil {
fail(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) entityPatch(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
var in struct {
Kind graph.Kind `json:"kind"`
Description string `json:"description"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
if err := s.deps.Graph.Update(r.Context(), m.ID, id, in.Kind, in.Description); err != nil {
fail(w, r, graphErr(err))
return
}
s.deps.Audit.Log(r.Context(), "entity.update", "entity", id.String(), map[string]any{"kind": in.Kind})
d, _ := s.EntityDetail(r, m.ID, id)
writeJSON(w, http.StatusOK, d)
}
func (s *Server) entityRelate(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
var in struct {
To string `json:"to"`
Relation concepts.Relation `json:"relation"`
Confidence float64 `json:"confidence"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
to, err := s.resolveEntity(r, m.ID, in.To)
if err != nil {
fail(w, r, err)
return
}
if in.Confidence <= 0 {
in.Confidence = 0.8
}
rel, err := s.deps.Graph.Relate(r.Context(), s.deps.DB, m.ID, id, to.ID, in.Relation, in.Confidence, "operator:"+actor(r), ids.Nil)
if err != nil {
fail(w, r, graphErr(err))
return
}
s.deps.Audit.Log(r.Context(), "entity.relate", "entity", id.String(), map[string]any{"to": to.ID, "relation": in.Relation})
writeJSON(w, http.StatusCreated, rel)
}