bac8673f05
Every token of "что у меня сегодня?" is frame, so ownContent left nothing, needsRoute returned false and no route was computed at all. The parked reminder then read "сегодня" as its time and the question was answered nowhere. A question shape now gets routed even when it leaves no content of its own, and a role that fills nothing she asked about is decided by the route. A statement he makes mid-flow gets a role of its own, roleAside, so a note or a fact is stored and the question comes back instead of being dropped in silence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
126 lines
4.8 KiB
Go
126 lines
4.8 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.
|
|
//
|
|
// 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)
|
|
}
|