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:
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/andydixon/noema/internal/beliefs"
|
||||
"github.com/andydixon/noema/internal/brain"
|
||||
"github.com/andydixon/noema/internal/config"
|
||||
"github.com/andydixon/noema/internal/conversation"
|
||||
evstore "github.com/andydixon/noema/internal/events"
|
||||
"github.com/andydixon/noema/internal/llm/prompts"
|
||||
"github.com/andydixon/noema/internal/llm/router"
|
||||
@@ -38,7 +39,7 @@ func testServer(t *testing.T) (*httptest.Server, string) {
|
||||
llmStore := router.NewStore(pool)
|
||||
deps := app.Deps{Config: cfg, Log: log, DB: pool, Auth: auth.NewService(pool, time.Hour, 2*time.Hour), Secrets: sec, LLMStore: llmStore,
|
||||
LLM: router.NewRouter(llmStore, sec, log, nil, 2), Prompts: prompts.NewStore(pool),
|
||||
Audit: audit.New(pool, log), Metrics: observability.NewMetrics(), Minds: minds.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()}
|
||||
Audit: audit.New(pool, log), Metrics: observability.NewMetrics(), Minds: minds.NewStore(pool), Conversations: conversation.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()
|
||||
group := scheduler.NewGroup(ctx, log)
|
||||
t.Cleanup(func() { group.Stop() })
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/noema/internal/auth"
|
||||
"github.com/andydixon/noema/internal/conversation"
|
||||
"github.com/andydixon/noema/internal/storage"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
func (s *Server) conversationRoutes() {
|
||||
m := s.mux
|
||||
m.Handle("GET /api/v1/minds/{mind}/conversations", require(auth.RoleObserver, s.conversationsList))
|
||||
m.Handle("POST /api/v1/minds/{mind}/conversations", require(auth.RoleOperator, s.conversationsCreate))
|
||||
m.Handle("GET /api/v1/minds/{mind}/conversations/{id}", require(auth.RoleObserver, s.conversationGet))
|
||||
m.Handle("POST /api/v1/minds/{mind}/conversations/{id}/messages", require(auth.RoleOperator, s.messagePost))
|
||||
m.Handle("POST /api/v1/minds/{mind}/ask", require(auth.RoleOperator, s.ask))
|
||||
}
|
||||
|
||||
func convErr(err error) error {
|
||||
if errors.Is(err, conversation.ErrInvalid) {
|
||||
return ValidationError(err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) conversationsList(w http.ResponseWriter, r *http.Request) {
|
||||
m, ok := s.mind(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
list, err := s.deps.Conversations.List(r.Context(), m.ID, 100)
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []conversation.Conversation{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, page[conversation.Conversation]{Items: list})
|
||||
}
|
||||
|
||||
type conversationIn struct {
|
||||
Title string `json:"title"`
|
||||
Channel string `json:"channel"`
|
||||
Person string `json:"person"`
|
||||
Project string `json:"project"`
|
||||
}
|
||||
|
||||
func (s *Server) conversationsCreate(w http.ResponseWriter, r *http.Request) {
|
||||
m, ok := s.mind(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in conversationIn
|
||||
if r.ContentLength != 0 {
|
||||
if err := decode(r, &in); err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if in.Person == "" {
|
||||
in.Person = actor(r)
|
||||
}
|
||||
c, err := s.deps.Conversations.Create(r.Context(), m.ID, in.Title, firstNonEmpty(in.Channel, "api"), in.Person, in.Project)
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, c)
|
||||
}
|
||||
|
||||
// ConversationView is a conversation with its messages.
|
||||
type ConversationView struct {
|
||||
Conversation conversation.Conversation `json:"conversation"`
|
||||
Messages []conversation.Message `json:"messages"`
|
||||
}
|
||||
|
||||
func (s *Server) conversationGet(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
|
||||
}
|
||||
c, err := s.deps.Conversations.Get(r.Context(), m.ID, id)
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
msgs, _ := s.deps.Conversations.Messages(r.Context(), m.ID, id, 200)
|
||||
if msgs == nil {
|
||||
msgs = []conversation.Message{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ConversationView{Conversation: c, Messages: msgs})
|
||||
}
|
||||
|
||||
// PostMessage records a user message as an observation event so the cognitive
|
||||
// cycle perceives it. Shared with the UI. It returns the stored message.
|
||||
func (s *Server) PostMessage(r *http.Request, mindID ids.ID, conv conversation.Conversation, text, who string) (conversation.Message, error) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" || len(text) > 20000 {
|
||||
return conversation.Message{}, ValidationError("message required (max 20000 chars)")
|
||||
}
|
||||
trust := events.TrustUser
|
||||
if p, ok := auth.PrincipalFrom(r.Context()); ok && p.User.Role.AtLeast(auth.RoleAdministrator) {
|
||||
trust = events.TrustOperator
|
||||
}
|
||||
msg, err := s.deps.Conversations.AddMessage(r.Context(), s.deps.DB, conversation.Message{ConversationID: conv.ID, MindID: mindID, Role: "user", Actor: who, Content: text})
|
||||
if err != nil {
|
||||
return conversation.Message{}, convErr(err)
|
||||
}
|
||||
summary := text
|
||||
if len(summary) > 160 {
|
||||
summary = summary[:157] + "..."
|
||||
}
|
||||
evs, err := s.deps.Events.Append(r.Context(), events.New{MindID: mindID, Type: events.Observation, Source: "conversation", Summary: who + ": " + summary, Salience: 0.8, Confidence: 1,
|
||||
Metadata: map[string]any{"conversation_id": string(conv.ID), "message_id": string(msg.ID), "trust": trust, "actor": who, "channel": conv.Channel, "person": conv.Person, "project": conv.Project},
|
||||
Payload: events.ObservationPayload{Text: text, ContentType: "text/plain", Source: "conversation", Trust: trust, Method: "chat", Reference: string(msg.ID), Channel: string(conv.ID), Actor: who}})
|
||||
if err != nil {
|
||||
return msg, err
|
||||
}
|
||||
s.deps.Conversations.SetEventID(r.Context(), s.deps.DB, msg.ID, evs[0].ID) //nolint:errcheck
|
||||
msg.EventID = evs[0].ID
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// WaitReply polls for the mind's reply to a message for up to d.
|
||||
func (s *Server) WaitReply(r *http.Request, mindID, msgID ids.ID, d time.Duration) (conversation.Message, bool) {
|
||||
deadline := time.Now().Add(d)
|
||||
for {
|
||||
if reply, err := s.deps.Conversations.Reply(r.Context(), mindID, msgID); err == nil {
|
||||
return reply, true
|
||||
} else if !errors.Is(err, storage.ErrNotFound) {
|
||||
return conversation.Message{}, false
|
||||
}
|
||||
if time.Now().After(deadline) || r.Context().Err() != nil {
|
||||
return conversation.Message{}, false
|
||||
}
|
||||
select {
|
||||
case <-time.After(250 * time.Millisecond):
|
||||
case <-r.Context().Done():
|
||||
return conversation.Message{}, false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) messagePost(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
|
||||
}
|
||||
conv, err := s.deps.Conversations.Get(r.Context(), m.ID, id)
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Text string `json:"text"`
|
||||
Wait int `json:"wait_seconds"`
|
||||
}
|
||||
if err := decode(r, &in); err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
msg, err := s.PostMessage(r, m.ID, conv, in.Text, actor(r))
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
out := map[string]any{"message": msg}
|
||||
if in.Wait > 0 {
|
||||
if in.Wait > 120 {
|
||||
in.Wait = 120
|
||||
}
|
||||
if reply, ok := s.WaitReply(r, m.ID, msg.ID, time.Duration(in.Wait)*time.Second); ok {
|
||||
out["reply"] = reply
|
||||
} else {
|
||||
out["reply"] = nil
|
||||
out["note"] = "no reply within the wait window; the mind may be paused, busy or over budget"
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// ask is the one-shot conversational endpoint used by noemactl ask: it posts
|
||||
// into (or creates) the caller's CLI conversation and waits for the reply.
|
||||
func (s *Server) ask(w http.ResponseWriter, r *http.Request) {
|
||||
m, ok := s.mind(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Text string `json:"text"`
|
||||
ConversationID ids.ID `json:"conversation_id"`
|
||||
Wait int `json:"wait_seconds"`
|
||||
}
|
||||
if err := decode(r, &in); err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
var conv conversation.Conversation
|
||||
var err error
|
||||
if !in.ConversationID.IsZero() {
|
||||
conv, err = s.deps.Conversations.Get(r.Context(), m.ID, in.ConversationID)
|
||||
} else {
|
||||
list, _ := s.deps.Conversations.List(r.Context(), m.ID, 50)
|
||||
for _, c := range list {
|
||||
if c.Channel == "cli" && c.Person == actor(r) {
|
||||
conv = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if conv.ID.IsZero() {
|
||||
conv, err = s.deps.Conversations.Create(r.Context(), m.ID, "", "cli", actor(r), "")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
msg, err := s.PostMessage(r, m.ID, conv, in.Text, actor(r))
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if in.Wait <= 0 {
|
||||
in.Wait = 30
|
||||
}
|
||||
if in.Wait > 120 {
|
||||
in.Wait = 120
|
||||
}
|
||||
out := map[string]any{"conversation_id": conv.ID, "message": msg}
|
||||
if reply, ok := s.WaitReply(r, m.ID, msg.ID, time.Duration(in.Wait)*time.Second); ok {
|
||||
out["reply"] = reply
|
||||
} else {
|
||||
out["note"] = "no reply within " + strconv.Itoa(in.Wait) + "s"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/andydixon/noema/internal/auth"
|
||||
"github.com/andydixon/noema/internal/beliefs"
|
||||
"github.com/andydixon/noema/internal/memory"
|
||||
"github.com/andydixon/noema/internal/minds"
|
||||
"github.com/andydixon/noema/pkg/concepts"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
)
|
||||
|
||||
// Seed is the mind-seed document (schema noema.mind-seed/1): identity,
|
||||
// personality, configuration and starting knowledge. It is not the full
|
||||
// export/import archive (phase 15); it is how sample and template minds are
|
||||
// loaded. Treated as hostile input: strict decoding, size limits, operator
|
||||
// trust on every item, no secrets, no policies.
|
||||
type Seed struct {
|
||||
Schema string `json:"schema"`
|
||||
Slug string `json:"slug"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Notes string `json:"notes"`
|
||||
Personality *minds.Personality `json:"personality"`
|
||||
Config *minds.CognitiveConfig `json:"config"`
|
||||
Memories []struct {
|
||||
Kind concepts.MemoryKind `json:"kind"`
|
||||
Epistemic concepts.Epistemic `json:"epistemic"`
|
||||
Content string `json:"content"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Importance float64 `json:"importance"`
|
||||
} `json:"memories"`
|
||||
Beliefs []struct {
|
||||
Proposition string `json:"proposition"`
|
||||
Subject string `json:"subject"`
|
||||
Epistemic concepts.Epistemic `json:"epistemic"`
|
||||
Weight float64 `json:"weight"`
|
||||
Note string `json:"note"`
|
||||
} `json:"beliefs"`
|
||||
}
|
||||
|
||||
const maxSeedItems = 500
|
||||
|
||||
func (s *Server) importRoutes() {
|
||||
s.mux.Handle("POST /api/v1/minds/import", require(auth.RoleAdministrator, s.mindImport))
|
||||
}
|
||||
|
||||
// mindImport creates a mind from a seed document.
|
||||
func (s *Server) mindImport(w http.ResponseWriter, r *http.Request) {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
var seed Seed
|
||||
if err := dec.Decode(&seed); err != nil {
|
||||
fail(w, r, ValidationError("invalid seed: "+err.Error()))
|
||||
return
|
||||
}
|
||||
if seed.Schema != "noema.mind-seed/1" {
|
||||
fail(w, r, ValidationError("unsupported schema; expected noema.mind-seed/1"))
|
||||
return
|
||||
}
|
||||
if len(seed.Memories) > maxSeedItems || len(seed.Beliefs) > maxSeedItems {
|
||||
fail(w, r, ValidationError("too many seed items"))
|
||||
return
|
||||
}
|
||||
m, err := s.deps.Minds.Create(r.Context(), minds.CreateParams{Slug: seed.Slug, DisplayName: seed.DisplayName, Personality: seed.Personality, Config: seed.Config, Notes: seed.Notes, CreatedBy: actor(r)})
|
||||
if err != nil {
|
||||
fail(w, r, mindErr(err))
|
||||
return
|
||||
}
|
||||
var memCount, beliefCount int
|
||||
var problems []string
|
||||
for _, sm := range seed.Memories {
|
||||
mem, err := s.deps.Memory.Create(r.Context(), s.deps.DB, memory.New{MindID: m.ID, Kind: sm.Kind, Epistemic: sm.Epistemic, Content: sm.Content, Source: "seed", Trust: events.TrustOperator, Confidence: sm.Confidence, Importance: sm.Importance, Salience: 0.5})
|
||||
if err != nil {
|
||||
problems = append(problems, "memory: "+err.Error())
|
||||
continue
|
||||
}
|
||||
memCount++
|
||||
s.deps.Events.Append(r.Context(), events.New{MindID: m.ID, Type: events.MemoryCreated, Source: "seed", Summary: "seeded " + string(mem.Kind) + " memory: " + trunc(mem.Content, 120), Salience: 0.3, Confidence: mem.Confidence, //nolint:errcheck
|
||||
Evidence: []events.EvidenceRef{{Kind: "memory", ID: string(mem.ID)}}, Metadata: map[string]any{"memory_id": mem.ID, "kind": mem.Kind, "actor": actor(r)}})
|
||||
}
|
||||
for _, sb := range seed.Beliefs {
|
||||
weight := sb.Weight
|
||||
if weight <= 0 {
|
||||
weight = 0.5
|
||||
}
|
||||
_, err := s.deps.Beliefs.Create(r.Context(), s.deps.DB, s.Emitter(), beliefs.New{MindID: m.ID, Proposition: sb.Proposition, Subject: sb.Subject, Epistemic: sb.Epistemic, Source: "seed",
|
||||
Evidence: []beliefs.EvidenceInput{{Direction: "for", Kind: "operator", Source: "seed:" + actor(r), Weight: weight, Credibility: events.TrustOperator.Credibility(), Note: firstNonEmpty(sb.Note, "seeded by "+actor(r))}}})
|
||||
if err != nil {
|
||||
if errors.Is(err, beliefs.ErrInvalid) || errors.Is(err, beliefs.ErrBlocked) {
|
||||
problems = append(problems, "belief: "+err.Error())
|
||||
continue
|
||||
}
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
beliefCount++
|
||||
}
|
||||
s.deps.Brain.Reconcile(r.Context()) //nolint:errcheck
|
||||
s.deps.Audit.Log(r.Context(), "mind.import", "mind", m.ID.String(), map[string]any{"slug": m.Slug, "memories": memCount, "beliefs": beliefCount, "problems": problems})
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"mind": m, "memories": memCount, "beliefs": beliefCount, "problems": problems})
|
||||
}
|
||||
@@ -90,6 +90,9 @@ func (s *Server) mindsCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.deps.Audit.Log(r.Context(), "mind.create", "mind", m.ID.String(), map[string]any{"slug": m.Slug})
|
||||
if s.deps.Brain != nil {
|
||||
s.deps.Brain.Reconcile(r.Context()) //nolint:errcheck // starts the runtime now instead of at the next periodic reconcile
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, m)
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ func (s *Server) routes() {
|
||||
s.beliefRoutes()
|
||||
s.llmRoutes()
|
||||
s.promptRoutes()
|
||||
s.conversationRoutes()
|
||||
s.importRoutes()
|
||||
|
||||
m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint")
|
||||
|
||||
+19
-17
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/andydixon/noema/internal/beliefs"
|
||||
"github.com/andydixon/noema/internal/brain"
|
||||
"github.com/andydixon/noema/internal/config"
|
||||
"github.com/andydixon/noema/internal/conversation"
|
||||
"github.com/andydixon/noema/internal/events"
|
||||
"github.com/andydixon/noema/internal/llm/prompts"
|
||||
"github.com/andydixon/noema/internal/llm/router"
|
||||
@@ -26,21 +27,22 @@ import (
|
||||
|
||||
// Deps is everything a handler may need. Fields are added per phase.
|
||||
type Deps struct {
|
||||
Config config.Config
|
||||
Log *slog.Logger
|
||||
DB *pgxpool.Pool
|
||||
Auth *auth.Service
|
||||
Audit *audit.Logger
|
||||
Metrics *observability.Metrics
|
||||
Minds *minds.Store
|
||||
Events *events.Store
|
||||
Brain *brain.Manager
|
||||
Memory *memory.Store
|
||||
Beliefs *beliefs.Store
|
||||
LLM *router.Router
|
||||
LLMStore *router.Store
|
||||
Prompts *prompts.Store
|
||||
Secrets *secrets.Resolver
|
||||
Migrations fs.FS
|
||||
Started time.Time
|
||||
Config config.Config
|
||||
Log *slog.Logger
|
||||
DB *pgxpool.Pool
|
||||
Auth *auth.Service
|
||||
Audit *audit.Logger
|
||||
Metrics *observability.Metrics
|
||||
Minds *minds.Store
|
||||
Events *events.Store
|
||||
Brain *brain.Manager
|
||||
Memory *memory.Store
|
||||
Beliefs *beliefs.Store
|
||||
Conversations *conversation.Store
|
||||
LLM *router.Router
|
||||
LLMStore *router.Store
|
||||
Prompts *prompts.Store
|
||||
Secrets *secrets.Resolver
|
||||
Migrations fs.FS
|
||||
Started time.Time
|
||||
}
|
||||
|
||||
@@ -787,3 +787,94 @@ func trunc(s string, n int) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Search returns live beliefs matching any of the words in text, best match
|
||||
// first. Used to answer "what do you believe about X".
|
||||
func (s *Store) Search(ctx context.Context, mind ids.ID, text string, limit int) ([]Belief, error) {
|
||||
toks, _ := Polarity(text)
|
||||
if len(toks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 10
|
||||
}
|
||||
q := strings.Join(toks, " | ")
|
||||
rows, err := s.db.Query(ctx, `SELECT `+cols+` FROM beliefs b WHERE b.mind_id=$1 AND b.status NOT IN ('superseded') AND b.search @@ to_tsquery('english', $2)
|
||||
ORDER BY ts_rank_cd(b.search, to_tsquery('english', $2)) DESC, b.confidence DESC LIMIT $3`, mind, q, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Belief
|
||||
for rows.Next() {
|
||||
b, err := scan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Change describes a belief whose status or confidence moved.
|
||||
type Change struct {
|
||||
Belief Belief `json:"belief"`
|
||||
FromStatus Status `json:"from_status"`
|
||||
FromConf float64 `json:"from_confidence"`
|
||||
Reason string `json:"reason"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// RecentChanges lists beliefs whose latest version differs in status from an
|
||||
// earlier version within the window ("what have you changed your mind about").
|
||||
func (s *Store) RecentChanges(ctx context.Context, mind ids.ID, since time.Time, limit int) ([]Change, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 10
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `WITH latest AS (
|
||||
SELECT DISTINCT ON (belief_id) belief_id, status, confidence, reason, created_at FROM belief_versions WHERE mind_id=$1 AND created_at >= $2 ORDER BY belief_id, version DESC),
|
||||
first AS (
|
||||
SELECT DISTINCT ON (belief_id) belief_id, status, confidence FROM belief_versions WHERE mind_id=$1 ORDER BY belief_id, version ASC)
|
||||
SELECT `+cols+`, f.status, f.confidence, l.reason, l.created_at FROM latest l JOIN first f ON f.belief_id=l.belief_id JOIN beliefs b ON b.id=l.belief_id
|
||||
WHERE l.status <> f.status ORDER BY l.created_at DESC LIMIT $3`, mind, since, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Change
|
||||
for rows.Next() {
|
||||
var c Change
|
||||
var epi, status, fromStatus string
|
||||
var detail []byte
|
||||
var origin, superseded, pinned *string
|
||||
b := &c.Belief
|
||||
if err := rows.Scan(&b.ID, &b.MindID, &b.Proposition, &b.Subject, &epi, &status, &b.Confidence, &b.Prior, &b.Version, &b.Source, &detail, &origin, &superseded, &pinned, &b.FirstCreated, &b.LastEvaluated, &b.Evaluations, &fromStatus, &c.FromConf, &c.Reason, &c.At); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.Epistemic, b.Status, c.FromStatus = concepts.Epistemic(epi), Status(status), Status(fromStatus)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Uncertain returns beliefs in the middle band or contested, least certain first.
|
||||
func (s *Store) Uncertain(ctx context.Context, mind ids.ID, limit int) ([]Belief, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 10
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `SELECT `+cols+` FROM beliefs b WHERE b.mind_id=$1 AND b.status NOT IN ('rejected','superseded') AND (b.status='contested' OR (b.confidence BETWEEN 0.3 AND 0.7))
|
||||
ORDER BY abs(b.confidence-0.5) ASC, b.last_evaluated DESC LIMIT $2`, mind, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Belief
|
||||
for rows.Next() {
|
||||
b, err := scan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
@@ -643,6 +643,9 @@ func (r *Runtime) observe(ctx context.Context, st *State) []Candidate {
|
||||
return all
|
||||
}
|
||||
|
||||
// Workspace returns a copy of the live workspace state for handlers.
|
||||
func (r *Runtime) Workspace() workspace.State { return r.copyState() }
|
||||
|
||||
// Events exposes the event store to handlers.
|
||||
func (r *Runtime) Events() *evstore.Store { return r.events }
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ package brain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/noema/internal/attention"
|
||||
@@ -65,6 +66,21 @@ type State struct {
|
||||
NewEvents []events.Event // since the last cycle, ascending
|
||||
Workspace workspace.State // snapshot before this cycle
|
||||
Services Services
|
||||
llmCalls atomic.Int32 // model calls made in this cycle; processes and handlers share the cap
|
||||
}
|
||||
|
||||
// AllowLLM reserves one model call against the mind's per-cycle cap and
|
||||
// reports whether it was granted, so a chatty cycle cannot exceed the budget.
|
||||
func (s *State) AllowLLM() bool {
|
||||
cap := int32(s.Mind.Config.LLMCallsPerCycle)
|
||||
if cap <= 0 || s.Services.LLM == nil {
|
||||
return false
|
||||
}
|
||||
if s.llmCalls.Add(1) > cap {
|
||||
s.llmCalls.Add(-1)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Services are read handles processes may use. Extended per phase.
|
||||
|
||||
@@ -9,6 +9,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/memory"
|
||||
"github.com/andydixon/noema/internal/storage"
|
||||
"github.com/andydixon/noema/internal/workspace"
|
||||
@@ -19,8 +20,9 @@ import (
|
||||
|
||||
// Handlers are the executive actions for admitted thought kinds.
|
||||
type Handlers struct {
|
||||
Memory *memory.Store
|
||||
Beliefs *beliefs.Store
|
||||
Memory *memory.Store
|
||||
Beliefs *beliefs.Store
|
||||
Conversations *conversation.Store
|
||||
}
|
||||
|
||||
// Register installs the handlers on a registry.
|
||||
@@ -28,6 +30,7 @@ func (h Handlers) Register(r *brain.Registry) {
|
||||
r.Handle("attend", h.attend)
|
||||
r.Handle("retrieve", h.retrieve)
|
||||
r.Handle("hypothesis", h.hypothesis)
|
||||
r.Handle("perceive", h.perceive)
|
||||
}
|
||||
|
||||
// emitter adapts the cycle to the beliefs store's Emitter.
|
||||
@@ -84,6 +87,9 @@ func (h Handlers) attend(ctx context.Context, c *brain.Cycle, t workspace.Though
|
||||
var mem memory.Memory
|
||||
if h.Memory != nil && evID != "" {
|
||||
ev, err := c.Runtime.Events().Get(ctx, ids.ID(evID))
|
||||
if err == nil && ev.Metadata["conversation_id"] != nil {
|
||||
err = errSkipEncode // the perceive handler encodes conversational episodes
|
||||
}
|
||||
if err == nil {
|
||||
n := memory.New{MindID: c.State.Mind.ID, Kind: concepts.Episodic, Epistemic: concepts.Observation, Content: ev.Summary, Source: ev.Source,
|
||||
Trust: events.TrustLow, Confidence: ev.Confidence, Importance: t.Score, Salience: ev.Salience, Valence: ev.Valence, Arousal: ev.Arousal, OccurredAt: ev.OccurredAt, OriginEvent: ev.ID,
|
||||
@@ -143,3 +149,5 @@ func (h Handlers) retrieve(ctx context.Context, c *brain.Cycle, t workspace.Thou
|
||||
c.Runtime.Hold(workspace.Item{Key: "memory:" + ref, Content: t.Summary, Kind: "retrieval", Ref: ref, SourceEvent: ids.ID(src)})
|
||||
return nil
|
||||
}
|
||||
|
||||
var errSkipEncode = errors.New("skip")
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package cognition
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/noema/internal/attention"
|
||||
"github.com/andydixon/noema/internal/brain"
|
||||
"github.com/andydixon/noema/internal/conversation"
|
||||
"github.com/andydixon/noema/internal/llm"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// Perceive understands conversational messages. The deterministic analysis
|
||||
// always runs; when a model is available and the cycle budget allows, the
|
||||
// perceive prompt enriches entities and claims. Someone is waiting for an
|
||||
// answer, so the candidate carries high urgency and operator priority.
|
||||
type Perceive struct{}
|
||||
|
||||
// Name implements brain.Process.
|
||||
func (Perceive) Name() string { return "perceive" }
|
||||
|
||||
// Observe implements brain.Process.
|
||||
func (Perceive) Observe(ctx context.Context, s *brain.State) ([]brain.Candidate, error) {
|
||||
var out []brain.Candidate
|
||||
for _, ev := range s.NewEvents {
|
||||
if ev.Type != events.Observation || ev.Metadata["conversation_id"] == nil {
|
||||
continue
|
||||
}
|
||||
var p events.ObservationPayload
|
||||
if json.Unmarshal(ev.Payload, &p) != nil || p.Text == "" {
|
||||
continue
|
||||
}
|
||||
a := conversation.Analyse(p.Text)
|
||||
content := map[string]any{"source_event": string(ev.ID), "conversation_id": ev.Metadata["conversation_id"], "message_id": ev.Metadata["message_id"], "text": p.Text, "actor": p.Actor, "trust": p.Trust}
|
||||
evidence := []events.EvidenceRef{{Kind: "event", ID: string(ev.ID), Note: "message"}}
|
||||
if s.Services.LLM != nil && s.Services.Prompts != nil && s.Services.LLM.Available(ctx, llm.RolePerceive) && s.AllowLLM() {
|
||||
if pr, err := s.Services.Prompts.Get(ctx, "perceive.message"); err == nil {
|
||||
if req, err := pr.Render(map[string]any{"Text": p.Text, "Actor": p.Actor, "Trust": p.Trust, "Channel": p.Channel}); err == nil {
|
||||
req.MindID, req.CorrelationID, req.Purpose = s.Mind.ID, s.CycleID, "perceive message"
|
||||
if resp, err := s.Services.LLM.Complete(ctx, req); err == nil && len(resp.JSON) > 0 {
|
||||
merge(&a, resp.JSON)
|
||||
content["llm_call"] = string(resp.CallID)
|
||||
content["llm_model"] = resp.Provider + "/" + resp.Model
|
||||
evidence = append(evidence, events.EvidenceRef{Kind: "llm_call", ID: string(resp.CallID), Note: "perception"})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
content["analysis"] = a
|
||||
prio := 0.7
|
||||
if p.Trust == events.TrustOperator {
|
||||
prio = 1
|
||||
}
|
||||
out = append(out, brain.Candidate{Kind: "perceive", Subject: string(ev.ID), Summary: "understand message (" + a.Intent + "): " + truncate(p.Text, 100), Content: content,
|
||||
Features: attention.Features{Salience: 0.9, Urgency: 0.6 + 0.4*a.Urgency, Novelty: 0.5, Consequence: 0.8, Confidence: 1, OperatorPriority: prio, ContextRelevance: 0.6},
|
||||
Confidence: 1, SourceEvents: []ids.ID{ev.ID}, Evidence: evidence})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// merge folds model-extracted fields into the deterministic analysis. The
|
||||
// intent stays deterministic unless the model disagrees on question/statement
|
||||
// and the message has no question mark; entities and claims are unioned.
|
||||
func merge(a *conversation.Analysis, raw json.RawMessage) {
|
||||
var m struct {
|
||||
Intent string `json:"intent"`
|
||||
Summary string `json:"summary"`
|
||||
Entities []struct{ Name, Kind string } `json:"entities"`
|
||||
Claims []struct{ Proposition, Certainty string } `json:"claims"`
|
||||
Questions []string `json:"questions"`
|
||||
Urgency float64 `json:"urgency"`
|
||||
}
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return
|
||||
}
|
||||
a.Source = "llm"
|
||||
seen := map[string]bool{}
|
||||
for _, e := range a.Entities {
|
||||
seen[strings.ToLower(e)] = true
|
||||
}
|
||||
for _, e := range m.Entities {
|
||||
if e.Name != "" && !seen[strings.ToLower(e.Name)] {
|
||||
seen[strings.ToLower(e.Name)] = true
|
||||
a.Entities = append(a.Entities, e.Name)
|
||||
}
|
||||
}
|
||||
if a.Intent == "statement" || a.Intent == "correction" {
|
||||
known := map[string]bool{}
|
||||
for _, c := range a.Claims {
|
||||
known[strings.ToLower(c.Proposition)] = true
|
||||
}
|
||||
for _, c := range m.Claims {
|
||||
if c.Proposition != "" && !known[strings.ToLower(c.Proposition)] && c.Certainty != "asked" {
|
||||
cert := "asserted"
|
||||
if c.Certainty == "hedged" {
|
||||
cert = "hedged"
|
||||
}
|
||||
a.Claims = append(a.Claims, conversation.Claim{Proposition: c.Proposition, Certainty: cert})
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.Urgency > a.Urgency {
|
||||
a.Urgency = m.Urgency
|
||||
}
|
||||
if a.Intent == "statement" && m.Intent == "request" {
|
||||
a.Intent = "request"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
package cognition
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/noema/internal/beliefs"
|
||||
"github.com/andydixon/noema/internal/brain"
|
||||
"github.com/andydixon/noema/internal/conversation"
|
||||
evstore "github.com/andydixon/noema/internal/events"
|
||||
"github.com/andydixon/noema/internal/llm"
|
||||
"github.com/andydixon/noema/internal/memory"
|
||||
"github.com/andydixon/noema/internal/workspace"
|
||||
"github.com/andydixon/noema/pkg/concepts"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// Intent is the structured decision of what to say, built from state before
|
||||
// any language is generated. It is stored with the reply so the operator can
|
||||
// check that the words match the intent and the intent matches the evidence.
|
||||
type Intent struct {
|
||||
Kind string `json:"kind"` // answer | introspect | acknowledge | clarify | decline | greet | unknown
|
||||
Summary string `json:"summary"`
|
||||
Points []string `json:"points"`
|
||||
Beliefs []BeliefRef `json:"beliefs"`
|
||||
Memories []MemRef `json:"memories"`
|
||||
Uncertainty string `json:"uncertainty,omitempty"`
|
||||
Limitations []string `json:"limitations,omitempty"`
|
||||
Analysis any `json:"analysis"`
|
||||
}
|
||||
|
||||
// BeliefRef cites a belief.
|
||||
type BeliefRef struct {
|
||||
ID ids.ID `json:"id"`
|
||||
Proposition string `json:"proposition"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// MemRef cites a memory.
|
||||
type MemRef struct {
|
||||
ID ids.ID `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Content string `json:"content"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
// perceive is the handler for admitted "perceive" thoughts: it records the
|
||||
// perception, encodes the episode, turns claims into beliefs, builds the
|
||||
// response intent and generates the reply.
|
||||
func (h Handlers) perceive(ctx context.Context, c *brain.Cycle, t workspace.Thought) error {
|
||||
raw, _ := json.Marshal(t.Content["analysis"])
|
||||
var a conversation.Analysis
|
||||
json.Unmarshal(raw, &a) //nolint:errcheck
|
||||
text, _ := t.Content["text"].(string)
|
||||
actor, _ := t.Content["actor"].(string)
|
||||
convID, _ := t.Content["conversation_id"].(string)
|
||||
msgID, _ := t.Content["message_id"].(string)
|
||||
srcID, _ := t.Content["source_event"].(string)
|
||||
trust := events.Trust(fmt.Sprint(t.Content["trust"]))
|
||||
if !trust.Valid() {
|
||||
trust = events.TrustUser
|
||||
}
|
||||
mind := c.State.Mind
|
||||
|
||||
if call, ok := t.Content["llm_call"].(string); ok && call != "" {
|
||||
c.Emit(events.New{Type: events.LLMResponse, Source: "brain.perceive", Summary: "perception enriched by " + fmt.Sprint(t.Content["llm_model"]), Salience: 0.1, Confidence: 0.8,
|
||||
CausationID: t.EventID, Evidence: []events.EvidenceRef{{Kind: "llm_call", ID: call}}, Metadata: map[string]any{"role": "perceive", "call_id": call}})
|
||||
}
|
||||
perc, err := c.EmitNow(ctx, events.New{Type: events.Perception, Source: "brain.perceive", Summary: fmt.Sprintf("perceived %s from %s: %s", a.Intent, actor, truncate(text, 100)),
|
||||
Salience: t.Score, Confidence: 0.9, CausationID: t.EventID, ParentIDs: []ids.ID{t.EventID, ids.ID(srcID)}, Evidence: t.Evidence,
|
||||
Metadata: map[string]any{"intent": a.Intent, "introspection": a.Introspection, "conversation_id": convID, "message_id": msgID, "entities": a.Entities},
|
||||
Payload: map[string]any{"analysis": a, "text": text, "actor": actor}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Episode of the exchange.
|
||||
var memRef ids.ID
|
||||
if h.Memory != nil {
|
||||
mem, err := h.Memory.Create(ctx, c.Tx, memory.New{MindID: mind.ID, Kind: concepts.Episodic, Epistemic: concepts.Observation, Content: actor + " said: " + text, Source: "conversation",
|
||||
Trust: trust, Confidence: 1, Importance: 0.4 + 0.3*a.Urgency, Salience: t.Score, OccurredAt: c.State.Now, OriginEvent: ids.ID(srcID),
|
||||
Detail: map[string]any{"participants": []string{actor}, "channel": "conversation", "conversation_id": convID, "intent": a.Intent, "entities": a.Entities}})
|
||||
if err == nil {
|
||||
memRef = mem.ID
|
||||
c.Emit(events.New{Type: events.MemoryCreated, Source: "brain.memory", Summary: "remembered: " + truncate(mem.Content, 100), Salience: 0.3, Confidence: 1, CausationID: perc.ID,
|
||||
Evidence: []events.EvidenceRef{{Kind: "memory", ID: string(mem.ID)}, {Kind: "event", ID: srcID, Note: "source"}}, Metadata: map[string]any{"memory_id": mem.ID, "kind": mem.Kind}})
|
||||
}
|
||||
}
|
||||
// Claims become beliefs; the message is their evidence.
|
||||
var claimed []BeliefRef
|
||||
if h.Beliefs != nil {
|
||||
for _, cl := range a.Claims {
|
||||
w := 0.6
|
||||
if cl.Certainty == "hedged" {
|
||||
w = 0.3
|
||||
}
|
||||
if a.Intent == "correction" {
|
||||
w += 0.2
|
||||
}
|
||||
res, err := h.Beliefs.Create(ctx, c.Tx, emitter(c), beliefs.New{MindID: mind.ID, Proposition: cl.Proposition, Epistemic: concepts.Claim, Source: "conversation", OriginEvent: perc.ID, Causation: perc.ID, Correlation: c.State.CycleID,
|
||||
Evidence: []beliefs.EvidenceInput{{Direction: "for", Kind: "event", RefID: srcID, Source: "said-by:" + actor, Weight: w, Credibility: trust.Credibility(), Note: actor + " " + cl.Certainty + " it"}}})
|
||||
if err != nil {
|
||||
c.Emit(events.New{Type: events.Metacognition, Source: "brain.perceive", Summary: "claim not recorded: " + err.Error(), Salience: 0.3, Confidence: 1, Metadata: map[string]any{"check": "claim_rejected"}})
|
||||
continue
|
||||
}
|
||||
claimed = append(claimed, BeliefRef{ID: res.Belief.ID, Proposition: res.Belief.Proposition, Confidence: res.Belief.Confidence, Status: string(res.Belief.Status)})
|
||||
}
|
||||
}
|
||||
intent := h.buildIntent(ctx, c, a, text, claimed)
|
||||
reply, callID, err := h.generate(ctx, c, text, intent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
evidence := []events.EvidenceRef{{Kind: "event", ID: srcID, Note: "question"}, {Kind: "event", ID: string(perc.ID), Note: "perception"}}
|
||||
for _, b := range intent.Beliefs {
|
||||
evidence = append(evidence, events.EvidenceRef{Kind: "belief", ID: string(b.ID), Note: fmt.Sprintf("cited %.2f", b.Confidence)})
|
||||
}
|
||||
for _, m := range intent.Memories {
|
||||
evidence = append(evidence, events.EvidenceRef{Kind: "memory", ID: string(m.ID), Note: "cited"})
|
||||
}
|
||||
if !memRef.IsZero() {
|
||||
evidence = append(evidence, events.EvidenceRef{Kind: "memory", ID: string(memRef), Note: "episode"})
|
||||
}
|
||||
if !callID.IsZero() {
|
||||
evidence = append(evidence, events.EvidenceRef{Kind: "llm_call", ID: string(callID), Note: "wording"})
|
||||
}
|
||||
respEv, err := c.EmitNow(ctx, events.New{Type: events.ResponseGenerated, Source: "brain.respond", Summary: "replied (" + intent.Kind + "): " + truncate(reply, 120), Salience: 0.5, Confidence: intentConfidence(intent),
|
||||
CausationID: perc.ID, ParentIDs: []ids.ID{perc.ID}, Evidence: evidence,
|
||||
Metadata: map[string]any{"conversation_id": convID, "message_id": msgID, "intent": intent.Kind, "llm_call": callID},
|
||||
Payload: map[string]any{"intent": intent, "reply": reply, "model_used": !callID.IsZero()}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if h.Conversations != nil && convID != "" {
|
||||
intentJSON, _ := json.Marshal(intent)
|
||||
if _, err := h.Conversations.AddMessage(ctx, c.Tx, conversation.Message{ConversationID: ids.ID(convID), MindID: mind.ID, Role: "mind", Actor: mind.DisplayName, Content: reply,
|
||||
EventID: respEv.ID, ReplyTo: ids.ID(msgID), Intent: intentJSON, LLMCallID: callID}); err != nil {
|
||||
return fmt.Errorf("store reply: %w", err)
|
||||
}
|
||||
}
|
||||
c.Runtime.Hold(workspace.Item{Key: "conversation:" + convID, Content: actor + ": " + truncate(text, 80) + " → " + truncate(reply, 80), Kind: "thought", SourceEvent: ids.ID(srcID), Ref: convID})
|
||||
return nil
|
||||
}
|
||||
|
||||
func intentConfidence(in Intent) float64 {
|
||||
var max float64
|
||||
for _, b := range in.Beliefs {
|
||||
if b.Confidence > max {
|
||||
max = b.Confidence
|
||||
}
|
||||
}
|
||||
switch in.Kind {
|
||||
case "unknown", "decline":
|
||||
return 0.9 // confident that it does not know
|
||||
case "introspect", "acknowledge", "greet":
|
||||
return 0.95
|
||||
}
|
||||
if max == 0 {
|
||||
return 0.5
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
// buildIntent decides what to convey from state alone.
|
||||
func (h Handlers) buildIntent(ctx context.Context, c *brain.Cycle, a conversation.Analysis, text string, claimed []BeliefRef) Intent {
|
||||
mind := c.State.Mind
|
||||
in := Intent{Analysis: a, Limitations: c.State.SelfModel.Limitations}
|
||||
if len(in.Limitations) > 3 {
|
||||
in.Limitations = in.Limitations[:3]
|
||||
}
|
||||
topic := a.Topic
|
||||
if topic == "" {
|
||||
topic = text
|
||||
}
|
||||
switch a.Intent {
|
||||
case "greeting":
|
||||
in.Kind, in.Summary = "greet", "Greet "+firstNonEmpty(a.Topic, "the user")+" briefly and offer to help."
|
||||
case "statement", "correction":
|
||||
in.Kind = "acknowledge"
|
||||
if len(claimed) == 0 {
|
||||
in.Summary = "Acknowledge the message; no checkable claim was found in it."
|
||||
break
|
||||
}
|
||||
in.Beliefs = claimed
|
||||
in.Summary = fmt.Sprintf("Acknowledge that %d statement(s) were recorded as claims with the shown confidence, noting they rest on this message alone.", len(claimed))
|
||||
for _, b := range claimed {
|
||||
in.Points = append(in.Points, fmt.Sprintf("Recorded \"%s\" as a %s at confidence %.2f.", b.Proposition, b.Status, b.Confidence))
|
||||
if b.Status == string(beliefs.StatusContested) {
|
||||
in.Points = append(in.Points, "That conflicts with something I already held; both are now contested.")
|
||||
}
|
||||
}
|
||||
case "request":
|
||||
in.Kind = "decline"
|
||||
caps := c.State.SelfModel.Capabilities
|
||||
if len(caps) == 0 {
|
||||
in.Summary = "Explain honestly that no external capabilities are granted, so the request cannot be carried out; offer what is known instead."
|
||||
in.Points = append(in.Points, "I have no capabilities granted to act on external systems.")
|
||||
} else {
|
||||
in.Summary = "Explain which capabilities exist and that actions require policy approval."
|
||||
}
|
||||
h.addKnowledge(ctx, c, &in, topic)
|
||||
case "introspection":
|
||||
in.Kind = "introspect"
|
||||
h.introspect(ctx, c, &in, a, topic)
|
||||
default: // question, other
|
||||
in.Kind = "answer"
|
||||
h.addKnowledge(ctx, c, &in, topic)
|
||||
if len(in.Beliefs) == 0 && len(in.Memories) == 0 {
|
||||
in.Kind = "unknown"
|
||||
in.Summary = "Say plainly that nothing is known about this; do not guess."
|
||||
in.Uncertainty = "No beliefs or memories match the question."
|
||||
} else {
|
||||
in.Summary = "Answer from the cited beliefs and memories, stating confidence; say what is uncertain."
|
||||
}
|
||||
}
|
||||
if in.Points == nil {
|
||||
in.Points = []string{}
|
||||
}
|
||||
if in.Beliefs == nil {
|
||||
in.Beliefs = []BeliefRef{}
|
||||
}
|
||||
if in.Memories == nil {
|
||||
in.Memories = []MemRef{}
|
||||
}
|
||||
_ = mind
|
||||
return in
|
||||
}
|
||||
|
||||
// addKnowledge cites the beliefs and memories relevant to a topic.
|
||||
func (h Handlers) addKnowledge(ctx context.Context, c *brain.Cycle, in *Intent, topic string) {
|
||||
mind := c.State.Mind.ID
|
||||
if h.Beliefs != nil {
|
||||
if bs, _ := h.Beliefs.Search(ctx, mind, topic, 5); len(bs) > 0 {
|
||||
var maxConf float64
|
||||
for _, b := range bs {
|
||||
if b.Status == beliefs.StatusRejected {
|
||||
continue
|
||||
}
|
||||
in.Beliefs = append(in.Beliefs, BeliefRef{ID: b.ID, Proposition: b.Proposition, Confidence: b.Confidence, Status: string(b.Status)})
|
||||
in.Points = append(in.Points, fmt.Sprintf("%s (%s, %.2f)", b.Proposition, b.Status, b.Confidence))
|
||||
if b.Confidence > maxConf {
|
||||
maxConf = b.Confidence
|
||||
}
|
||||
}
|
||||
if maxConf > 0 && maxConf < 0.6 {
|
||||
in.Uncertainty = fmt.Sprintf("The strongest relevant belief is only at %.2f.", maxConf)
|
||||
}
|
||||
}
|
||||
}
|
||||
if h.Memory != nil {
|
||||
if hits, _ := h.Memory.Retrieve(ctx, mind, memory.Query{Text: topic, Limit: 4, Now: c.State.Now, Semantic: true}); len(hits) > 0 {
|
||||
for _, r := range hits {
|
||||
if r.Score < 0.3 {
|
||||
continue
|
||||
}
|
||||
in.Memories = append(in.Memories, MemRef{ID: r.Memory.ID, Kind: string(r.Memory.Kind), Content: truncate(r.Memory.Content, 200), Confidence: r.Memory.Confidence})
|
||||
in.Points = append(in.Points, fmt.Sprintf("I remember: %s (%s, %.2f)", truncate(r.Memory.Content, 160), r.Memory.Kind, r.Memory.Confidence))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// introspect answers questions about the mind's own state from that state.
|
||||
func (h Handlers) introspect(ctx context.Context, c *brain.Cycle, in *Intent, a conversation.Analysis, topic string) {
|
||||
mind := c.State.Mind.ID
|
||||
ws := c.Runtime.Workspace()
|
||||
switch a.Introspection {
|
||||
case "thinking":
|
||||
in.Summary = "Describe what is currently in the workspace and working memory, most salient first."
|
||||
for i, t := range ws.Workspace {
|
||||
if i >= 5 {
|
||||
break
|
||||
}
|
||||
in.Points = append(in.Points, fmt.Sprintf("%s (score %.2f)", t.Summary, t.Score))
|
||||
}
|
||||
for i, it := range ws.WorkingMemory {
|
||||
if i >= 4 {
|
||||
break
|
||||
}
|
||||
in.Points = append(in.Points, "holding: "+truncate(it.Content, 100))
|
||||
}
|
||||
if len(in.Points) == 0 {
|
||||
in.Points = append(in.Points, "The workspace is empty; nothing is being attended to right now.")
|
||||
}
|
||||
case "beliefs":
|
||||
in.Summary = "List what is believed about the topic with confidence and status; say if nothing is known."
|
||||
h.addKnowledge(ctx, c, in, topic)
|
||||
if len(in.Beliefs) == 0 && len(in.Memories) == 0 {
|
||||
in.Kind, in.Uncertainty = "unknown", "No beliefs or memories concern "+topic+"."
|
||||
}
|
||||
case "why":
|
||||
in.Summary = "Explain the evidence for and against the belief the user asked about, and what would change it."
|
||||
if h.Beliefs != nil {
|
||||
if bs, _ := h.Beliefs.Search(ctx, mind, topic, 1); len(bs) > 0 {
|
||||
b := bs[0]
|
||||
in.Beliefs = append(in.Beliefs, BeliefRef{ID: b.ID, Proposition: b.Proposition, Confidence: b.Confidence, Status: string(b.Status)})
|
||||
rows, _ := h.Beliefs.EvidenceFor(ctx, c.Tx, mind, b.ID)
|
||||
for _, e := range rows {
|
||||
if e.Retracted {
|
||||
continue
|
||||
}
|
||||
in.Points = append(in.Points, fmt.Sprintf("%s: %s (weight %.2f × credibility %.2f)", e.Direction, firstNonEmpty(e.Note, e.Kind), e.Weight, e.Credibility))
|
||||
}
|
||||
in.Points = append(in.Points, "Independent evidence from another source would move it most; a contradicting observation would lower it.")
|
||||
} else {
|
||||
in.Kind, in.Summary = "unknown", "Say that no belief matches what was asked."
|
||||
}
|
||||
}
|
||||
case "uncertain":
|
||||
in.Summary = "List the beliefs that are least settled, with confidence."
|
||||
if h.Beliefs != nil {
|
||||
bs, _ := h.Beliefs.Uncertain(ctx, mind, 6)
|
||||
for _, b := range bs {
|
||||
in.Beliefs = append(in.Beliefs, BeliefRef{ID: b.ID, Proposition: b.Proposition, Confidence: b.Confidence, Status: string(b.Status)})
|
||||
in.Points = append(in.Points, fmt.Sprintf("%s (%s, %.2f)", b.Proposition, b.Status, b.Confidence))
|
||||
}
|
||||
}
|
||||
if len(in.Points) == 0 {
|
||||
in.Points = append(in.Points, "No beliefs sit in the uncertain band right now.")
|
||||
}
|
||||
case "contradictions":
|
||||
in.Summary = "List contested beliefs and what they conflict with."
|
||||
if h.Beliefs != nil {
|
||||
bs, _ := h.Beliefs.Contested(ctx, mind, 6)
|
||||
for _, b := range bs {
|
||||
in.Beliefs = append(in.Beliefs, BeliefRef{ID: b.ID, Proposition: b.Proposition, Confidence: b.Confidence, Status: string(b.Status)})
|
||||
links, _ := h.Beliefs.Links(ctx, mind, b.ID)
|
||||
for _, l := range links {
|
||||
if l.Relation == "contradicts" && l.Other != nil {
|
||||
in.Points = append(in.Points, fmt.Sprintf("\"%s\" (%.2f) contradicts \"%s\" (%.2f)", b.Proposition, b.Confidence, l.Other.Proposition, l.Other.Confidence))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(in.Points) == 0 {
|
||||
in.Points = append(in.Points, "No contradictions are currently open.")
|
||||
}
|
||||
case "learned":
|
||||
in.Summary = "Summarise what was recorded in the last seven days: new memories and beliefs."
|
||||
evs, _ := c.Runtime.Events().Query(ctx, evstore.Filter{MindID: mind, Types: []events.Type{events.MemoryCreated, events.BeliefCreated, events.HypothesisCreated, events.MemoryConsolidated}, Since: c.State.Now.Add(-7 * 24 * time.Hour), Limit: 8})
|
||||
for _, e := range evs {
|
||||
in.Points = append(in.Points, e.Summary)
|
||||
}
|
||||
if len(in.Points) == 0 {
|
||||
in.Points = append(in.Points, "Nothing new was recorded this week.")
|
||||
}
|
||||
case "changed":
|
||||
in.Summary = "List beliefs whose status changed recently and why."
|
||||
if h.Beliefs != nil {
|
||||
ch, _ := h.Beliefs.RecentChanges(ctx, mind, c.State.Now.Add(-30*24*time.Hour), 6)
|
||||
for _, x := range ch {
|
||||
in.Beliefs = append(in.Beliefs, BeliefRef{ID: x.Belief.ID, Proposition: x.Belief.Proposition, Confidence: x.Belief.Confidence, Status: string(x.Belief.Status)})
|
||||
in.Points = append(in.Points, fmt.Sprintf("\"%s\": %s (%.2f) → %s (%.2f) because %s", x.Belief.Proposition, x.FromStatus, x.FromConf, x.Belief.Status, x.Belief.Confidence, firstNonEmpty(x.Reason, "evidence changed")))
|
||||
}
|
||||
}
|
||||
if len(in.Points) == 0 {
|
||||
in.Points = append(in.Points, "No belief has changed status in the last month.")
|
||||
}
|
||||
default: // predictions, curious, surprised, patterns: subsystems not yet present
|
||||
in.Kind = "decline"
|
||||
in.Summary = "Explain that this aspect of self-observation is not available in this version, without inventing anything."
|
||||
in.Points = append(in.Points, "I do not yet keep "+a.Introspection+" records, so I cannot report on them honestly.")
|
||||
}
|
||||
}
|
||||
|
||||
// generate phrases the intent. With a model: the respond prompt, constrained
|
||||
// to cited material. Without: a deterministic rendering of the points.
|
||||
func (h Handlers) generate(ctx context.Context, c *brain.Cycle, text string, in Intent) (string, ids.ID, error) {
|
||||
s := c.State
|
||||
if s.Services.LLM != nil && s.Services.Prompts != nil && s.Services.LLM.Available(ctx, llm.RoleRespond) && s.AllowLLM() {
|
||||
if pr, err := s.Services.Prompts.Get(ctx, "respond"); err == nil {
|
||||
data := map[string]any{"Brief": s.Mind.Personality.Brief(), "Intent": in.Summary + "\n" + strings.Join(in.Points, "\n"), "Beliefs": in.Beliefs, "Memories": in.Memories, "Uncertainty": in.Uncertainty, "Limitations": in.Limitations, "Message": text}
|
||||
if req, err := pr.Render(data); err == nil {
|
||||
req.MindID, req.CorrelationID, req.Purpose = s.Mind.ID, s.CycleID, "respond"
|
||||
c.Emit(events.New{Type: events.LLMRequest, Source: "brain.respond", Summary: "asked model to phrase reply (" + in.Kind + ")", Salience: 0.1, Confidence: 1, Metadata: map[string]any{"role": "respond", "prompt": pr.Name, "version": pr.CurrentVersion}})
|
||||
resp, err := s.Services.LLM.Complete(ctx, req)
|
||||
if err == nil && strings.TrimSpace(resp.Text) != "" {
|
||||
c.Emit(events.New{Type: events.LLMResponse, Source: "brain.respond", Summary: "model phrased reply via " + resp.Provider + "/" + resp.Model, Salience: 0.1, Confidence: 1,
|
||||
Evidence: []events.EvidenceRef{{Kind: "llm_call", ID: string(resp.CallID)}}, Metadata: map[string]any{"role": "respond", "call_id": string(resp.CallID), "tokens": resp.Usage.TotalTokens}})
|
||||
return strings.TrimSpace(resp.Text), resp.CallID, nil
|
||||
}
|
||||
c.Emit(events.New{Type: events.Metacognition, Source: "brain.respond", Summary: "model reply failed; using deterministic wording", Salience: 0.3, Confidence: 1, Metadata: map[string]any{"check": "llm_failure", "error": errString(err)}})
|
||||
}
|
||||
}
|
||||
}
|
||||
return deterministicReply(in), ids.Nil, nil
|
||||
}
|
||||
|
||||
func errString(err error) string {
|
||||
if err == nil {
|
||||
return "empty response"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// deterministicReply renders the intent without a model: honest, plain, with
|
||||
// confidences visible.
|
||||
func deterministicReply(in Intent) string {
|
||||
var b strings.Builder
|
||||
switch in.Kind {
|
||||
case "greet":
|
||||
b.WriteString("Hello. I can tell you what I currently hold and how sure I am of it.")
|
||||
case "unknown":
|
||||
b.WriteString("I don't know. Nothing I hold bears on that.")
|
||||
case "decline":
|
||||
b.WriteString("I can't do that.")
|
||||
case "acknowledge":
|
||||
b.WriteString("Noted.")
|
||||
case "introspect":
|
||||
b.WriteString("From my current state:")
|
||||
default:
|
||||
b.WriteString("Here is what I hold on that:")
|
||||
}
|
||||
for _, p := range in.Points {
|
||||
b.WriteString("\n- " + p)
|
||||
}
|
||||
if in.Uncertainty != "" {
|
||||
b.WriteString("\n" + in.Uncertainty)
|
||||
}
|
||||
b.WriteString("\n(No language model is configured, so this is a direct rendering of my state.)")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/andydixon/noema/internal/auth"
|
||||
"github.com/andydixon/noema/internal/llm"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
func (u *UI) conversationRoutes(m *http.ServeMux) {
|
||||
m.Handle("GET /minds/{mind}/conversations", u.require(auth.RoleObserver, u.conversations))
|
||||
m.Handle("POST /minds/{mind}/conversations", u.require(auth.RoleOperator, u.conversationCreate))
|
||||
m.Handle("GET /minds/{mind}/conversations/{id}", u.require(auth.RoleObserver, u.conversationPage))
|
||||
m.Handle("POST /minds/{mind}/conversations/{id}/messages", u.require(auth.RoleOperator, u.conversationPost))
|
||||
}
|
||||
|
||||
var suggestions = []string{"What are you thinking about?", "What do you believe about atlas?", "Why do you believe that?", "What are you uncertain about?", "Which of your beliefs contradict each other?", "What did you learn this week?", "What have you changed your mind about?"}
|
||||
|
||||
func (u *UI) conversations(w http.ResponseWriter, r *http.Request) {
|
||||
m, ok := u.mind(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
list, _ := u.deps.Conversations.List(r.Context(), m.ID, 100)
|
||||
u.render(w, r, "conversations", m.DisplayName+" · Conversations", map[string]any{"Mind": m, "Conversations": list})
|
||||
}
|
||||
|
||||
func (u *UI) conversationCreate(w http.ResponseWriter, r *http.Request) {
|
||||
m, ok := u.mind(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r.ParseForm() //nolint:errcheck
|
||||
c, err := u.deps.Conversations.Create(r.Context(), m.ID, r.PostFormValue("title"), "web", r.PostFormValue("person"), r.PostFormValue("project"))
|
||||
if err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/conversations")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/minds/"+m.Slug+"/conversations/"+c.ID.String(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UI) conversationPage(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 conversation.")
|
||||
return
|
||||
}
|
||||
c, err := u.deps.Conversations.Get(r.Context(), m.ID, id)
|
||||
if err != nil {
|
||||
u.errorPage(w, r, 404, "No such conversation.")
|
||||
return
|
||||
}
|
||||
msgs, _ := u.deps.Conversations.Messages(r.Context(), m.ID, id, 200)
|
||||
u.render(w, r, "conversation", c.Title, map[string]any{"Mind": m, "Conversation": c, "Messages": msgs, "Suggestions": suggestions, "LLM": u.deps.LLM.Available(r.Context(), llm.RoleRespond)})
|
||||
}
|
||||
|
||||
func (u *UI) conversationPost(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 conversation.")
|
||||
return
|
||||
}
|
||||
c, err := u.deps.Conversations.Get(r.Context(), m.ID, id)
|
||||
if err != nil {
|
||||
u.errorPage(w, r, 404, "No such conversation.")
|
||||
return
|
||||
}
|
||||
if _, err := u.postF(r, m.ID, c, r.PostFormValue("text"), username(r)); err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/conversations/"+id.String())
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/minds/"+m.Slug+"/conversations/"+id.String(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func prettyRaw(raw json.RawMessage) string { return pretty(raw) }
|
||||
@@ -88,6 +88,7 @@ func (u *UI) mindsCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "mind.create", "mind", m.ID.String(), map[string]any{"slug": m.Slug})
|
||||
u.deps.Brain.Reconcile(r.Context()) //nolint:errcheck
|
||||
u.flash(w, r, "ok", "Mind created. Shape its personality next.", "/minds/"+m.Slug+"/personality")
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/andydixon/noema/internal/attention"
|
||||
"github.com/andydixon/noema/internal/auth"
|
||||
"github.com/andydixon/noema/internal/beliefs"
|
||||
"github.com/andydixon/noema/internal/conversation"
|
||||
"github.com/andydixon/noema/internal/llm"
|
||||
"github.com/andydixon/noema/internal/minds"
|
||||
"github.com/andydixon/noema/internal/version"
|
||||
@@ -40,6 +41,7 @@ type UI struct {
|
||||
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)
|
||||
}
|
||||
|
||||
// View is the data passed to every template.
|
||||
@@ -60,7 +62,7 @@ const flashCookie = "noema_flash"
|
||||
|
||||
// New parses templates. In development, when ./web exists on disk, templates
|
||||
// and static files are read from disk so edits show without a rebuild.
|
||||
func New(deps app.Deps, statusF func(r *http.Request) any, ctxF func(r *http.Request, id ids.ID) (api.EventContext, error), 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) (*UI, error) {
|
||||
func New(deps app.Deps, statusF func(r *http.Request) any, ctxF func(r *http.Request, id ids.ID) (api.EventContext, error), 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)) (*UI, error) {
|
||||
tmplFS, staticFS := web.Templates(), web.Static()
|
||||
if !deps.Config.Production() {
|
||||
if st, err := os.Stat("web/templates"); err == nil && st.IsDir() {
|
||||
@@ -68,7 +70,7 @@ func New(deps app.Deps, statusF func(r *http.Request) any, ctxF func(r *http.Req
|
||||
deps.Log.Info("ui: serving templates and static files from ./web (development)")
|
||||
}
|
||||
}
|
||||
u := &UI{deps: deps, static: staticFS, statusF: statusF, ctxF: ctxF, wsF: wsF, memF: memF, belF: belF, emitF: emitF}
|
||||
u := &UI{deps: deps, static: staticFS, statusF: statusF, ctxF: ctxF, wsF: wsF, memF: memF, belF: belF, emitF: emitF, postF: postF}
|
||||
if err := u.parse(tmplFS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -120,6 +122,7 @@ func (u *UI) parse(fsys fs.FS) error {
|
||||
"epistemicKinds": func() []concepts.Epistemic { return concepts.AllEpistemic },
|
||||
"relations": func() []concepts.Relation { return concepts.AllRelations },
|
||||
"llmRoles": func() []llm.RoleInfo { return llm.Roles },
|
||||
"pretty": prettyRaw,
|
||||
"deref_id": func(id *ids.ID) ids.ID {
|
||||
if id == nil {
|
||||
return ids.Nil
|
||||
@@ -297,6 +300,7 @@ func (u *UI) Register(m *http.ServeMux) {
|
||||
u.memoryRoutes(m)
|
||||
u.beliefRoutes(m)
|
||||
u.llmRoutes(m)
|
||||
u.conversationRoutes(m)
|
||||
|
||||
for _, p := range []struct{ path, title, note string }{
|
||||
{"/approvals", "Approvals", "The approval centre arrives with the capability system."},
|
||||
|
||||
Reference in New Issue
Block a user