Files
noema/internal/ui/beliefs.go
T
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

165 lines
6.7 KiB
Go

package ui
import (
"net/http"
"strconv"
"github.com/andydixon/noema/internal/api"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/pkg/concepts"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
func (u *UI) beliefRoutes(m *http.ServeMux) {
m.Handle("GET /minds/{mind}/beliefs", u.require(auth.RoleObserver, u.beliefsPage))
m.Handle("POST /minds/{mind}/beliefs", u.require(auth.RoleOperator, u.beliefAssert))
m.Handle("GET /minds/{mind}/beliefs/{id}", u.require(auth.RoleObserver, u.beliefDetail))
m.Handle("POST /minds/{mind}/beliefs/{id}/evidence", u.require(auth.RoleOperator, u.beliefEvidence))
m.Handle("POST /minds/{mind}/beliefs/{id}/alternatives", u.require(auth.RoleOperator, u.beliefAlternative))
m.Handle("POST /minds/{mind}/beliefs/{id}/retract", u.require(auth.RoleOperator, u.beliefRetract))
m.Handle("POST /minds/{mind}/corrections", u.require(auth.RoleOperator, u.correctionCreate))
}
func (u *UI) beliefsPage(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
q := r.URL.Query()
f := api.ParseBeliefFilter(q)
f.Limit = 200
list, err := u.deps.Beliefs.List(r.Context(), m.ID, f)
if err != nil {
u.errorPage(w, r, 500, "Could not list beliefs.")
return
}
flat := map[string]string{}
for k := range q {
flat[k] = q.Get(k)
}
stats, _ := u.deps.Beliefs.Stats(r.Context(), m.ID)
corr, _ := u.deps.Beliefs.Corrections(r.Context(), m.ID, "")
u.render(w, r, "beliefs", m.DisplayName+" · Beliefs", map[string]any{"Mind": m, "Beliefs": list, "Q": flat, "Stats": stats, "Corrections": corr})
}
func (u *UI) beliefAssert(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
r.ParseForm() //nolint:errcheck
weight, _ := strconv.ParseFloat(r.PostFormValue("weight"), 64)
res, err := u.deps.Beliefs.Create(r.Context(), u.deps.DB, u.emitF(), beliefs.New{MindID: m.ID, Proposition: r.PostFormValue("proposition"), Subject: r.PostFormValue("subject"),
Epistemic: concepts.Epistemic(r.PostFormValue("epistemic")), Source: "operator",
Evidence: []beliefs.EvidenceInput{{Direction: "for", Kind: "operator", Source: "operator:" + username(r), Weight: weight, Credibility: events.TrustOperator.Credibility(), Note: "asserted by operator " + username(r)}}})
if err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/beliefs")
return
}
u.deps.Events.Publish(res.Events...)
u.deps.Audit.Log(r.Context(), "belief.create", "belief", res.Belief.ID.String(), map[string]any{"mind": m.Slug, "proposition": res.Belief.Proposition})
u.flash(w, r, "ok", "Belief recorded with confidence "+strconv.FormatFloat(res.Belief.Confidence, 'f', 2, 64)+".", "/minds/"+m.Slug+"/beliefs/"+res.Belief.ID.String())
}
func (u *UI) beliefDetail(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 belief.")
return
}
d, err := u.belF(r, m.ID, id)
if err != nil {
u.errorPage(w, r, 404, "No such belief.")
return
}
u.render(w, r, "belief_detail", "Belief", map[string]any{"Mind": m, "Detail": d})
}
func (u *UI) beliefEvidence(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 belief.")
return
}
r.ParseForm() //nolint:errcheck
weight, _ := strconv.ParseFloat(r.PostFormValue("weight"), 64)
note := r.PostFormValue("note")
res, err := u.deps.Beliefs.AddEvidence(r.Context(), u.deps.DB, u.emitF(), m.ID, id, beliefs.EvidenceInput{Direction: r.PostFormValue("direction"), Kind: "operator", Source: "operator:" + username(r), Weight: weight, Credibility: events.TrustOperator.Credibility(), Note: note}, "operator evidence: "+note, ids.Nil, ids.Nil)
if err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/beliefs/"+id.String())
return
}
u.deps.Events.Publish(res.Events...)
u.deps.Audit.Log(r.Context(), "belief.evidence", "belief", id.String(), map[string]any{"direction": r.PostFormValue("direction"), "note": note})
u.flash(w, r, "ok", "Evidence added and confidence re-evaluated.", "/minds/"+m.Slug+"/beliefs/"+id.String())
}
func (u *UI) beliefAlternative(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 belief.")
return
}
if _, err := u.deps.Beliefs.AddAlternative(r.Context(), m.ID, id, r.PostFormValue("proposition"), "operator"); err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/beliefs/"+id.String())
return
}
u.flash(w, r, "ok", "Alternative recorded.", "/minds/"+m.Slug+"/beliefs/"+id.String())
}
func (u *UI) beliefRetract(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 belief.")
return
}
reason := r.PostFormValue("reason")
dni := r.PostFormValue("do_not_infer") == "1"
res, err := u.deps.Beliefs.Retract(r.Context(), u.emitF(), m.ID, id, reason, username(r), dni)
if err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/beliefs/"+id.String())
return
}
u.deps.Events.Publish(res.Events...)
u.deps.Audit.Log(r.Context(), "belief.retract", "belief", id.String(), map[string]any{"reason": reason, "do_not_infer": dni})
u.flash(w, r, "ok", "Belief marked wrong. Its history is preserved.", "/minds/"+m.Slug+"/beliefs/"+id.String())
}
func (u *UI) correctionCreate(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
stmt, reason := r.PostFormValue("statement"), r.PostFormValue("reason")
evs, err := u.deps.Events.Append(r.Context(), events.New{MindID: m.ID, Type: events.OperatorAction, Source: "operator", Summary: "Operator " + username(r) + " forbade inference: " + stmt,
Salience: 0.6, Confidence: 1, Metadata: map[string]any{"action": "correction.do_not_infer", "actor": username(r)}, Payload: map[string]any{"statement": stmt, "reason": reason}})
if err != nil {
u.flash(w, r, "error", "Could not record.", "/minds/"+m.Slug+"/beliefs")
return
}
if _, err := u.deps.Beliefs.AddCorrection(r.Context(), u.deps.DB, m.ID, beliefs.Correction{Kind: "do_not_infer", Statement: stmt, Reason: reason, CreatedBy: username(r), EventID: evs[0].ID}); err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/beliefs")
return
}
u.deps.Audit.Log(r.Context(), "correction.create", "mind", m.ID.String(), map[string]any{"kind": "do_not_infer", "statement": stmt})
u.flash(w, r, "ok", "The mind will not infer that again.", "/minds/"+m.Slug+"/beliefs")
}