/trace grows a turn-decisions table under the rule trace (V-564)

Both tables answer the same question, who won and who lost, one about nudges
and the other about utterances, so they share a page rather than splitting the
nav. A turn is one collapsible row; never_asked is coloured like a block,
because it usually is one. A read failure is logged and the rule trace above it
still renders: a daemon too old to know the method is the ordinary case during
a rolling deploy.
This commit is contained in:
2026-08-06 00:52:58 +04:00
parent a5b245dbf5
commit eec3d9bed2
3 changed files with 80 additions and 3 deletions
+40
View File
@@ -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 ---
+18 -1
View File
@@ -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)
+22 -2
View File
@@ -1,9 +1,9 @@
{{template "shellTop" "trace"}}
<h1>Rule Trace</h1>
<div class="hint mb-4">{{.Now | ago}} — winner: <strong>{{if .Winner}}{{.Winner}}{{else}}nothing fired{{end}}</strong></div>
<div class="hint mb-4">{{.Tick.Now | ago}} — winner: <strong>{{if .Tick.Winner}}{{.Tick.Winner}}{{else}}nothing fired{{end}}</strong></div>
<div class=scroll><table class=mono>
<tr><th>rule<th>sev<th>predicate<th>gate<th>blocked by<th>detail<th>selected<th>lost to</tr>
{{range .Rules}}<tr>
{{range .Tick.Rules}}<tr>
<td>{{.RuleName}}</td>
<td>{{.Severity}}</td>
<td class={{if .PredicateResult}}green{{else}}gray{{end}}>{{.PredicateResult}}</td>
@@ -21,5 +21,25 @@
<td>{{.LostTo}}</td>
</tr>{{end}}
</table></div>
<h1 class=mt-4>Turn Decisions</h1>
<div class="hint mb-4">Who claimed each utterance, who lost it, and who was never asked. In memory, newest first, cleared on restart.</div>
{{if not .Turns}}<div class=hint>no turn has run since the daemon started</div>{{end}}
{{range .Turns}}
<details class=mb-4>
<summary><span class=mono>{{.Utterance}}</span><strong>{{if .Winner}}{{.Winner}}{{else}}nobody{{end}}</strong> <span class=hint>{{.Ts | ago}}</span></summary>
<div class=scroll><table class=mono>
<tr><th>stage<th>claimant<th>would have been<th>score<th>outcome<th>why</tr>
{{range .Claims}}<tr>
<td>{{.Stage}}</td>
<td>{{.Claimant}}</td>
<td>{{if .Intent}}{{.Intent}}{{else}}—{{end}}</td>
<td>{{if .HasScore}}{{printf "%.3f" .Score}}{{else}}—{{end}}</td>
<td class={{if eq .Outcome "won"}}green{{else if eq .Outcome "never_asked"}}red{{else}}gray{{end}}>{{.Outcome}}</td>
<td>{{.Reason}}</td>
</tr>{{end}}
</table></div>
</details>
{{end}}
{{template "shellBottom"}}
</html>