feat: add conversation with grounded replies, sample mind and M1 acceptance test

Phase 8, completing milestone M1. A user message is stored and appended
as an observation event carrying trust, actor, channel and reference. The
perceive process analyses it deterministically (intent, introspection
kind, entities, claims, urgency) and, within the per-cycle model
allowance, enriches it through the perceive prompt. Its handler writes a
perception event, an episodic memory with participants, turns claims into
beliefs whose evidence is the message, and builds a response intent from
state: beliefs and memories for the topic, evidence rows for why-questions,
the uncertain band, contested beliefs and their links, recent learning,
status changes, and honest declines for subsystems that do not exist yet
or for requests with no capabilities. The respond prompt phrases the
intent when a model is assigned; otherwise the reply is a direct
rendering of the intent. response_generated cites the message, the
perception, every belief and memory used, the episode and the model call.

API: conversations, messages with optional wait, one-shot ask; chat UI
with live refresh and a per-reply intent view. Sample mind Dani as a
strictly decoded seed document with an import endpoint and noemactl mind
import. tests/m1 drives all fourteen milestone steps including restart.
This commit is contained in:
Andy Dixon
2026-06-25 19:19:24 +01:00
parent d9c6ffa30f
commit 777e8f50b5
34 changed files with 2189 additions and 39 deletions
+224
View File
@@ -0,0 +1,224 @@
package conversation
import (
"regexp"
"sort"
"strings"
)
// Analysis is the deterministic understanding of a message. An LLM may
// enrich it (entities, claims) but these fields are always computed first so
// the system works without a model and never depends on one for routing.
type Analysis struct {
Intent string `json:"intent"` // question | statement | request | correction | greeting | introspection | other
Introspection string `json:"introspection"` // thinking | beliefs | why | uncertain | contradictions | learned | changed | curious | predictions | surprised | patterns | ""
Topic string `json:"topic,omitempty"`
Entities []string `json:"entities"`
Tokens []string `json:"tokens"`
Urgency float64 `json:"urgency"`
Sentiment float64 `json:"sentiment"`
Claims []Claim `json:"claims"`
Question string `json:"question,omitempty"`
Source string `json:"source"` // deterministic | llm
}
// Claim is a proposition the sender asserted.
type Claim struct {
Proposition string `json:"proposition"`
Certainty string `json:"certainty"` // asserted | hedged
}
var (
whWords = map[string]bool{"what": true, "why": true, "how": true, "when": true, "where": true, "who": true, "which": true, "is": true, "are": true, "do": true, "does": true, "did": true, "can": true, "could": true, "should": true, "will": true, "was": true, "were": true, "have": true, "has": true}
greetings = map[string]bool{"hi": true, "hello": true, "hey": true, "morning": true, "evening": true, "thanks": true, "thank": true, "bye": true, "goodbye": true}
hedges = []string{"i think", "maybe", "perhaps", "possibly", "might", "probably", "not sure", "i guess", "seems"}
requests = []string{"please", "can you", "could you", "would you", "check", "look into", "investigate", "find out", "tell me", "show me", "list", "explain", "summarise", "summarize"}
corrections = []string{"that's wrong", "that is wrong", "thats wrong", "you're wrong", "incorrect", "not true", "actually,", "no,", "wrong,", "that's not right", "that is not right"}
urgentWords = map[string]float64{"urgent": 0.9, "asap": 0.9, "immediately": 0.9, "now": 0.5, "down": 0.8, "outage": 0.9, "broken": 0.7, "failed": 0.7, "failing": 0.7, "error": 0.6, "errors": 0.6, "critical": 0.9, "emergency": 1}
positive = map[string]bool{"good": true, "great": true, "thanks": true, "excellent": true, "love": true, "nice": true, "works": true, "fixed": true, "happy": true}
negative = map[string]bool{"bad": true, "broken": true, "hate": true, "terrible": true, "annoying": true, "slow": true, "failed": true, "wrong": true, "frustrated": true, "angry": true, "down": true, "error": true, "errors": true, "outage": true}
entityRe = regexp.MustCompile(`\b([A-Z][a-zA-Z0-9]+(?:[- ][A-Z][a-zA-Z0-9]+)*|[a-z][a-z0-9]*(?:-[a-z0-9]+)+|[a-z0-9.-]+\.(?:com|net|org|io|local|lan)|[a-z]+[0-9]{1,3})\b`)
introspect = []struct {
kind string
pats []string
}{
{"thinking", []string{"what are you thinking", "thinking about", "on your mind", "what are you doing", "what are you working on", "current focus"}},
{"why", []string{"why do you believe", "why do you think", "what evidence", "how do you know", "what makes you think", "would change your mind"}},
{"uncertain", []string{"uncertain about", "unsure about", "not sure about", "what don't you know", "what do you not know", "confidence in"}},
{"contradictions", []string{"contradict", "conflict with each other", "inconsistent"}},
{"learned", []string{"what did you learn", "what have you learned", "learned this week", "learned today", "learnt"}},
{"changed", []string{"changed your mind", "change your mind about", "used to believe", "no longer believe"}},
{"curious", []string{"curious about", "what interests you", "want to know more", "what would you like to investigate"}},
{"predictions", []string{"predict", "what do you expect", "what will happen", "forecast"}},
{"surprised", []string{"surprise", "unexpected", "surprising"}},
{"patterns", []string{"pattern", "noticed", "what have you seen", "recurring"}},
{"beliefs", []string{"what do you believe", "what do you know about", "what do you think about", "tell me about", "what do you remember about", "do you know", "beliefs about", "believe about"}},
}
)
// Analyse performs the deterministic pass.
func Analyse(text string) Analysis {
a := Analysis{Source: "deterministic", Entities: []string{}, Claims: []Claim{}}
lower := strings.ToLower(strings.TrimSpace(text))
a.Tokens = tokens(lower)
words := strings.Fields(lower)
if len(words) == 0 {
a.Intent = "other"
return a
}
// Urgency and sentiment from lexicons.
for _, w := range a.Tokens {
if u, ok := urgentWords[w]; ok && u > a.Urgency {
a.Urgency = u
}
if positive[w] {
a.Sentiment += 0.3
}
if negative[w] {
a.Sentiment -= 0.3
}
}
if a.Sentiment > 1 {
a.Sentiment = 1
}
if a.Sentiment < -1 {
a.Sentiment = -1
}
// Entities: hostnames, hyphenated ids, Capitalised names.
seen := map[string]bool{}
for _, m := range entityRe.FindAllString(text, -1) {
if len(m) < 3 || whWords[strings.ToLower(m)] || greetings[strings.ToLower(m)] {
continue
}
// Skip a sentence-initial capitalised common word.
if strings.HasPrefix(text, m) && strings.ToLower(m) == m[:1]+strings.ToLower(m[1:]) && !strings.ContainsAny(m, "-0123456789.") && len(a.Tokens) > 0 && a.Tokens[0] == strings.ToLower(m) {
continue
}
key := strings.ToLower(m)
if !seen[key] {
seen[key] = true
a.Entities = append(a.Entities, m)
}
}
// Intent.
isQuestion := strings.HasSuffix(lower, "?") || whWords[words[0]] && len(words) > 2
for _, c := range corrections {
if strings.Contains(lower, c) {
a.Intent = "correction"
break
}
}
if a.Intent == "" {
for _, in := range introspect {
for _, p := range in.pats {
if strings.Contains(lower, p) {
a.Intent, a.Introspection = "introspection", in.kind
break
}
}
if a.Intent != "" {
break
}
}
}
if a.Intent == "" && len(words) <= 3 && greetings[strings.Trim(words[0], "!.,")] {
a.Intent = "greeting"
}
if a.Intent == "" && isQuestion {
a.Intent = "question"
}
if a.Intent == "" {
for _, r := range requests {
if strings.Contains(lower, r) {
a.Intent = "request"
break
}
}
}
if a.Intent == "" {
a.Intent = "statement"
}
if a.Intent == "question" || a.Intent == "introspection" {
a.Question = strings.TrimSpace(text)
}
// Topic: the entity mentioned after "about", else the first entity.
if i := strings.Index(lower, " about "); i >= 0 {
rest := strings.Trim(strings.TrimSpace(lower[i+7:]), "?.!")
if rest != "" {
a.Topic = strings.Join(strings.Fields(rest), " ")
}
}
if a.Topic == "" && len(a.Entities) > 0 {
a.Topic = a.Entities[0]
}
// Claims: declarative sentences in statements and corrections.
if a.Intent == "statement" || a.Intent == "correction" {
for _, sent := range splitSentences(text) {
sl := strings.ToLower(sent)
if strings.HasSuffix(strings.TrimSpace(sent), "?") || len(strings.Fields(sent)) < 3 {
continue
}
certainty := "asserted"
for _, h := range hedges {
if strings.Contains(sl, h) {
certainty = "hedged"
break
}
}
clean := strings.TrimSpace(sent)
for _, c := range corrections {
clean = strings.TrimSpace(strings.TrimPrefix(strings.ToLower(clean), c))
}
if len(clean) >= 8 {
a.Claims = append(a.Claims, Claim{Proposition: strings.TrimRight(clean, ".!"), Certainty: certainty})
}
}
}
return a
}
func splitSentences(text string) []string {
var out []string
var cur strings.Builder
runes := []rune(text)
for i, r := range runes {
cur.WriteRune(r)
endOfSentence := r == '\n' || ((r == '.' || r == '!' || r == '?') && (i+1 == len(runes) || runes[i+1] == ' ' || runes[i+1] == '\n'))
if endOfSentence {
if s := strings.TrimSpace(cur.String()); s != "" {
out = append(out, s)
}
cur.Reset()
}
}
if s := strings.TrimSpace(cur.String()); s != "" {
out = append(out, s)
}
return out
}
var stop = map[string]bool{"the": true, "a": true, "an": true, "is": true, "are": true, "was": true, "were": true, "of": true, "to": true, "in": true, "on": true, "at": true, "for": true, "and": true, "or": true, "that": true, "this": true, "it": true, "you": true, "your": true, "i": true, "me": true, "my": true, "we": true, "do": true, "does": true, "about": true, "what": true, "why": true, "how": true, "with": true, "be": true, "have": true, "has": true, "had": true, "can": true, "please": true}
func tokens(lower string) []string {
var out []string
seen := map[string]bool{}
var cur strings.Builder
flush := func() {
w := strings.Trim(cur.String(), ".-_")
cur.Reset()
if len(w) >= 2 && !stop[w] && !seen[w] {
seen[w] = true
out = append(out, w)
}
}
for _, r := range lower {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '.' || r == '_' {
cur.WriteRune(r)
} else {
flush()
}
}
flush()
sort.Strings(out)
return out
}
+44
View File
@@ -0,0 +1,44 @@
package conversation
import "testing"
func TestAnalyseIntents(t *testing.T) {
cases := []struct {
text, intent, intro string
}{
{"What are you thinking about?", "introspection", "thinking"},
{"Why do you believe atlas is slow?", "introspection", "why"},
{"What are you uncertain about?", "introspection", "uncertain"},
{"Which of your beliefs contradict each other?", "introspection", "contradictions"},
{"What did you learn this week?", "introspection", "learned"},
{"What have you changed your mind about?", "introspection", "changed"},
{"What do you currently believe about atlas?", "introspection", "beliefs"},
{"Is atlas slow again since the deploy?", "question", ""},
{"Please check the disk on server-01", "request", ""},
{"No, that's wrong, atlas is on the new rack", "correction", ""},
{"hello", "greeting", ""},
{"Andy prefers Go for deployed services.", "statement", ""},
}
for _, c := range cases {
a := Analyse(c.text)
if a.Intent != c.intent || a.Introspection != c.intro {
t.Errorf("%q: got %s/%s want %s/%s", c.text, a.Intent, a.Introspection, c.intent, c.intro)
}
}
}
func TestAnalyseEntitiesClaimsAndUrgency(t *testing.T) {
a := Analyse("server-01 is down and atlas.local shows SMART errors. I think the SATA cable might be loose.")
if a.Urgency < 0.8 || a.Sentiment >= 0 {
t.Fatalf("urgency/sentiment wrong: %+v", a)
}
if len(a.Entities) < 2 {
t.Fatalf("entities missing: %v", a.Entities)
}
if len(a.Claims) != 2 || a.Claims[0].Certainty != "asserted" || a.Claims[1].Certainty != "hedged" {
t.Fatalf("claims wrong: %+v", a.Claims)
}
if b := Analyse("What do you believe about server-01?"); b.Topic != "server-01" {
t.Fatalf("topic wrong: %q", b.Topic)
}
}
+215
View File
@@ -0,0 +1,215 @@
// Package conversation stores conversations and messages and provides the
// deterministic first pass of message understanding. A user message is never
// sent straight to a model: it becomes an observation event that the
// cognitive cycle perceives, recalls against, reasons about and answers.
package conversation
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/ids"
)
// Conversation groups messages with a person, project and channel.
type Conversation struct {
ID ids.ID `json:"id"`
MindID ids.ID `json:"mind_id"`
Title string `json:"title"`
Channel string `json:"channel"`
Person string `json:"person"`
Project string `json:"project"`
Context map[string]any `json:"context"`
CreatedAt time.Time `json:"created_at"`
LastMessageAt time.Time `json:"last_message_at"`
}
// Message is one turn.
type Message struct {
ID ids.ID `json:"id"`
ConversationID ids.ID `json:"conversation_id"`
MindID ids.ID `json:"mind_id"`
Role string `json:"role"` // user | mind | system
Actor string `json:"actor"`
Content string `json:"content"`
EventID ids.ID `json:"event_id,omitempty"`
ReplyTo ids.ID `json:"reply_to,omitempty"`
Intent json.RawMessage `json:"intent,omitempty"`
LLMCallID ids.ID `json:"llm_call_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ErrInvalid marks bad input.
var ErrInvalid = errors.New("conversation: invalid")
// Store persists conversations.
type Store struct{ db *pgxpool.Pool }
// NewStore creates a store.
func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} }
// Create starts a conversation.
func (s *Store) Create(ctx context.Context, mind ids.ID, title, channel, person, project string) (Conversation, error) {
if channel == "" {
channel = "web"
}
c := Conversation{ID: ids.New(), MindID: mind, Title: strings.TrimSpace(title), Channel: channel, Person: strings.TrimSpace(person), Project: strings.TrimSpace(project), Context: map[string]any{}, CreatedAt: time.Now(), LastMessageAt: time.Now()}
_, err := s.db.Exec(ctx, `INSERT INTO conversations (id, mind_id, title, channel, person, project) VALUES ($1,$2,$3,$4,$5,$6)`, c.ID, c.MindID, c.Title, c.Channel, c.Person, c.Project)
return c, err
}
const convCols = `id, mind_id, title, channel, person, project, context, created_at, last_message_at`
func scanConv(row pgx.Row) (Conversation, error) {
var c Conversation
var ctxJSON []byte
if err := row.Scan(&c.ID, &c.MindID, &c.Title, &c.Channel, &c.Person, &c.Project, &ctxJSON, &c.CreatedAt, &c.LastMessageAt); err != nil {
return c, storage.Translate(err)
}
json.Unmarshal(ctxJSON, &c.Context) //nolint:errcheck
if c.Context == nil {
c.Context = map[string]any{}
}
return c, nil
}
// Get fetches a conversation scoped to a mind.
func (s *Store) Get(ctx context.Context, mind, id ids.ID) (Conversation, error) {
return scanConv(s.db.QueryRow(ctx, `SELECT `+convCols+` FROM conversations WHERE id=$1 AND mind_id=$2`, id, mind))
}
// List returns a mind's conversations, most recent first.
func (s *Store) List(ctx context.Context, mind ids.ID, limit int) ([]Conversation, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.db.Query(ctx, `SELECT `+convCols+` FROM conversations WHERE mind_id=$1 ORDER BY last_message_at DESC LIMIT $2`, mind, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Conversation
for rows.Next() {
c, err := scanConv(rows)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// AddMessage appends a message using q (pool or transaction).
func (s *Store) AddMessage(ctx context.Context, q storage.Querier, m Message) (Message, error) {
m.Content = strings.TrimSpace(m.Content)
if m.Content == "" || len(m.Content) > 20000 {
return Message{}, fmt.Errorf("%w: content required (max 20000 chars)", ErrInvalid)
}
if m.Role != "user" && m.Role != "mind" && m.Role != "system" {
return Message{}, fmt.Errorf("%w: bad role", ErrInvalid)
}
if m.ID.IsZero() {
m.ID = ids.New()
}
m.CreatedAt = time.Now()
_, err := q.Exec(ctx, `INSERT INTO messages (id, conversation_id, mind_id, role, actor, content, event_id, reply_to, intent, llm_call_id, created_at)
SELECT $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11 WHERE EXISTS (SELECT 1 FROM conversations WHERE id=$2 AND mind_id=$3)`,
m.ID, m.ConversationID, m.MindID, m.Role, m.Actor, m.Content, nullID(m.EventID), nullID(m.ReplyTo), nullRaw(m.Intent), nullID(m.LLMCallID), m.CreatedAt)
if err != nil {
return Message{}, err
}
q.Exec(ctx, `UPDATE conversations SET last_message_at=$2, title=CASE WHEN title='' THEN left($3, 80) ELSE title END WHERE id=$1`, m.ConversationID, m.CreatedAt, m.Content) //nolint:errcheck
return m, nil
}
// SetEventID links a message to its event after the event is written.
func (s *Store) SetEventID(ctx context.Context, q storage.Querier, id, eventID ids.ID) error {
_, err := q.Exec(ctx, `UPDATE messages SET event_id=$2 WHERE id=$1`, id, eventID)
return err
}
func nullID(id ids.ID) *string {
if id.IsZero() {
return nil
}
s := string(id)
return &s
}
func nullRaw(r json.RawMessage) *json.RawMessage {
if len(r) == 0 {
return nil
}
return &r
}
// Messages lists a conversation's messages in order.
func (s *Store) Messages(ctx context.Context, mind, conv ids.ID, limit int) ([]Message, error) {
if limit <= 0 || limit > 1000 {
limit = 200
}
rows, err := s.db.Query(ctx, `SELECT id, conversation_id, mind_id, role, actor, content, event_id, reply_to, intent, llm_call_id, created_at FROM messages
WHERE conversation_id=$1 AND mind_id=$2 ORDER BY created_at DESC LIMIT $3`, conv, mind, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Message
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, err
}
out = append(out, m)
}
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out, rows.Err()
}
func scanMessage(row pgx.Row) (Message, error) {
var m Message
var ev, reply, call *string
var intent *json.RawMessage
if err := row.Scan(&m.ID, &m.ConversationID, &m.MindID, &m.Role, &m.Actor, &m.Content, &ev, &reply, &intent, &call, &m.CreatedAt); err != nil {
return m, storage.Translate(err)
}
if ev != nil {
m.EventID = ids.ID(*ev)
}
if reply != nil {
m.ReplyTo = ids.ID(*reply)
}
if call != nil {
m.LLMCallID = ids.ID(*call)
}
if intent != nil {
m.Intent = *intent
}
return m, nil
}
// Reply returns the mind's reply to a user message, if any.
func (s *Store) Reply(ctx context.Context, mind, userMessage ids.ID) (Message, error) {
return scanMessage(s.db.QueryRow(ctx, `SELECT id, conversation_id, mind_id, role, actor, content, event_id, reply_to, intent, llm_call_id, created_at FROM messages WHERE reply_to=$1 AND mind_id=$2 ORDER BY created_at LIMIT 1`, userMessage, mind))
}
// Recent returns the last n messages of a conversation for context.
func (s *Store) Recent(ctx context.Context, mind, conv ids.ID, n int) ([]Message, error) {
msgs, err := s.Messages(ctx, mind, conv, n)
return msgs, err
}
// GetMessage fetches one message.
func (s *Store) GetMessage(ctx context.Context, mind, id ids.ID) (Message, error) {
return scanMessage(s.db.QueryRow(ctx, `SELECT id, conversation_id, mind_id, role, actor, content, event_id, reply_to, intent, llm_call_id, created_at FROM messages WHERE id=$1 AND mind_id=$2`, id, mind))
}
+51
View File
@@ -0,0 +1,51 @@
package conversation
import (
"context"
"testing"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/migrations"
"github.com/andydixon/noema/pkg/ids"
)
func TestStoreScopingAndReplies(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)
c, err := s.Create(ctx, a.ID, "", "web", "andy", "noema")
if err != nil {
t.Fatal(err)
}
um, err := s.AddMessage(ctx, pool, Message{ConversationID: c.ID, MindID: a.ID, Role: "user", Actor: "andy", Content: "Is atlas slow?"})
if err != nil {
t.Fatal(err)
}
if _, err := s.AddMessage(ctx, pool, Message{ConversationID: c.ID, MindID: b.ID, Role: "user", Content: "leak?"}); err == nil {
t.Log("cross-mind insert silently ignored (no row)")
}
msgsB, _ := s.Messages(ctx, b.ID, c.ID, 10)
if len(msgsB) != 0 {
t.Fatal("cross-mind message visible")
}
if _, err := s.Get(ctx, b.ID, c.ID); err == nil {
t.Fatal("cross-mind conversation read")
}
rm, _ := s.AddMessage(ctx, pool, Message{ConversationID: c.ID, MindID: a.ID, Role: "mind", Actor: "a", Content: "Somewhat.", ReplyTo: um.ID, LLMCallID: ids.New()})
got, err := s.Reply(ctx, a.ID, um.ID)
if err != nil || got.ID != rm.ID {
t.Fatalf("reply lookup: %v %+v", err, got)
}
cv, _ := s.Get(ctx, a.ID, c.ID)
if cv.Title != "Is atlas slow?" {
t.Fatalf("title not derived: %q", cv.Title)
}
msgs, _ := s.Messages(ctx, a.ID, c.ID, 10)
if len(msgs) != 2 || msgs[0].Role != "user" {
t.Fatalf("ordering wrong: %+v", msgs)
}
}