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
+2
View File
@@ -20,6 +20,7 @@ import (
"github.com/andydixon/noema/internal/conversation"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/goals"
"github.com/andydixon/noema/internal/graph"
"github.com/andydixon/noema/internal/llm/prompts"
"github.com/andydixon/noema/internal/llm/router"
"github.com/andydixon/noema/internal/memory"
@@ -49,6 +50,7 @@ func testServer(t *testing.T) (*httptest.Server, string) {
Audit: audit.New(pool, log), Metrics: observability.NewMetrics(), Minds: minds.NewStore(pool), Conversations: conversation.NewStore(pool), Goals: goals.NewStore(pool), Motivation: motivation.NewStore(pool), Predictions: predictions.NewStore(pool), Beliefs: beliefs.NewStore(pool), Memory: memory.NewStore(context.Background(), pool, nil), Events: evstore.NewStore(pool, evstore.NewBus(50), nil), Migrations: migrations.FS, Started: time.Now()}
ctx := context.Background()
deps.Resolver = &predictions.Resolver{Store: deps.Predictions, Events: deps.Events, Beliefs: deps.Beliefs, Log: log}
deps.Graph = graph.NewStore(pool)
deps.Narrative = narrative.NewStore(pool)
deps.Notifications = notifications.NewStore(pool)
deps.Consolidation = &consolidation.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Minds: deps.Minds, Log: log}
+286
View File
@@ -0,0 +1,286 @@
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)
}
+1
View File
@@ -62,6 +62,7 @@ func (s *Server) routes() {
s.importRoutes()
s.motivationRoutes()
s.cognitionRoutes()
s.graphRoutes()
m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint")
+2
View File
@@ -19,6 +19,7 @@ import (
"github.com/andydixon/noema/internal/conversation"
"github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/goals"
"github.com/andydixon/noema/internal/graph"
"github.com/andydixon/noema/internal/llm/prompts"
"github.com/andydixon/noema/internal/llm/router"
"github.com/andydixon/noema/internal/memory"
@@ -54,6 +55,7 @@ type Deps struct {
Notifications *notifications.Store
Consolidation *consolidation.Service
Reflection *reflection.Service
Graph *graph.Store
LLM *router.Router
LLMStore *router.Store
Prompts *prompts.Store
+66
View File
@@ -0,0 +1,66 @@
package cognition
import (
"context"
"github.com/andydixon/noema/internal/brain"
"github.com/andydixon/noema/internal/conversation"
"github.com/andydixon/noema/internal/graph"
"github.com/andydixon/noema/pkg/concepts"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// linkEntities upserts the entities mentioned in text, links them to the
// given objects and records co-mention edges. Deterministic: the graph grows
// from what was actually said, and every edge cites the event it came from.
func (h Handlers) linkEntities(ctx context.Context, c *brain.Cycle, names []string, actor string, sourceEvent ids.ID, refs map[string]ids.ID) []graph.Entity {
if h.Graph == nil {
return nil
}
mind := c.State.Mind.ID
var ents []graph.Entity
seen := map[ids.ID]bool{}
add := func(kind graph.Kind, name string) {
e, err := h.Graph.Upsert(ctx, c.Tx, mind, kind, name, c.State.Now)
if err != nil || seen[e.ID] {
return
}
seen[e.ID] = true
ents = append(ents, e)
if !sourceEvent.IsZero() {
h.Graph.LinkRef(ctx, c.Tx, mind, e.ID, "event", sourceEvent) //nolint:errcheck
}
for kind, id := range refs {
if !id.IsZero() {
h.Graph.LinkRef(ctx, c.Tx, mind, e.ID, kind, id) //nolint:errcheck
}
}
}
if actor != "" && actor != "operator" && actor != "system" {
add(graph.Person, actor)
}
for _, n := range names {
if len(n) >= 2 && len(ents) < 12 {
add("", n)
}
}
// The speaker discussed each entity; mentioned entities relate to each other.
for i := range ents {
for j := i + 1; j < len(ents); j++ {
rel := concepts.RelatedTo
if ents[i].Kind == graph.Person && i == 0 && actor != "" {
rel = concepts.DiscussedWith
}
h.Graph.Relate(ctx, c.Tx, mind, ents[i].ID, ents[j].ID, rel, 0.3, "co-mention", sourceEvent) //nolint:errcheck
}
}
return ents
}
// entitiesFromText runs the deterministic entity extractor over free text.
func entitiesFromText(text string) []string {
return conversation.Analyse(text).Entities
}
var _ = events.Observation
+4
View File
@@ -11,6 +11,7 @@ import (
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/brain"
"github.com/andydixon/noema/internal/conversation"
"github.com/andydixon/noema/internal/graph"
"github.com/andydixon/noema/internal/memory"
"github.com/andydixon/noema/internal/predictions"
"github.com/andydixon/noema/internal/storage"
@@ -25,6 +26,7 @@ type Handlers struct {
Memory *memory.Store
Beliefs *beliefs.Store
Conversations *conversation.Store
Graph *graph.Store
}
// Register installs the handlers on a registry.
@@ -163,6 +165,8 @@ func (h Handlers) attend(ctx context.Context, c *brain.Cycle, t workspace.Though
item := workspace.Item{Key: "attend:" + t.Subject, Content: t.Summary, Kind: "observation", SourceEvent: ids.ID(evID)}
if !mem.ID.IsZero() {
item.Ref = string(mem.ID)
text, _ := t.Content["text"].(string)
h.linkEntities(ctx, c, entitiesFromText(text), "", ids.ID(evID), map[string]ids.ID{"memory": mem.ID})
}
c.Runtime.Hold(item)
return nil
+5
View File
@@ -110,6 +110,11 @@ func (h Handlers) perceive(ctx context.Context, c *brain.Cycle, t workspace.Thou
claimed = append(claimed, BeliefRef{ID: res.Belief.ID, Proposition: res.Belief.Proposition, Confidence: res.Belief.Confidence, Status: string(res.Belief.Status)})
}
}
refs := map[string]ids.ID{"memory": memRef}
for _, b := range claimed {
refs["belief"] = b.ID
}
h.linkEntities(ctx, c, a.Entities, actor, ids.ID(srcID), refs)
intent := h.buildIntent(ctx, c, a, text, claimed)
reply, callID, err := h.generate(ctx, c, text, intent)
if err != nil {
+530
View File
@@ -0,0 +1,530 @@
// Package graph maintains a mind's entity graph: people, systems, projects,
// documents, concepts and the typed, evidenced relationships between them
// (spec §24). Extraction elsewhere calls Upsert and Relate; operators can
// merge or separate entities and every such correction is recorded.
package graph
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/concepts"
"github.com/andydixon/noema/pkg/ids"
)
// Kind of entity.
type Kind string
// Kinds.
const (
Person Kind = "person"
System Kind = "system"
Project Kind = "project"
Document Kind = "document"
Concept Kind = "concept"
Repository Kind = "repository"
Organisation Kind = "organisation"
Place Kind = "place"
Other Kind = "other"
)
// AllKinds lists kinds.
var AllKinds = []Kind{Person, System, Project, Document, Concept, Repository, Organisation, Place, Other}
// Valid reports whether k is known.
func (k Kind) Valid() bool {
for _, x := range AllKinds {
if x == k {
return true
}
}
return false
}
// Entity is a node.
type Entity struct {
ID ids.ID `json:"id"`
MindID ids.ID `json:"mind_id"`
Kind Kind `json:"kind"`
Name string `json:"name"`
Canonical string `json:"canonical"`
Aliases []string `json:"aliases"`
Description string `json:"description"`
Attributes map[string]any `json:"attributes"`
MentionCount int `json:"mention_count"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
MergedInto ids.ID `json:"merged_into,omitempty"`
}
// Relationship is an edge.
type Relationship struct {
ID ids.ID `json:"id"`
FromEntity ids.ID `json:"from_entity"`
ToEntity ids.ID `json:"to_entity"`
Relation concepts.Relation `json:"relation"`
Confidence float64 `json:"confidence"`
Source string `json:"source"`
EventID ids.ID `json:"event_id,omitempty"`
Count int `json:"count"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
}
// Link ties an entity to another object.
type Link struct {
Kind string `json:"kind"`
RefID ids.ID `json:"ref_id"`
CreatedAt time.Time `json:"created_at"`
}
// ErrInvalid marks bad input.
var ErrInvalid = errors.New("graph: invalid")
// Store persists the graph.
type Store struct{ db *pgxpool.Pool }
// NewStore creates a store.
func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} }
var spaces = regexp.MustCompile(`\s+`)
// Canonical normalises a name for matching: lower-case, collapsed spaces,
// trailing punctuation removed.
func Canonical(name string) string {
c := strings.ToLower(strings.TrimSpace(name))
c = strings.Trim(c, ".,;:!?\"'()[]")
return spaces.ReplaceAllString(c, " ")
}
// GuessKind classifies a mention deterministically. A model may override
// the kind later; this keeps the graph useful without one.
func GuessKind(name string) Kind {
l := strings.ToLower(name)
switch {
case strings.HasSuffix(l, ".pdf"), strings.HasSuffix(l, ".md"), strings.HasSuffix(l, ".txt"), strings.HasSuffix(l, ".docx"):
return Document
case regexp.MustCompile(`^[a-z0-9-]+\.[a-z]{2,}(\.[a-z]{2,})*$`).MatchString(l), regexp.MustCompile(`^[a-z]+-?[0-9]{1,3}$`).MatchString(l), regexp.MustCompile(`^[a-z]+(-[a-z0-9]+)+$`).MatchString(l):
return System
case strings.Contains(l, "/") && !strings.Contains(l, " "):
return Repository
case len(strings.Fields(name)) == 1 && name != "" && name[0] >= 'A' && name[0] <= 'Z' && !strings.ContainsAny(name, "0123456789-."):
return Person
}
return Concept
}
// Upsert finds an entity by canonical name or alias, or creates it, and
// bumps its mention count. Merged entities resolve to their survivor. A
// recorded "entities_different" correction between the exact names prevents
// alias matching from merging them.
func (s *Store) Upsert(ctx context.Context, q storage.Querier, mind ids.ID, kind Kind, name string, seen time.Time) (Entity, error) {
name = strings.TrimSpace(name)
canon := Canonical(name)
if canon == "" || len(canon) > 200 {
return Entity{}, fmt.Errorf("%w: name required (max 200)", ErrInvalid)
}
if !kind.Valid() {
kind = GuessKind(name)
}
if seen.IsZero() {
seen = time.Now()
}
e, err := s.find(ctx, q, mind, canon)
if err == nil {
_, err = q.Exec(ctx, `UPDATE entities SET mention_count=mention_count+1, last_seen=GREATEST(last_seen,$2) WHERE id=$1`, e.ID, seen)
e.MentionCount++
return e, err
}
if !errors.Is(err, storage.ErrNotFound) {
return Entity{}, err
}
e = Entity{ID: ids.New(), MindID: mind, Kind: kind, Name: name, Canonical: canon, Aliases: []string{}, Attributes: map[string]any{}, MentionCount: 1, FirstSeen: seen, LastSeen: seen}
_, err = q.Exec(ctx, `INSERT INTO entities (id, mind_id, kind, name, canonical, mention_count, first_seen, last_seen) VALUES ($1,$2,$3,$4,$5,1,$6,$6)
ON CONFLICT (mind_id, canonical) WHERE merged_into IS NULL DO UPDATE SET mention_count=entities.mention_count+1, last_seen=GREATEST(entities.last_seen, excluded.last_seen)`, e.ID, mind, string(kind), name, canon, seen)
if err != nil {
return Entity{}, err
}
return s.find(ctx, q, mind, canon)
}
func (s *Store) find(ctx context.Context, q storage.Querier, mind ids.ID, canon string) (Entity, error) {
e, err := scan(q.QueryRow(ctx, `SELECT `+cols+` FROM entities e WHERE e.mind_id=$1 AND e.merged_into IS NULL AND (e.canonical=$2 OR $2 = ANY(e.aliases)) ORDER BY (e.canonical=$2) DESC LIMIT 1`, mind, canon))
if err != nil {
return Entity{}, storage.Translate(err)
}
return e, nil
}
const cols = `e.id, e.mind_id, e.kind, e.name, e.canonical, e.aliases, e.description, e.attributes, e.mention_count, e.first_seen, e.last_seen, e.merged_into`
func scan(row pgx.Row) (Entity, error) {
var e Entity
var kind string
var attrs []byte
var merged *string
if err := row.Scan(&e.ID, &e.MindID, &kind, &e.Name, &e.Canonical, &e.Aliases, &e.Description, &attrs, &e.MentionCount, &e.FirstSeen, &e.LastSeen, &merged); err != nil {
return e, storage.Translate(err)
}
e.Kind = Kind(kind)
json.Unmarshal(attrs, &e.Attributes) //nolint:errcheck
if e.Attributes == nil {
e.Attributes = map[string]any{}
}
if e.Aliases == nil {
e.Aliases = []string{}
}
if merged != nil {
e.MergedInto = ids.ID(*merged)
}
return e, nil
}
// Get fetches an entity, following merges.
func (s *Store) Get(ctx context.Context, mind, id ids.ID) (Entity, error) {
for i := 0; i < 5; i++ {
e, err := scan(s.db.QueryRow(ctx, `SELECT `+cols+` FROM entities e WHERE e.id=$1 AND e.mind_id=$2`, id, mind))
if err != nil {
return Entity{}, err
}
if e.MergedInto.IsZero() {
return e, nil
}
id = e.MergedInto
}
return Entity{}, storage.ErrNotFound
}
// Search lists entities by name prefix/substring and kind, most mentioned first.
func (s *Store) Search(ctx context.Context, mind ids.ID, query string, kind Kind, limit int) ([]Entity, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.db.Query(ctx, `SELECT `+cols+` FROM entities e WHERE e.mind_id=$1 AND e.merged_into IS NULL AND ($2='' OR e.canonical LIKE '%'||$2||'%') AND ($3='' OR e.kind=$3) ORDER BY e.mention_count DESC, e.last_seen DESC LIMIT $4`, mind, Canonical(query), string(kind), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Entity
for rows.Next() {
e, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// Relate adds or reinforces an edge. Repeats increase count and take the
// higher confidence; both endpoints must belong to the mind.
func (s *Store) Relate(ctx context.Context, q storage.Querier, mind, from, to ids.ID, rel concepts.Relation, confidence float64, source string, eventID ids.ID) (Relationship, error) {
if !rel.Valid() || from == to || from.IsZero() || to.IsZero() {
return Relationship{}, fmt.Errorf("%w: bad relation", ErrInvalid)
}
var ev *string
if !eventID.IsZero() {
e := string(eventID)
ev = &e
}
r := Relationship{ID: ids.New(), FromEntity: from, ToEntity: to, Relation: rel, Confidence: clamp01(confidence), Source: source, EventID: eventID, Count: 1, FirstSeen: time.Now(), LastSeen: time.Now()}
tag, err := q.Exec(ctx, `INSERT INTO entity_relationships (id, mind_id, from_entity, to_entity, relation, confidence, source, event_id)
SELECT $1,$2,$3,$4,$5,$6,$7,$8 WHERE (SELECT count(*) FROM entities WHERE id IN ($3,$4) AND mind_id=$2) = 2
ON CONFLICT (from_entity, to_entity, relation) DO UPDATE SET count=entity_relationships.count+1, confidence=GREATEST(entity_relationships.confidence, excluded.confidence), last_seen=now()`,
r.ID, mind, from, to, string(rel), r.Confidence, source, ev)
if err != nil {
return Relationship{}, err
}
if tag.RowsAffected() == 0 {
return Relationship{}, fmt.Errorf("%w: entities not in mind", ErrInvalid)
}
return r, nil
}
// LinkRef records that an entity is involved in a memory, belief, event, goal or prediction.
func (s *Store) LinkRef(ctx context.Context, q storage.Querier, mind, entity ids.ID, kind string, ref ids.ID) error {
_, err := q.Exec(ctx, `INSERT INTO entity_links (id, mind_id, entity_id, kind, ref_id) SELECT $1,$2,$3,$4,$5 WHERE EXISTS (SELECT 1 FROM entities WHERE id=$3 AND mind_id=$2) ON CONFLICT DO NOTHING`, ids.New(), mind, entity, kind, ref)
return err
}
// Links returns an entity's links, newest first.
func (s *Store) Links(ctx context.Context, mind, entity ids.ID, limit int) ([]Link, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.db.Query(ctx, `SELECT kind, ref_id, created_at FROM entity_links WHERE mind_id=$1 AND entity_id=$2 ORDER BY created_at DESC LIMIT $3`, mind, entity, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Link
for rows.Next() {
var l Link
if err := rows.Scan(&l.Kind, &l.RefID, &l.CreatedAt); err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.Err()
}
// Edge is a relationship with both entities loaded for rendering.
type Edge struct {
Relationship
From Entity `json:"from"`
To Entity `json:"to"`
}
// Neighbourhood returns the entity's edges with the other endpoints.
func (s *Store) Neighbourhood(ctx context.Context, mind, entity ids.ID, limit int) ([]Edge, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.db.Query(ctx, `SELECT r.id, r.from_entity, r.to_entity, r.relation, r.confidence, r.source, r.event_id, r.count, r.first_seen, r.last_seen FROM entity_relationships r
WHERE r.mind_id=$1 AND (r.from_entity=$2 OR r.to_entity=$2) ORDER BY r.count DESC, r.confidence DESC LIMIT $3`, mind, entity, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var rels []Relationship
for rows.Next() {
r, err := scanRel(rows)
if err != nil {
return nil, err
}
rels = append(rels, r)
}
rows.Close()
return s.hydrate(ctx, mind, rels)
}
func scanRel(row pgx.Row) (Relationship, error) {
var r Relationship
var rel string
var ev *string
if err := row.Scan(&r.ID, &r.FromEntity, &r.ToEntity, &rel, &r.Confidence, &r.Source, &ev, &r.Count, &r.FirstSeen, &r.LastSeen); err != nil {
return r, err
}
r.Relation = concepts.Relation(rel)
if ev != nil {
r.EventID = ids.ID(*ev)
}
return r, nil
}
func (s *Store) hydrate(ctx context.Context, mind ids.ID, rels []Relationship) ([]Edge, error) {
cache := map[ids.ID]Entity{}
get := func(id ids.ID) (Entity, error) {
if e, ok := cache[id]; ok {
return e, nil
}
e, err := s.Get(ctx, mind, id)
if err == nil {
cache[id] = e
}
return e, err
}
var out []Edge
for _, r := range rels {
from, err := get(r.FromEntity)
if err != nil {
continue
}
to, err := get(r.ToEntity)
if err != nil {
continue
}
out = append(out, Edge{Relationship: r, From: from, To: to})
}
return out, nil
}
// Subgraph returns the most-connected entities (optionally around a focus
// entity or filtered by kind) and the edges among them, bounded by limit so
// the explorer never renders thousands of nodes.
type Subgraph struct {
Nodes []Entity `json:"nodes"`
Edges []Edge `json:"edges"`
Total int `json:"total_entities"`
}
// Subgraph builds a bounded view.
func (s *Store) Subgraph(ctx context.Context, mind ids.ID, focus ids.ID, kind Kind, limit int) (Subgraph, error) {
if limit <= 0 || limit > 300 {
limit = 80
}
var sg Subgraph
s.db.QueryRow(ctx, `SELECT count(*) FROM entities WHERE mind_id=$1 AND merged_into IS NULL`, mind).Scan(&sg.Total) //nolint:errcheck
var nodes []Entity
if !focus.IsZero() {
f, err := s.Get(ctx, mind, focus)
if err != nil {
return sg, err
}
nodes = append(nodes, f)
edges, err := s.Neighbourhood(ctx, mind, f.ID, limit)
if err != nil {
return sg, err
}
seen := map[ids.ID]bool{f.ID: true}
for _, e := range edges {
for _, n := range []Entity{e.From, e.To} {
if !seen[n.ID] && (kind == "" || n.Kind == kind || n.ID == f.ID) {
seen[n.ID] = true
nodes = append(nodes, n)
}
}
}
} else {
var err error
nodes, err = s.Search(ctx, mind, "", kind, limit)
if err != nil {
return sg, err
}
}
idList := make([]string, 0, len(nodes))
for _, n := range nodes {
idList = append(idList, string(n.ID))
}
sg.Nodes = nodes
if len(idList) == 0 {
sg.Edges = []Edge{}
return sg, nil
}
rows, err := s.db.Query(ctx, `SELECT r.id, r.from_entity, r.to_entity, r.relation, r.confidence, r.source, r.event_id, r.count, r.first_seen, r.last_seen FROM entity_relationships r
WHERE r.mind_id=$1 AND r.from_entity = ANY($2) AND r.to_entity = ANY($2) ORDER BY r.count DESC LIMIT 1000`, mind, idList)
if err != nil {
return sg, err
}
defer rows.Close()
var rels []Relationship
for rows.Next() {
r, err := scanRel(rows)
if err != nil {
return sg, err
}
rels = append(rels, r)
}
sg.Edges, err = s.hydrate(ctx, mind, rels)
if sg.Edges == nil {
sg.Edges = []Edge{}
}
return sg, err
}
// Merge folds loser into survivor: relationships and links are re-pointed,
// the loser's name becomes an alias, and the loser row stays with
// merged_into set so old references still resolve.
func (s *Store) Merge(ctx context.Context, mind, survivor, loser ids.ID) error {
if survivor == loser {
return fmt.Errorf("%w: same entity", ErrInvalid)
}
return storage.Tx(ctx, s.db, func(tx pgx.Tx) error {
sv, err := scan(tx.QueryRow(ctx, `SELECT `+cols+` FROM entities e WHERE e.id=$1 AND e.mind_id=$2 AND e.merged_into IS NULL FOR UPDATE`, survivor, mind))
if err != nil {
return err
}
lo, err := scan(tx.QueryRow(ctx, `SELECT `+cols+` FROM entities e WHERE e.id=$1 AND e.mind_id=$2 AND e.merged_into IS NULL FOR UPDATE`, loser, mind))
if err != nil {
return err
}
// Re-point edges; drop those that would become self-loops or duplicates.
if _, err := tx.Exec(ctx, `DELETE FROM entity_relationships WHERE mind_id=$1 AND ((from_entity=$2 AND to_entity=$3) OR (from_entity=$3 AND to_entity=$2))`, mind, survivor, loser); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE entity_relationships r SET from_entity=$1 WHERE r.from_entity=$2 AND NOT EXISTS (SELECT 1 FROM entity_relationships x WHERE x.from_entity=$1 AND x.to_entity=r.to_entity AND x.relation=r.relation)`, survivor, loser); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE entity_relationships r SET to_entity=$1 WHERE r.to_entity=$2 AND NOT EXISTS (SELECT 1 FROM entity_relationships x WHERE x.to_entity=$1 AND x.from_entity=r.from_entity AND x.relation=r.relation)`, survivor, loser); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM entity_relationships WHERE from_entity=$1 OR to_entity=$1`, loser); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE entity_links l SET entity_id=$1 WHERE l.entity_id=$2 AND NOT EXISTS (SELECT 1 FROM entity_links x WHERE x.entity_id=$1 AND x.kind=l.kind AND x.ref_id=l.ref_id)`, survivor, loser); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM entity_links WHERE entity_id=$1`, loser); err != nil {
return err
}
aliases := append(append([]string{}, sv.Aliases...), lo.Canonical)
aliases = append(aliases, lo.Aliases...)
if _, err := tx.Exec(ctx, `UPDATE entities SET aliases=$2, mention_count=mention_count+$3, first_seen=LEAST(first_seen,$4), last_seen=GREATEST(last_seen,$5) WHERE id=$1`, survivor, uniq(aliases), lo.MentionCount, lo.FirstSeen, lo.LastSeen); err != nil {
return err
}
_, err = tx.Exec(ctx, `UPDATE entities SET merged_into=$2 WHERE id=$1`, loser, survivor)
return err
})
}
// SetKind and description let operators correct classification.
func (s *Store) Update(ctx context.Context, mind, id ids.ID, kind Kind, description string) error {
if !kind.Valid() {
return fmt.Errorf("%w: unknown kind", ErrInvalid)
}
tag, err := s.db.Exec(ctx, `UPDATE entities SET kind=$3, description=$4 WHERE id=$1 AND mind_id=$2`, id, mind, string(kind), description)
if err == nil && tag.RowsAffected() == 0 {
return storage.ErrNotFound
}
return err
}
// Stats counts entities by kind and edges.
type Stats struct {
Entities int64 `json:"entities"`
Edges int64 `json:"edges"`
ByKind map[string]int64 `json:"by_kind"`
}
// Stats returns counts.
func (s *Store) Stats(ctx context.Context, mind ids.ID) (Stats, error) {
st := Stats{ByKind: map[string]int64{}}
rows, err := s.db.Query(ctx, `SELECT kind, count(*) FROM entities WHERE mind_id=$1 AND merged_into IS NULL GROUP BY kind`, mind)
if err != nil {
return st, err
}
for rows.Next() {
var k string
var n int64
if err := rows.Scan(&k, &n); err != nil {
rows.Close()
return st, err
}
st.ByKind[k] = n
st.Entities += n
}
rows.Close()
err = s.db.QueryRow(ctx, `SELECT count(*) FROM entity_relationships WHERE mind_id=$1`, mind).Scan(&st.Edges)
return st, err
}
func uniq(xs []string) []string {
seen := map[string]bool{}
var out []string
for _, x := range xs {
if x != "" && !seen[x] {
seen[x] = true
out = append(out, x)
}
}
return out
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
+85
View File
@@ -0,0 +1,85 @@
package graph
import (
"context"
"testing"
"time"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/migrations"
"github.com/andydixon/noema/pkg/concepts"
"github.com/andydixon/noema/pkg/ids"
)
func TestGuessKindAndCanonical(t *testing.T) {
cases := map[string]Kind{"atlas.local": System, "server-01": System, "web-api-prod": System, "Andy": Person, "report.pdf": Document, "andydixon/noema": Repository, "latency": Concept}
for name, want := range cases {
if got := GuessKind(name); got != want {
t.Errorf("%s: got %s want %s", name, got, want)
}
}
if Canonical(" Atlas, ") != "atlas" {
t.Fatal("canonical wrong")
}
}
func TestUpsertRelateMergeAndIsolation(t *testing.T) {
pool := storage.TestPool(t, migrations.FS)
ctx := context.Background()
ms := minds.NewStore(pool)
a, _ := ms.Create(ctx, minds.CreateParams{Slug: "a"})
b, _ := ms.Create(ctx, minds.CreateParams{Slug: "b"})
s := NewStore(pool)
now := time.Now()
andy, _ := s.Upsert(ctx, pool, a.ID, "", "Andy", now)
andy2, _ := s.Upsert(ctx, pool, a.ID, "", "andy", now)
if andy.ID != andy2.ID || andy2.MentionCount != 2 || andy.Kind != Person {
t.Fatalf("upsert dedupe wrong: %+v %+v", andy, andy2)
}
atlas, _ := s.Upsert(ctx, pool, a.ID, "", "atlas", now)
atlasHost, _ := s.Upsert(ctx, pool, a.ID, "", "atlas.local", now)
if _, err := s.Relate(ctx, pool, a.ID, andy.ID, atlas.ID, concepts.DiscussedWith, 0.3, "co-mention", ids.Nil); err != nil {
t.Fatal(err)
}
r2, _ := s.Relate(ctx, pool, a.ID, andy.ID, atlas.ID, concepts.DiscussedWith, 0.6, "co-mention", ids.Nil)
_ = r2
nb, _ := s.Neighbourhood(ctx, a.ID, andy.ID, 10)
if len(nb) != 1 || nb[0].Count != 2 || nb[0].Confidence != 0.6 {
t.Fatalf("relate reinforcement wrong: %+v", nb)
}
// Cross-mind endpoints are refused.
other, _ := s.Upsert(ctx, pool, b.ID, "", "atlas", now)
if _, err := s.Relate(ctx, pool, a.ID, andy.ID, other.ID, concepts.RelatedTo, 0.5, "x", ids.Nil); err == nil {
t.Fatal("cross-mind edge accepted")
}
if _, err := s.Get(ctx, b.ID, andy.ID); err == nil {
t.Fatal("cross-mind get")
}
// Merge atlas.local into atlas; old id still resolves; alias matches.
s.Relate(ctx, pool, a.ID, atlasHost.ID, andy.ID, concepts.BelongsTo, 0.5, "x", ids.Nil) //nolint:errcheck
s.LinkRef(ctx, pool, a.ID, atlasHost.ID, "memory", ids.New()) //nolint:errcheck
if err := s.Merge(ctx, a.ID, atlas.ID, atlasHost.ID); err != nil {
t.Fatal(err)
}
got, err := s.Get(ctx, a.ID, atlasHost.ID)
if err != nil || got.ID != atlas.ID || len(got.Aliases) == 0 {
t.Fatalf("merge resolution wrong: %v %+v", err, got)
}
again, _ := s.Upsert(ctx, pool, a.ID, "", "atlas.local", now)
if again.ID != atlas.ID {
t.Fatal("alias did not match after merge")
}
links, _ := s.Links(ctx, a.ID, atlas.ID, 10)
if len(links) != 1 {
t.Fatalf("links not moved: %d", len(links))
}
sg, _ := s.Subgraph(ctx, a.ID, ids.Nil, "", 50)
if len(sg.Nodes) != 2 || len(sg.Edges) < 1 || sg.Total != 2 {
t.Fatalf("subgraph wrong: %d nodes %d edges total %d", len(sg.Nodes), len(sg.Edges), sg.Total)
}
st, _ := s.Stats(ctx, a.ID)
if st.Entities != 2 || st.ByKind["person"] != 1 {
t.Fatalf("stats: %+v", st)
}
}
+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."},