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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user