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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user