diff --git a/cmd/mavend/clarify.go b/cmd/mavend/clarify.go index 6220725..f0d532d 100644 --- a/cmd/mavend/clarify.go +++ b/cmd/mavend/clarify.go @@ -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 diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 2a949bc..d5366a1 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -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.