From c8a5b5416ecfe2b4c802d9637c61aa102694ef73 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 01:23:19 +0400 Subject: [PATCH] a side query suspends the flow instead of ending it (V-561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/mavend/clarify.go | 192 +++++++++++++++++++++++++++++---- cmd/mavend/clarifytemplates.go | 32 ++++++ cmd/mavend/turnroute.go | 11 ++ cmd/mavend/voice.go | 10 +- internal/dialogue/clarify.go | 23 ++-- 5 files changed, 236 insertions(+), 32 deletions(-) diff --git a/cmd/mavend/clarify.go b/cmd/mavend/clarify.go index f0d532d..413b219 100644 --- a/cmd/mavend/clarify.go +++ b/cmd/mavend/clarify.go @@ -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 diff --git a/cmd/mavend/clarifytemplates.go b/cmd/mavend/clarifytemplates.go index 99693db..4e366af 100644 --- a/cmd/mavend/clarifytemplates.go +++ b/cmd/mavend/clarifytemplates.go @@ -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 diff --git a/cmd/mavend/turnroute.go b/cmd/mavend/turnroute.go index b2a5fa3..d852ad7 100644 --- a/cmd/mavend/turnroute.go +++ b/cmd/mavend/turnroute.go @@ -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{} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 2909b16..1b783d9 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -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 diff --git a/internal/dialogue/clarify.go b/internal/dialogue/clarify.go index 4f5408a..cdab9a4 100644 --- a/internal/dialogue/clarify.go +++ b/internal/dialogue/clarify.go @@ -207,23 +207,26 @@ func (s *ClarifyStore) Depth(id string) int { return len(s.stacks[id]) } -// TakeExpired reports whether a question was parked here but its TTL ran out, -// and drops it. Get drops such a question silently, which leaves the user -// thinking his request is still alive — the caller uses this to tell him it is -// gone before treating his words as a fresh utterance. +// TakeExpired reports HOW MANY parked questions were dropped because the TTL +// ran out, and drops them. 0 ⇒ nothing was parked, or what was parked is still +// live. Get drops such a question silently, which leaves the user thinking his +// request is still alive — the caller uses this to tell him it is gone before +// treating his words as a fresh utterance. // -// It looks at the top only, and drops the whole stack when that one is dead: one -// notice is what a reply can carry, and anything parked under a question that -// timed out has been waiting at least as long. -func (s *ClarifyStore) TakeExpired(id string, now time.Time) bool { +// It looks at the top only, and drops the whole stack when that one is dead: +// anything parked under a question that timed out has been waiting at least as +// long. The COUNT rather than a bool since V-561, because the stack can now +// hold two — the flow and the side query that suspended it — and a notice +// saying "прошлую просьбу" when two died is a lie about the count. +func (s *ClarifyStore) TakeExpired(id string, now time.Time) int { s.mu.Lock() defer s.mu.Unlock() stack := s.stacks[id] if len(stack) == 0 || !stack[len(stack)-1].IsExpired(now) { - return false + return 0 } delete(s.stacks, id) - return true + return len(stack) } // Delete drops every question parked for this id. The old single-slot Delete