Files
Andy Dixon 212eef6b8c fix(api): belief events were published before their transaction committed
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.
2026-09-27 22:19:21 +01:00

158 lines
5.9 KiB
Go

package ui
import (
"net/http"
"net/url"
"strconv"
"time"
"github.com/andydixon/noema/internal/api"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/memory"
"github.com/andydixon/noema/pkg/concepts"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
func (u *UI) memoryRoutes(m *http.ServeMux) {
m.Handle("GET /minds/{mind}/memory", u.require(auth.RoleObserver, u.memoryExplorer))
m.Handle("POST /minds/{mind}/memory", u.require(auth.RoleOperator, u.memoryAdd))
m.Handle("GET /minds/{mind}/memory/{id}", u.require(auth.RoleObserver, u.memoryDetail))
m.Handle("POST /minds/{mind}/memory/{id}/retract", u.require(auth.RoleOperator, u.memoryRetract))
m.Handle("POST /minds/{mind}/memory/{id}/relate", u.require(auth.RoleOperator, u.memoryRelate))
}
func (u *UI) memoryExplorer(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
q := r.URL.Query()
f := api.ParseMemoryFilter(q)
f.Limit = 100
flat := map[string]string{}
for k := range q {
flat[k] = q.Get(k)
}
stats, _ := u.deps.Memory.Stats(r.Context(), m.ID)
data := map[string]any{"Mind": m, "Q": flat, "Stats": stats, "Semantic": u.deps.Memory.SemanticEnabled()}
if f.Search != "" {
hits, err := u.deps.Memory.Retrieve(r.Context(), m.ID, memory.Query{Text: f.Search, Kinds: f.Kinds, Limit: 30, Semantic: true, Strict: q.Get("strict") == "1"})
if err != nil {
u.errorPage(w, r, 500, "Search failed.")
return
}
data["Hits"] = hits
} else {
list, err := u.deps.Memory.List(r.Context(), m.ID, f)
if err != nil {
u.errorPage(w, r, 500, "Could not list memories.")
return
}
data["Memories"] = list
if len(list) == 100 {
data["NextBefore"] = list[len(list)-1].OccurredAt.Format(time.RFC3339Nano)
}
rq := url.Values{}
for _, k := range []string{"kind", "epistemic", "status", "min_confidence", "min_importance"} {
if v := q.Get(k); v != "" {
rq.Set(k, v)
}
}
data["RawQuery"] = rq.Encode()
}
u.render(w, r, "memory", m.DisplayName+" · Memory", data)
}
func (u *UI) memoryAdd(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
r.ParseForm() //nolint:errcheck
conf, _ := strconv.ParseFloat(r.PostFormValue("confidence"), 64)
imp, _ := strconv.ParseFloat(r.PostFormValue("importance"), 64)
mem, err := u.deps.Memory.Create(r.Context(), u.deps.DB, memory.New{MindID: m.ID, Kind: concepts.MemoryKind(r.PostFormValue("kind")), Epistemic: concepts.Epistemic(r.PostFormValue("epistemic")),
Content: r.PostFormValue("content"), Source: "operator", Trust: events.TrustOperator, Confidence: conf, Importance: imp, Salience: 0.5})
if err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/memory")
return
}
u.deps.Events.Append(r.Context(), events.New{MindID: m.ID, Type: events.MemoryCreated, Source: "operator", Summary: "operator added " + string(mem.Kind) + " memory: " + truncateStr(mem.Content, 120), //nolint:errcheck
Salience: 0.5, Confidence: mem.Confidence, Evidence: []events.EvidenceRef{{Kind: "memory", ID: string(mem.ID)}},
Metadata: map[string]any{"memory_id": mem.ID, "kind": mem.Kind, "actor": username(r)}, Payload: map[string]any{"memory_id": mem.ID, "kind": mem.Kind, "epistemic": mem.Epistemic, "content": mem.Content}})
u.deps.Audit.Log(r.Context(), "memory.create", "memory", mem.ID.String(), map[string]any{"mind": m.Slug, "kind": mem.Kind})
u.flash(w, r, "ok", "Memory stored.", "/minds/"+m.Slug+"/memory/"+mem.ID.String())
}
func (u *UI) memoryDetail(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 memory.")
return
}
d, err := u.memF(r, m.ID, id)
if err != nil {
u.errorPage(w, r, 404, "No such memory.")
return
}
u.render(w, r, "memory_detail", "Memory", map[string]any{"Mind": m, "Detail": d})
}
func (u *UI) memoryRetract(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 memory.")
return
}
reason := r.PostFormValue("reason")
if err := u.deps.Memory.SetStatus(r.Context(), m.ID, id, memory.StatusRetracted); err != nil {
u.flash(w, r, "error", "Could not retract.", "/minds/"+m.Slug+"/memory/"+id.String())
return
}
u.deps.Events.OperatorAction(r.Context(), m.ID, username(r), "memory.retract", "Operator "+username(r)+" marked a memory inaccurate: "+reason, map[string]any{"memory_id": id, "reason": reason})
u.deps.Audit.Log(r.Context(), "memory.retract", "memory", id.String(), map[string]any{"reason": reason})
if affected, err := u.deps.Beliefs.RetractEvidence(r.Context(), u.deps.DB, m.ID, "memory", string(id)); err == nil {
for _, bid := range affected {
if res, err := u.deps.Beliefs.Reevaluate(r.Context(), u.deps.DB, u.emitF(), m.ID, bid, "supporting memory retracted by operator"); err == nil {
u.deps.Events.Publish(res.Events...)
}
}
}
u.flash(w, r, "ok", "Memory marked inaccurate. It remains in history.", "/minds/"+m.Slug+"/memory/"+id.String())
}
func (u *UI) memoryRelate(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
id, err := ids.Parse(r.PathValue("id"))
to, err2 := ids.Parse(r.PostFormValue("to"))
if err != nil || err2 != nil {
u.flash(w, r, "error", "Bad memory id.", "/minds/"+m.Slug+"/memory")
return
}
if _, err := u.deps.Memory.Relate(r.Context(), u.deps.DB, m.ID, id, to, concepts.Relation(r.PostFormValue("relation")), 0.8, "operator"); err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/memory/"+id.String())
return
}
u.deps.Audit.Log(r.Context(), "memory.relate", "memory", id.String(), map[string]any{"to": to, "relation": r.PostFormValue("relation")})
u.flash(w, r, "ok", "Linked.", "/minds/"+m.Slug+"/memory/"+id.String())
}
func truncateStr(s string, n int) string {
if len(s) > n {
return s[:n-1] + "…"
}
return s
}