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.
287 lines
8.6 KiB
Go
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)
|
|
}
|