The API's belief emitter published every event as soon as it was appended. Belief retraction runs inside its own transaction, so subscribers on the event bus saw belief_retracted while the belief row still held its old status, breaking the rule that subscribers never see uncommitted events; a plan reacting to a retracted assumption read the belief before the change and did nothing. The emitter now only appends, and every API and UI caller publishes the operation's events after it succeeds.
106 lines
4.6 KiB
Go
106 lines
4.6 KiB
Go
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
|
|
}
|
|
res, 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)), Derivation: beliefs.DerivationOperator, Family: "seed:" + 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
|
|
}
|
|
s.deps.Events.Publish(res.Events...)
|
|
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})
|
|
}
|