diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go index 53e6b96..437ef23 100644 --- a/cmd/mavweb/handlers_test.go +++ b/cmd/mavweb/handlers_test.go @@ -65,6 +65,7 @@ type fakeCore struct { // for handleTrace tests tickTrace ipc.TickTrace traceErr error + turns []ipc.TurnDecision // for handleChatAPI tests chatText string @@ -173,6 +174,10 @@ func (f *fakeCore) RevertFact(_ context.Context, key string) (int64, error) { return f.revertNewID, nil } +func (f *fakeCore) TurnDecisions(_ context.Context, _ int) ([]ipc.TurnDecision, error) { + return f.turns, nil +} + func (f *fakeCore) TickTrace(_ context.Context) (ipc.TickTrace, error) { if f.traceErr != nil { return ipc.TickTrace{}, f.traceErr @@ -825,6 +830,41 @@ func TestHandleTrace(t *testing.T) { t.Error("rendered 'nothing fired' but a winner was set") } }) + + // The turn arbitration shares this page (V-564). A reader must see the + // winner, a loser and the claimants that were never asked, because the last + // of those is what the hardcoded ordering hides. + t.Run("renders the turn decision record", func(t *testing.T) { + core := &fakeCore{turns: []ipc.TurnDecision{{ + Ts: time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC), + Utterance: "какая погода в риме", + Winner: "query:weather", + Claims: []ipc.TurnClaim{ + {Stage: "query", Claimant: "weather", Intent: "query", Outcome: "won"}, + {Stage: "query", Claimant: "calendar", Outcome: "declined", Reason: "no answer"}, + {Stage: "query", Claimant: "kiwix", Outcome: "never_asked"}, + }, + }}} + rr := httptest.NewRecorder() + handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), core) + body := rr.Body.String() + for _, want := range []string{"какая погода в риме", "query:weather", "calendar", "kiwix", "never_asked"} { + if !strings.Contains(body, want) { + t.Errorf("rendered page is missing %q", want) + } + } + }) + + t.Run("no turns renders the empty note, not an error", func(t *testing.T) { + rr := httptest.NewRecorder() + handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), &fakeCore{}) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), "no turn has run") { + t.Error("empty ring did not render its note") + } + }) } // --- handleRevert --- diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 88b4414..0d89d76 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -1415,12 +1415,29 @@ func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { http.Error(w, "core read failed", http.StatusBadGateway) return } + // The turn records share this page rather than getting one of their own + // (V-564): both answer the same question — who won, who lost and why — and + // one is about nudges while the other is about utterances. A read failure + // here is not fatal to the page: the rule trace above it still renders, and + // a daemon too old to know the method is the ordinary case during a rolling + // deploy. + turns, err := core.TurnDecisions(ctx, 25) + if err != nil { + log.Printf("trace: turn decisions: %v", err) + } w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := traceTmpl.Execute(w, trace); err != nil { + if err := traceTmpl.Execute(w, traceData{Tick: trace, Turns: turns}); err != nil { log.Printf("trace render: %v", err) } } +// traceData — what trace.html renders: the last tick's rule arbitration and the +// last turns' claim arbitration. +type traceData struct { + Tick ipc.TickTrace + Turns []ipc.TurnDecision +} + func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if core == nil { http.Error(w, "morning disabled (no -core)", http.StatusServiceUnavailable) diff --git a/cmd/mavweb/trace.html b/cmd/mavweb/trace.html index 3d95037..4c997b2 100644 --- a/cmd/mavweb/trace.html +++ b/cmd/mavweb/trace.html @@ -1,9 +1,9 @@ {{template "shellTop" "trace"}}

Rule Trace

-
{{.Now | ago}} — winner: {{if .Winner}}{{.Winner}}{{else}}nothing fired{{end}}
+
{{.Tick.Now | ago}} — winner: {{if .Tick.Winner}}{{.Tick.Winner}}{{else}}nothing fired{{end}}
-{{range .Rules}} +{{range .Tick.Rules}} @@ -21,5 +21,25 @@ {{end}}
rulesevpredicategateblocked bydetailselectedlost to
{{.RuleName}} {{.Severity}} {{.PredicateResult}}{{.LostTo}}
+ +

Turn Decisions

+
Who claimed each utterance, who lost it, and who was never asked. In memory, newest first, cleared on restart.
+{{if not .Turns}}
no turn has run since the daemon started
{{end}} +{{range .Turns}} +
+{{.Utterance}}{{if .Winner}}{{.Winner}}{{else}}nobody{{end}} {{.Ts | ago}} +
+ +{{range .Claims}} + + + + + + +{{end}} +
stageclaimantwould have beenscoreoutcomewhy
{{.Stage}}{{.Claimant}}{{if .Intent}}{{.Intent}}{{else}}—{{end}}{{if .HasScore}}{{printf "%.3f" .Score}}{{else}}—{{end}}{{.Outcome}}{{.Reason}}
+
+{{end}} {{template "shellBottom"}}