a side query suspends the flow instead of ending it (V-561)

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>
This commit is contained in:
2026-08-06 01:23:19 +04:00
parent 70b32af8a7
commit c8a5b5416e
5 changed files with 236 additions and 32 deletions
+171 -21
View File
@@ -6,6 +6,8 @@ import (
"math/rand"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/router"
@@ -65,8 +67,28 @@ var clarifyExpiredVariants = []string{
"Столько времени прошло, что я отпустила прошлую просьбу. Скажи заново, если она в силе.",
}
// clarifyExpiredLine picks one of them at random.
func clarifyExpiredLine() string {
// clarifyExpiredPluralVariants — the same notice when TWO parked requests died
// together (Vikunja #561). Since a side query suspends the flow instead of
// dropping it, the stack can hold both the flow and the thing he interrupted it
// with, and TakeExpired drops the whole stack when the top times out. "Прошлую
// просьбу" would then be a lie about the count: he loses two and hears about
// one.
//
// Two phrasings only, against five for the singular. This fires when he walks
// off in the middle of an interrupted exchange, which is rarer than walking off
// in the middle of a plain one, so it repeats less and needs less variety.
var clarifyExpiredPluralVariants = []string{
"Прости, я слишком долго ждала и отпустила обе прошлые просьбы. Если они ещё нужны, скажи заново.",
"Я не дождалась ответа и убрала обе прошлые просьбы. Повтори, если они всё ещё нужны.",
}
// clarifyExpiredLine picks one of them at random. n is how many requests died;
// anything above one gets the plural wording, because the bound is two today and
// a third would still be "обе" short of the truth only if MaxStackDepth grew.
func clarifyExpiredLine(n int) string {
if n > 1 {
return clarifyExpiredPluralVariants[rand.Intn(len(clarifyExpiredPluralVariants))]
}
return clarifyExpiredVariants[rand.Intn(len(clarifyExpiredVariants))]
}
@@ -74,23 +96,34 @@ func clarifyExpiredLine() string {
// notice is glued in front of this turn's reply (see withNotice), so a caller
// checking for it has to match a prefix, not the whole string.
func isClarifyExpired(s string) bool {
for _, v := range clarifyExpiredVariants {
if strings.HasPrefix(s, v) {
return true
}
}
return false
_, ok := cutClarifyExpired(s)
return ok
}
// trimClarifyExpired strips a leading expiry notice, leaving this turn's actual
// reply. "" ⇒ the notice was the whole thing.
func trimClarifyExpired(s string) string {
for _, v := range clarifyExpiredVariants {
if strings.HasPrefix(s, v) {
return strings.TrimSpace(strings.TrimPrefix(s, v))
rest, ok := cutClarifyExpired(s)
if !ok {
return strings.TrimSpace(s)
}
return rest
}
// cutClarifyExpired matches either expiry deck as a prefix and returns what
// follows it. Both decks, since V-561 added the plural line: a caller asking
// "did she say a request timed out" means the fact, not which wording carried
// it, and a helper that knew only the singular would read the two-request
// notice as ordinary reply text.
func cutClarifyExpired(s string) (string, bool) {
for _, deck := range [][]string{clarifyExpiredVariants, clarifyExpiredPluralVariants} {
for _, v := range deck {
if strings.HasPrefix(s, v) {
return strings.TrimSpace(strings.TrimPrefix(s, v)), true
}
}
}
return strings.TrimSpace(s)
return "", false
}
// clarifyExpiredNotice returns that line when a parked question had just timed
@@ -101,11 +134,12 @@ func (h *reactiveHandler) clarifyExpiredNotice(ctx context.Context) string {
if h.clarifyStore == nil {
return ""
}
if !h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now()) {
n := h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now())
if n == 0 {
return ""
}
log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh")
return clarifyExpiredLine()
log.Printf("voice: clarify — %d parked question(s) expired, telling him and routing the words fresh", n)
return clarifyExpiredLine(n)
}
// withNotice glues the expiry notice in front of this turn's reply. One turn
@@ -121,6 +155,48 @@ func withNotice(notice, reply string) string {
return notice + " " + reply
}
// withResumed puts the resumed question on the END of this turn's reply, where
// withNotice puts the expiry notice on the front (Vikunja #561).
//
// The order is the owner's: "в Риме сейчас ..., на какое время поставить
// напоминание?" — answer first, then the open question. A question in front of
// its own answer would read as ignoring what he asked.
//
// A statement's full stop is folded into a comma, so the two acts read as one
// sentence — that is the owner's own punctuation, "в Риме сейчас ..., на какое
// время поставить напоминание?". An answer that is ITSELF a question keeps its
// mark and the resume starts a new sentence: she sometimes answers a side query
// by asking him to say it again, and "переформулировать?, на какое время" folds
// two questions into one unreadable line.
//
// A resume with no answer in front of it is just the question.
func withResumed(reply, resumed string) string {
if resumed == "" {
return reply
}
reply = strings.TrimSpace(reply)
if reply == "" {
return resumed
}
if strings.HasSuffix(reply, "?") {
return reply + " " + resumed
}
if trimmed := strings.TrimRight(reply, ".!"); trimmed != "" {
reply = trimmed
}
return reply + ", " + lowerFirst(resumed)
}
// lowerFirst lowercases the opening rune, so a deck line written as a standalone
// sentence reads as the second half of one. Only the first rune: "На какое
// время" must become "на какое время" and nothing else in it may move.
func lowerFirst(s string) string {
for i, r := range s {
return string(unicode.ToLower(r)) + s[i+utf8.RuneLen(r):]
}
return s
}
// missingFor returns the slots a decision still needs, most important first.
// Empty ⇒ there is nothing identifiable to ask about.
func missingFor(dec router.Decision) []dialogue.Slot {
@@ -161,7 +237,7 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
log.Printf("voice: clarify — act %q matched no capability; saying so instead of asking", dec.Utterance)
return actNotRecognized, true
}
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
q := &dialogue.PendingQuestion{
Intent: dialogue.Intent(dec.Intent),
Slots: toDialogueSlots(dec.Slots),
Missing: []dialogue.Slot{slot},
@@ -170,7 +246,33 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
TTL: clarifyTTL,
Attempts: 1, // this ask
MaxAttempts: h.clarifyMaxAttempts,
})
}
// Push, not Put, when this turn suspended a flow (Vikunja #561): the side
// query needs clarifying of ITS own, and Put would replace the top of the
// stack — which is the very question the side query was allowed to interrupt
// rather than kill. Push keeps both.
//
// Push returns whatever the depth bound forced out, and that one has to be
// spoken: MaxStackDepth is a promise that every level she keeps is a level
// she can name when it dies. It is glued in front, like every other notice
// about something let go.
rt := turnRouteFrom(ctx)
if rt != nil && rt.suspended {
if evicted := h.clarifyStore.Push(dialogueIDOf(ctx), q); evicted != nil {
log.Printf("voice: clarify — stack full at %d, letting go of the request behind %q", dialogue.MaxStackDepth, evicted.Utterance)
rt.dropped = withNotice(rt.dropped, clarifyDropped)
}
// One question per breath still holds. The side query turned out to need
// a question of its own, so THAT is the one she asks; resuming as well
// would put two questions in one reply, which is the interrogation
// askRemainingGap already refuses to run. The suspended flow keeps its
// place underneath and is not lost — if it is never reached it dies on
// the TTL, and the expiry notice (now plural-aware) says so.
rt.resume = ""
log.Printf("voice: clarify — asked about %s for intent=%s, stacked on a suspended flow", slot, dec.Intent)
return question, true
}
h.clarifyStore.Put(dialogueIDOf(ctx), q)
log.Printf("voice: clarify — asked about %s for intent=%s", slot, dec.Intent)
return question, true
}
@@ -180,10 +282,17 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
// self-reference ("отменила"), as everywhere.
const clarifyCancelled = "Хорошо, отменила."
// clarifyDropped — he asked for something else instead, so the parked request
// 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.
// nothing may be dropped in silence.
//
// Only new_request and cancel reach this since V-561. A side query used to as
// well, and the owner rejected it on sight: he asks about the weather in the
// middle of setting a reminder, and hearing "прошлую просьбу отпускаю" tells him
// a thing he did not ask to lose has been lost. It had not been — there was
// simply nowhere to put it. Now there is (ClarifyStore's stack), so a side query
// suspends and resumes, and apologising for a drop that did not happen is worse
// than saying nothing.
const clarifyDropped = "Прошлую просьбу отпускаю."
// resolveClarifyAnswer reads an utterance against the parked question and
@@ -227,7 +336,19 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
case roleCancel:
h.clarifyStore.Delete(dialogueIDOf(ctx))
return clarifyCancelled, true
case roleSideQuery, roleNewRequest:
case roleSideQuery:
// He asked something of his own WITHOUT leaving the flow. The question
// stays exactly where it is — same slot, same attempt, same parked
// utterance — and these words go on to be answered as themselves. The
// resumed question is then glued onto the back of that answer, so one
// reply carries both acts (Vikunja #561).
//
// No attempt is spent. He answered the side query, not the parked
// question, and charging a retry for a turn that was never an answer is
// the V-554 shape.
h.noteSuspended(ctx, q)
return "", false
case 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
@@ -283,6 +404,35 @@ func (h *reactiveHandler) noteDropped(ctx context.Context) {
}
}
// noteSuspended keeps the parked question alive across a side query and records
// the words that bring it back, so runTurn can put them after this turn's answer
// (Vikunja #561).
//
// Two things happen to the question and neither is an attempt. Its clock is
// restarted, because she is about to ask it again and the 90s TTL measures the
// pause since she last spoke it — leaving Asked at the original ask would let a
// flow he is actively working through die of a wait he did not take. And the
// stack is left exactly as it is: the question is already on top, so suspending
// it is not a write.
//
// A slot with no resumed wording (clarifyResumedFor says so) resumes nothing and
// says nothing. She must not claim to be holding a question she cannot re-ask.
func (h *reactiveHandler) noteSuspended(ctx context.Context, q *dialogue.PendingQuestion) {
rt := turnRouteFrom(ctx)
if rt == nil || len(q.Missing) == 0 {
return
}
question, ok := clarifyResumedFor(q.Missing[0])
if !ok {
return
}
q.Asked = h.now()
h.clarifyStore.Put(dialogueIDOf(ctx), q)
rt.resume = question
rt.suspended = true
log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply", q.Missing[0])
}
// 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
+32
View File
@@ -50,6 +50,38 @@ var clarifyQuestionVariants = map[dialogue.Slot][]string{
},
}
// clarifyResumedVariants — the wording for a question coming BACK after a side
// query took the turn away from it (Vikunja #561).
//
// It is not the first question again. "Когда?" works in the same breath as
// "напомни позвонить маме", because the thing it is about was just said. After
// a turn about the weather in Rome it does not: he has been thinking about
// something else, and a bare "Когда?" asks him to remember what she is holding.
// So the resumed form names the request — "напоминание", "заметка" — and the
// first form stays short.
//
// One wording per slot, not a rotation and not an attempt ladder. A resume does
// not spend an attempt (that is the point of suspending rather than re-asking),
// so there is no attempt number to vary on, and this line is heard once per
// interruption rather than repeatedly.
//
// Persona holds: infinitive, so no gender agreement, "ты" nowhere needed, no pet
// names.
var clarifyResumedVariants = map[dialogue.Slot]string{
dialogue.SlotTime: "На какое время поставить напоминание?",
dialogue.SlotText: "Так о чём напомнить?",
dialogue.SlotKey: "Так что записать?",
dialogue.SlotFn: "Так какое действие выполнить?",
}
// clarifyResumedFor gives the resumed wording for a slot. ("", false) when the
// slot has none, and the caller then resumes nothing rather than inventing a
// question — a flow it cannot re-ask is one it must not claim to be holding.
func clarifyResumedFor(slot dialogue.Slot) (string, bool) {
q, ok := clarifyResumedVariants[slot]
return q, ok
}
// actNotRecognized is what an act she cannot run gets (Vikunja #556).
//
// The deck used to ask "Что сделать?" instead. That question has no answer he
+11
View File
@@ -31,6 +31,17 @@ type turnRoute struct {
// 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{}
+9 -1
View File
@@ -255,7 +255,7 @@ const (
// path wraps it in stt/tts, the text path returns it as-is.
//
// The ordering is load-bearing — see the step comments.
func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSource) string {
func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSource) (reply string) {
// 0. the decision record (V-564). Installed here rather than in the IPC
// entry point, so the mic, telegram and the web all leave the same trail —
// a record only the web produced would be missing exactly the turns that
@@ -311,6 +311,14 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// with — carried on the same notice, so every exit below keeps it.
expiredNotice = withNotice(expiredNotice, rt.dropped)
// 3b. and if it SUSPENDED a request instead of letting it go, the question
// comes back on the end of whatever these words are answered with (Vikunja
// #561). A deferred append rather than a call at each exit: there are eight
// returns between here and the replier, and the flow has to survive all of
// them — one that forgot would be a request parked for ever, waiting for an
// answer to a question he never heard asked.
defer func() { reply = withResumed(reply, rt.resume) }()
// 4. quiet-hours toggle — keyword match, not classifier-dependent.
// "тихий режим" / "quiet on" would route through the classifier
// unreliably (it's a command, not a free-form query), so we match it