c8a5b5416e
V-560 classified the side query correctly and then dropped the request behind it, saying "Прошлую просьбу отпускаю." The owner rejected that on sight: he asked about the weather in the middle of setting a reminder, and being told the reminder was let go reports a loss he did not ask for. It had not been lost — there was simply nowhere to put it. There is now. ClarifyStore grew a bounded stack in V-559 and nothing called Push; this is the caller it was built for. A side query leaves the question parked exactly as it is, the words are answered as themselves, and the question comes back on the end of the same reply — one utterance, two acts. The resumed question is not the first one again. "Когда?" works in the same breath as "напомни позвонить маме" and does not work after a turn about Rome, so the deck has a second form per slot that names the request: "На какое время поставить напоминание?". No attempt is spent, because he answered the side query and not the parked question, and charging a retry for a turn that was never an answer is the V-554 shape. clarifyDropped stays for new_request and cancel, where something really does die. Two things can now die at once, so TakeExpired reports a count instead of a bool and the expiry notice has a plural wording — "прошлую просьбу" when two were lost would be a lie about the number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
116 lines
4.2 KiB
Go
116 lines
4.2 KiB
Go
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.
|
|
func needsRoute(text string) bool {
|
|
return !isCancel(text) && len(ownContent(text)) > 0
|
|
}
|