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
+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 {