From 6e6f73da35c023acf402ed20be381161643cf7f5 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 00:55:54 +0400 Subject: [PATCH] voice: the turn is routed once and the decision is shared (V-560) turnRoute memoises this turn's routing, so the resolver that reads it to classify a role and the pipeline that acts on it cannot end up with two different decisions, and the extra route is paid once. needsRoute is the fast path: an utterance with no content of its own reaches the same role without the model. --- cmd/mavend/turnroute.go | 104 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 cmd/mavend/turnroute.go diff --git a/cmd/mavend/turnroute.go b/cmd/mavend/turnroute.go new file mode 100644 index 0000000..b2a5fa3 --- /dev/null +++ b/cmd/mavend/turnroute.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "log" + "sync" + "time" + + "github.com/kami/maven/internal/dialogue" + "github.com/kami/maven/internal/router" +) + +// turnRoute is this turn's routing, computed at most once. +// +// It exists because the arbitration was inverted (Vikunja #560): the clarify +// resolver now reads the routed decision before deciding what the utterance is, +// and the pipeline then acts on that same decision. Routing twice would cost a +// second on the resident model and — worse — could disagree with itself, which +// is exactly the class of bug this task is about. +type turnRoute struct { + h *reactiveHandler + text string + now time.Time + + once sync.Once + dec router.Decision + cont bool + prev *dialogue.Session + err error + + // dropped — what she let go of this turn and must say out loud. A parked + // request that dies without a word leaves him thinking it landed. + dropped string +} + +type turnRouteKey struct{} + +func (h *reactiveHandler) newTurnRoute(text string, now time.Time) *turnRoute { + return &turnRoute{h: h, text: text, now: now} +} + +func withTurnRoute(ctx context.Context, rt *turnRoute) context.Context { + return context.WithValue(ctx, turnRouteKey{}, rt) +} + +// turnRouteFrom returns the turn's memo, or nil when the caller is not inside +// runTurn — a unit test calling one resolver directly, most often. +func turnRouteFrom(ctx context.Context) *turnRoute { + rt, _ := ctx.Value(turnRouteKey{}).(*turnRoute) + return rt +} + +// resolve does the routing exactly as step 5 of runTurn does it: an elliptical +// follow-up is answered from the previous turn, everything else goes to the +// router. One copy of that, so the pre-route the clarify resolver reads and the +// decision the pipeline acts on cannot drift apart. +func (r *turnRoute) resolve(ctx context.Context) (router.Decision, bool, *dialogue.Session, error) { + r.once.Do(func() { + if r.h.dialogueSessions != nil { + r.prev = r.h.dialogueSessions.Get(dialogueIDOf(ctx), r.now) + } + if dec, cont := continuationDecision(r.prev, r.text, r.now); cont { + log.Printf("voice: continuation of %s from the previous turn", dec.Intent) + r.dec, r.cont = dec, true + return + } + if r.h.router == nil { + r.err = router.ErrNoIntents + return + } + r.dec, r.err = r.h.router.Route(ctx, r.text, r.now) + }) + return r.dec, r.cont, r.prev, r.err +} + +// routeForRole gives the role classifier the turn's routed decision. The second +// return is false when there is no usable decision — no router wired, or the +// route failed — and the classifier falls back to its offline tests then. A +// turn must never break on the model, so the error is logged and swallowed +// here; step 5 reads the same memo and reports it the way it always has. +func (h *reactiveHandler) routeForRole(ctx context.Context, text string) (router.Decision, bool) { + rt := turnRouteFrom(ctx) + if rt == nil { + rt = h.newTurnRoute(text, h.now()) + } + dec, _, _, err := rt.resolve(ctx) + if err != nil { + log.Printf("voice: role — no route to classify against (%v), falling back to the offline tests", err) + return router.Decision{}, false + } + return dec, true +} + +// needsRoute reports whether classifying this utterance's role is worth a +// route. It is not: an utterance with no content of its own carries no request +// of its own, so the classifier reaches the same answer without the model. A +// call-off is the same — it is read off a closed lexicon and nothing else. +// +// This is a fast path to the SAME answer and must stay one. If it ever needs a +// rule the classifier does not have, it has become a second decision procedure +// and it is the thing V-560 deleted. +func needsRoute(text string) bool { + return !isCancel(text) && len(ownContent(text)) > 0 +}