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 // resume — the parked question, re-worded, to put AFTER this turn's answer // (Vikunja #561). A side query does not end the flow it interrupted, so the // reply carries two acts: the answer he asked for, then the question he // still owes her. Empty ⇒ nothing was suspended. resume string // suspended — a flow is parked underneath this turn. askClarify reads it to // decide between Put (replace the top) and Push (keep the flow and stack the // new question on it), because a side query that needs clarifying of its own // must not overwrite the thing it interrupted. suspended bool } 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. // // A question shape is the exception and V-577 is why (measured 2026-08-06). // "что у меня сегодня?" is an interrogative, a preposition, a particle and a day // word, so every token of it is frame and it left no content of its own. The // fast path called it an answer, the parked reminder read "сегодня" as its time, // and the question he asked was never answered. Asked alone the same sentence // routes to query at stage 0, so the route knew and was never consulted. func needsRoute(text string) bool { if isCancel(text) { return false } return len(ownContent(text)) > 0 || router.IsQuestionShaped(text) }