voice: clarify routes first and isOwnRequest is gone (V-560)

resolveClarifyAnswer now decides what the utterance IS before deciding what to
do with it: route, classify the role, then answer, cancel, or step aside. The
side_query and new_request arms drop the parked question and say so — nothing
may die in silence — and V-561 turns the first of those into a suspend.

isOwnRequest is deleted rather than extended. It only ran where the answer
filled nothing, which is why the greedy 'сейчас' in a weather question walked
straight past it and set a reminder for a time nobody asked for.
This commit is contained in:
2026-08-06 00:56:06 +04:00
parent 6e6f73da35
commit 6abd2768e8
2 changed files with 72 additions and 54 deletions
+59 -38
View File
@@ -175,58 +175,69 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
return question, true
}
// resolveClarifyAnswer reads an utterance as the answer to a parked question.
// Returns ("", false) when no live question is parked (or it expired), so the
// caller routes the utterance normally as a fresh request. Sibling of
// resolveConfirm and checked in the same place.
//
// The answer is parsed with the same extractor the router uses, for the intent
// she parked — no second parser. If it still does not fill the gap she asks
// again, up to MaxAttempts; after that she says out loud that she did not
// understand. She never drops the request in silence.
// isOwnRequest reports whether an utterance asks for something in its own
// right, which is what a clarify answer never does. Two offline tests over
// tokens, both already written for other callers: a question shape, and a
// capture verb. Cheap on purpose — this runs on the answer to every parked
// question, and it must not cost a model call.
//
// It is not a general relevance test. A bare noun that answers nothing ("синий"
// after "Что сделать?") is still treated as an answer and still re-asked, and
// that is the intended shape: only an utterance that carries its own request
// wins over the question in front of it.
func isOwnRequest(text string) bool {
return router.IsQuestionShaped(text) || router.CarriesCaptureVerb(text)
}
// clarifyCancelled — he called the half-built request off. Said out loud, like
// every other way it can end: a silent drop reads as "done". Feminine
// self-reference ("отменила"), as everywhere.
const clarifyCancelled = "Хорошо, отменила."
// clarifyDropped — he asked for something else instead, so the parked request
// is gone. Glued in front of the answer to what he actually asked, because
// nothing may be dropped in silence. V-561 suspends and resumes it instead of
// letting it go, and this line goes away with it.
const clarifyDropped = "Прошлую просьбу отпускаю."
// resolveClarifyAnswer reads an utterance against the parked question and
// decides what it IS before deciding what to do with it. Returns ("", false)
// when the turn is not this resolver's — nothing parked, or the utterance turned
// out to be a request of its own — so the caller dispatches it normally.
//
// The order is the point (Vikunja #560). The utterance is ROUTED first, and the
// role is read off that decision: a routed decision that stands on its own is
// not an answer, whatever the extractor found inside it. Before this the
// extractor decided, so "какая сейчас погода в Риме?" became the time of a
// reminder on the strength of the word "сейчас".
//
// The answer itself is parsed with the same extractor the router uses, for the
// intent she parked — no second parser. If it still does not fill the gap she
// asks again, up to MaxAttempts; after that she says out loud that she did not
// understand. She never drops the request in silence.
func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) (string, bool) {
if h.clarifyStore == nil {
return "", false
}
q := h.clarifyStore.Get(dialogueIDOf(ctx), h.now())
if q == nil {
return "", false
return "", false // not_applicable: nothing is pending
}
intent := router.Intent(q.Intent)
answer := h.extractor.Extract(ctx, intent, text, h.now())
merged := q.Answer(text, toDialogueSlots(answer))
// He moved on. A parked question used to swallow whatever came next, so one
// act she could not fulfil ate the following three turns: "выключи свет в
// спальне" asked "Что сделать?", and "кто изобрёл телефон" was scored as an
// answer to it, then "как дела" after that (Vikunja #554). Nothing checked
// whether the words could be an answer at all.
//
// Deliberately narrow. It only fires where the answer filled nothing, so a
// turn that closes the gap is still an answer whatever shape it has, and
// the retry budget is untouched — the count was never the problem. Dropping
// the question and routing the utterance as itself is what he meant either
// way: if he really was answering, he can say it again, and if he was not,
// he gets the thing he asked for instead of being asked a third time.
if len(dialogue.StillMissing(q.Missing, merged)) > 0 && isOwnRequest(text) {
var (
routed router.Decision
routedOK bool
)
if needsRoute(text) {
routed, routedOK = h.routeForRole(ctx, text)
}
role := classifyTurnRole(q, text, toDialogueSlots(answer), routed, routedOK)
log.Printf("voice: clarify — %q is a %s against %s (routed=%v)", text, role, dialogue.CapabilityFor(q.Intent), routedOK)
switch role {
case roleCancel:
h.clarifyStore.Delete(dialogueIDOf(ctx))
log.Printf("voice: clarify — %q is its own request, not an answer to %v; dropping the question", text, q.Missing)
return clarifyCancelled, true
case roleSideQuery, roleNewRequest:
// He moved on. A parked question used to swallow whatever came next, so
// one act she could not fulfil ate the following three turns (Vikunja
// #554) and a world question set a reminder for a time nobody asked for
// (#558). Drop the question, say so, and let these words be themselves.
h.clarifyStore.Delete(dialogueIDOf(ctx))
h.noteDropped(ctx)
return "", false
}
merged := q.Answer(text, toDialogueSlots(answer))
// Fold a newly answered subject into the raw utterance. Downstream actions
// phrase from Utterance, not from the text slot — actionReminder stores it
// as the reminder payload — so a reminder clarified out of a bare "напомни"
@@ -262,6 +273,16 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
return h.finishClarified(ctx, dec), true
}
// noteDropped records that the parked request was let go this turn, so runTurn
// can say it in front of whatever these words are answered with. Nothing to
// record outside runTurn — a unit test calling one resolver has no turn to glue
// a notice onto.
func (h *reactiveHandler) noteDropped(ctx context.Context) {
if rt := turnRouteFrom(ctx); rt != nil {
rt.dropped = clarifyDropped
}
}
// foldAnswerIntoUtterance appends an answered subject to the original words,
// unless they already carry it. "напомни" + "позвонить маме" reads as the
// request he would have made in one breath. Nothing is appended when the
+13 -16
View File
@@ -248,6 +248,14 @@ const (
//
// The ordering is load-bearing — see the step comments.
func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSource) string {
// 0. the turn's routing, computed at most once and shared (Vikunja #560).
// The clarify resolver reads it to decide what this utterance IS before
// claiming it, and step 5 acts on the same decision — routing twice would
// cost a second on the resident model and could disagree with itself.
now := h.now()
rt := h.newTurnRoute(text, now)
ctx = withTurnRoute(ctx, rt)
// 1. expired clarify — a question was parked but its TTL ran out, so the
// request behind it is gone. Say that out loud (see clarify.go) and carry
// on: these words are still routed as a fresh utterance below, with the
@@ -278,6 +286,10 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
if reply, handled := h.resolveClarifyAnswer(ctx, text); handled {
return withNotice(expiredNotice, reply)
}
// It did not claim the turn. If it let a parked request go to get out of the
// way, that has to be said in front of whatever these words are answered
// with — carried on the same notice, so every exit below keeps it.
expiredNotice = withNotice(expiredNotice, rt.dropped)
// 4. quiet-hours toggle — keyword match, not classifier-dependent.
// "тихий режим" / "quiet on" would route through the classifier
@@ -323,22 +335,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// missing, so no amount of routing recovers it, and the model's guess
// costs seconds to obtain and is close to a coin flip. Everything else
// goes to the router.
var (
dec router.Decision
err error
prev *dialogue.Session
)
now := h.now()
if h.dialogueSessions != nil {
prev = h.dialogueSessions.Get(dialogueIDOf(ctx), now)
}
cont := false
if dec, cont = continuationDecision(prev, text, now); cont {
log.Printf("voice: continuation of %s from the previous turn", dec.Intent)
}
if !cont {
dec, err = h.router.Route(ctx, text, now)
}
dec, cont, prev, err := rt.resolve(ctx)
if err != nil {
// ErrNoIntents ⇒ classifier unseeded (cold boot). reply with a
// "still warming up" rather than a wire error.