diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index e24651a..80dec77 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -11,6 +11,7 @@ import ( "unicode" "github.com/kami/maven/internal/crawl" + "github.com/kami/maven/internal/decision" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/memory" "github.com/kami/maven/internal/morning" @@ -158,8 +159,17 @@ var querySources = []querySource{ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string { t := &queryTurn{dec: dec} + // The roster, so the record can say which sources were never reached rather + // than leaving them out and letting a reader assume they looked and passed + // (V-564). Finish names everyone below the winner. + decision.Expect(ctx, decision.StageQuery, querySourceNames()) + rec := decision.From(ctx) for _, src := range querySources { if dec.Continued && !src.dateAware { + rec.Note(decision.Claim{ + Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked, + Reason: "a continuation turn only asks the date-aware sources", + }) continue } if reply, ok := src.answer(h, ctx, t); ok { @@ -172,8 +182,16 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) // caller asked for one, so /chat can show it (V-539). log.Printf("voice: query claimed by source %q", src.name) noteQuerySource(ctx, src.name) + rec.Note(decision.Claim{ + Stage: decision.StageQuery, Claimant: src.name, + Intent: string(dec.Intent), Outcome: decision.Won, + }) return reply } + rec.Note(decision.Claim{ + Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.Declined, + Reason: "it had no answer for this turn", + }) } if dec.Continued { // The previous question cannot be re-asked for another day. Saying so diff --git a/cmd/mavend/decisiontrace.go b/cmd/mavend/decisiontrace.go new file mode 100644 index 0000000..247992c --- /dev/null +++ b/cmd/mavend/decisiontrace.go @@ -0,0 +1,132 @@ +// mavend/decisiontrace.go — the daemon's half of the per-turn decision record. +// +// V-564. The router says what the cascade did (internal/router/decisiontrace.go); +// this file covers the two claimant sets that live in the daemon: the stateful +// resolvers that run BEFORE routing and pre-empt it unconditionally, and the +// query source chain that runs after. Those two are where the arbitration is +// least visible, because both are a hardcoded order of functions that each +// answer "is this mine?" alone and none of which answers "is this more mine +// than yours?" (V-558). +// +// Recording changes no route. Every helper here is a no-op on a context with no +// record, which is what every test that does not ask for one gets. +package main + +import ( + "context" + + "github.com/kami/maven/internal/decision" + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/router" +) + +// preRouteLadder — the resolvers runTurn offers the utterance to before the +// router sees it, in the order they get their say. Kept here as a roster rather +// than derived from the code, so a resolver that returns early and skips the +// rest still leaves the rest NAMED in the record: a claimant that never looked +// and one that looked and passed are the distinction the ordering hides, and +// they are the difference between a bug in the ladder and a bug in a resolver. +// +// Adding a step to runTurn means adding its name here. Nothing enforces that, +// and nothing should: a missing name costs one line of the record, while a +// check that walks the ladder would have to run the ladder. +var preRouteLadder = []string{ + "confirm", "clarify-answer", "quiet-toggle", "snooze", "ack", "repair", "ordinal", +} + +// notePreRoute records one rung of that ladder and passes its verdict through +// unchanged, so the call site stays the single `if handled` it already was. +func notePreRoute(ctx context.Context, name string, handled bool) bool { + rec := decision.From(ctx) + if rec == nil { + return handled + } + if handled { + rec.Note(decision.Claim{ + Stage: decision.StagePreRoute, Claimant: name, Outcome: decision.Won, + Reason: "it pre-empted routing, so the router never saw this turn", + }) + return handled + } + rec.Note(decision.Claim{ + Stage: decision.StagePreRoute, Claimant: name, Outcome: decision.Declined, + Reason: "nothing of its own was pending", + }) + return handled +} + +// noteTerminal records whoever actually produced the reply, but only if the +// turn is still unclaimed. A route decides the intent; it does not answer, and +// on a thinned route or a plain act nothing downstream keeps a scoreboard. So +// the record would otherwise close with an empty winner, which reads as a lost +// turn instead of an asked question. +func noteTerminal(ctx context.Context, claimant string, intent router.Intent, reason string) { + decision.From(ctx).NoteIfUnclaimed(decision.Claim{ + Stage: decision.StageAction, Claimant: claimant, + Intent: string(intent), Reason: reason, + }) +} + +// noteMerge records the follow-up merge, which is the one claimant that edits +// the winning decision instead of taking the turn from it. It is compared on +// the four slots the merge can fill, because a Decision holds a slice and is +// not comparable. +func noteMerge(ctx context.Context, before, after router.Decision) { + rec := decision.From(ctx) + if rec == nil { + return + } + changed := before.Slots.HasTime != after.Slots.HasTime || + before.Slots.HasKey != after.Slots.HasKey || + before.Slots.HasFn != after.Slots.HasFn || + before.Slots.Text != after.Slots.Text || + before.Intent != after.Intent + if !changed { + rec.Note(decision.Claim{ + Stage: decision.StageMerge, Claimant: "follow-up-merge", Outcome: decision.Declined, + Reason: "no slot of this turn was left for a previous one to fill", + }) + return + } + rec.Note(decision.Claim{ + Stage: decision.StageMerge, Claimant: "follow-up-merge", Intent: string(after.Intent), + Outcome: decision.Merged, Reason: "filled this turn's gaps from the previous turn", + }) +} + +// turnDecisionsFn — the reader mavweb gets, or nil when voice was never wired. +// Same shape as intakeEventsFn: the daemon holds the ring, the IPC layer only +// converts it. +func turnDecisionsFn(w *voiceWiring) func(int) []ipc.TurnDecision { + if w == nil || w.handler == nil || w.handler.decisions == nil { + return nil + } + ring := w.handler.decisions + return func(n int) []ipc.TurnDecision { + recs := ring.Recent(n) + out := make([]ipc.TurnDecision, 0, len(recs)) + for _, rec := range recs { + claims := make([]ipc.TurnClaim, 0, len(rec.Claims)) + for _, c := range rec.Claims { + claims = append(claims, ipc.TurnClaim{ + Stage: c.Stage, Claimant: c.Claimant, Intent: c.Intent, + Score: c.Score, HasScore: c.HasScore, + Outcome: c.Outcome, Reason: c.Reason, + }) + } + out = append(out, ipc.TurnDecision{ + Ts: rec.Ts, Utterance: rec.Utterance, Winner: rec.Winner, Claims: claims, + }) + } + return out + } +} + +// querySourceNames — the query chain's roster, in chain order. +func querySourceNames() []string { + names := make([]string, len(querySources)) + for i, src := range querySources { + names[i] = src.name + } + return names +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index fe12f19..3a20399 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -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) @@ -374,6 +396,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) } } @@ -390,6 +414,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. diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 520d300..f3ef494 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -11,6 +11,7 @@ import ( "time" "github.com/kami/maven/internal/config" + "github.com/kami/maven/internal/decision" "github.com/kami/maven/internal/delivery" "github.com/kami/maven/internal/delivery/voicesink" "github.com/kami/maven/internal/dialogue" @@ -293,7 +294,11 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem }, dataStore: dataStore, dialogueSessions: dialogueSessions, - clarifyStore: clarifyStore, + // Always on (V-564). The record is the instrument the rest of V-558 is + // measured with, and one that only runs when a flag is set is not there + // on the night the misroute happens. + decisions: decision.NewRing(), + clarifyStore: clarifyStore, // 0 here (unset config) ⇒ the dialogue default. clarifyMaxAttempts: cfg.Voice.ClarifyMaxAttempts, extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}},