Merge the decision trace (#209)

V-564. One decision.Record per turn: the utterance, the winner, and a Claim
per claimant carrying its stage, name, the intent it would have made the turn,
the score it reported, the outcome and the reason. HasScore is separate from
the score so a real 0.0 is not read as no score. Outcomes are won, declined,
lost_on_order, lost_on_score, thinned, merged, never_asked.

Every stage declares its roster up front, so Finish names everyone who never
reported. NEVER ASKED is explicit rather than an absence, which is the fact
the hardcoded ordering hides.

Covered: the seven pre-route resolvers, eleven stage 0 grammar sets, the LLM
router and the classifier with which arm of gateLLMDecision thinned a route,
the classifier runners-up, the follow-up merge, 27 query sources, and a
terminal action-handler or clarify-ask claim.

On by default, no flag. It rides the context like querysource.go and is
installed in runTurn, so mic, telegram and web leave the same trail. Storage
is a 25-turn in-memory ring: no write on the answer path, no migration, and
none of his words outlive the diagnosis. Readable on /trace.

TestRecordingDoesNotChangeTheReply answers the same utterances with and
without the ring.
This commit is contained in:
2026-08-06 00:54:18 +04:00
24 changed files with 1000 additions and 18 deletions
+35 -8
View File
@@ -53,6 +53,7 @@ import (
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/crawl"
"github.com/kami/maven/internal/decision"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/lexicon"
@@ -137,6 +138,13 @@ type reactiveHandler struct {
// slot per reach, not one for the box. nil ⇒ no carry-over.
dialogueSessions *dialogue.SessionStore
// decisions holds the last few turns' arbitration records (V-564): who
// claimed the turn, who lost it and who was never asked. In memory and
// bounded, because a turn record is read minutes later or never, and none
// of his words belong in a table that outlives the diagnosis. nil ⇒ nothing
// is recorded, which is what a test that did not ask for one gets.
decisions *decision.Ring
// clarifyStore parks the request behind an open question she asked (see
// clarify.go). nil ⇒ she falls back to the canned "не поняла" reply.
clarifyStore *dialogue.ClarifyStore
@@ -248,6 +256,18 @@ const (
//
// The ordering is load-bearing — see the step comments.
func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSource) string {
// 0. the decision record (V-564). Installed here rather than in the IPC
// entry point, so the mic, telegram and the web all leave the same trail —
// a record only the web produced would be missing exactly the turns that
// are hardest to reproduce. It rides the context, costs a few dozen structs
// on a human-rate path, and no claim site can change a route with it.
if h.decisions != nil {
var rec *decision.Record
ctx, rec = decision.With(ctx, text)
decision.Expect(ctx, decision.StagePreRoute, preRouteLadder)
defer func() { h.decisions.Push(rec.Finish(h.now())) }()
}
// 1. expired clarify — a question was parked but its TTL ran out, so the
// request behind it is gone. Say that out loud (see clarify.go) and carry
// on: these words are still routed as a fresh utterance below, with the
@@ -263,7 +283,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// 2. confirm turn — if a destructive act is parked, this utterance is its
// y/n answer, not a fresh command. Handled before routing so "да" doesn't
// get classified as some other intent.
if reply, handled := h.resolveConfirm(ctx, text); handled {
if reply, handled := h.resolveConfirm(ctx, text); notePreRoute(ctx, "confirm", handled) {
return withNotice(expiredNotice, reply)
}
@@ -275,7 +295,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// so the notice is empty here in practice. withNotice anyway: every exit
// from runTurn carries it, and that is what stops the next one from
// forgetting.
if reply, handled := h.resolveClarifyAnswer(ctx, text); handled {
if reply, handled := h.resolveClarifyAnswer(ctx, text); notePreRoute(ctx, "clarify-answer", handled) {
return withNotice(expiredNotice, reply)
}
@@ -283,7 +303,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// "тихий режим" / "quiet on" would route through the classifier
// unreliably (it's a command, not a free-form query), so we match it
// before routing. Same pattern as the confirm turn above.
if reply, handled := h.resolveQuietToggle(ctx, text, src); handled {
if reply, handled := h.resolveQuietToggle(ctx, text, src); notePreRoute(ctx, "quiet-toggle", handled) {
return withNotice(expiredNotice, reply)
}
@@ -291,14 +311,14 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// sent. Only handled when a pending nudge is actually inside the window
// (snooze.go); otherwise the words route normally, because "потом" is an
// ordinary word and eating every one of them would break real sentences.
if reply, handled := h.resolveSnooze(ctx, text, src); handled {
if reply, handled := h.resolveSnooze(ctx, text, src); notePreRoute(ctx, "snooze", handled) {
return withNotice(expiredNotice, reply)
}
// 4c. spoken ack — "готово" closes that same nudge as `acted`. Only the
// contentless form is intercepted here; "выпил воды" keeps routing and
// closes the nudge after its fact lands (ackFromFact, step 8b).
if reply, handled := h.resolveAck(ctx, text, src); handled {
if reply, handled := h.resolveAck(ctx, text, src); notePreRoute(ctx, "ack", handled) {
return withNotice(expiredNotice, reply)
}
@@ -306,7 +326,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// turn and names what it should have been (repair.go). Before routing,
// like the confirm and clarify turns: routing the correction as a fresh
// utterance files the correction itself instead of fixing anything.
if reply, handled := h.resolveRepair(ctx, text); handled {
if reply, handled := h.resolveRepair(ctx, text); notePreRoute(ctx, "repair", handled) {
return withNotice(expiredNotice, reply)
}
@@ -314,7 +334,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// just read (ordinal.go). Before routing, and only when a list is actually
// bound to the session: with nothing offered, "второй" is an ordinary word
// and keeps routing.
if reply, handled := h.resolveCandidate(ctx, text, src); handled {
if reply, handled := h.resolveCandidate(ctx, text, src); notePreRoute(ctx, "ordinal", handled) {
return withNotice(expiredNotice, reply)
}
@@ -359,7 +379,9 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// ("а завтра?" … "а послезавтра?") keeps working.
if h.dialogueSessions != nil {
if !cont {
dec = followUpMerge(prev, dec, now)
merged := followUpMerge(prev, dec, now)
noteMerge(ctx, dec, merged)
dec = merged
}
if !dec.Clarify {
h.rememberTurn(ctx, prev, dec, now)
@@ -380,6 +402,8 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
return withNotice(expiredNotice, reply)
}
if question, asked := h.askClarify(ctx, dec); asked {
noteTerminal(ctx, "clarify-ask", dec.Intent,
"the route was below the threshold, so she asked instead of acting")
return withNotice(expiredNotice, question)
}
}
@@ -396,6 +420,9 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// the round-trip stays alive.
replyText := h.applyAction(ctx, dec)
log.Printf("voice: applyAction returned: %q", replyText)
// A query turn was already claimed by a source inside the chain; every other
// intent has no chain and no scoreboard, so the handler is the winner.
noteTerminal(ctx, "action-handler", dec.Intent, "")
// 8b. a fact that answers a live nudge closes it as `acted` (ack.go).
// Silent: the fact reply stands, she does not congratulate him for it.