package main import ( "context" "log" "math/rand" "strings" "time" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/router" ) // clarifyTTL — how long a parked question stays answerable. Same 90s as the // confirm gate, for the same reason: an answer is a same-breath gesture, and a // stale question must not eat an unrelated later utterance. const clarifyTTL = 90 * time.Second // wantedSlots — what each intent needs before she can act on it. First entry is // the one she asks about this turn; the rest are asked about on later turns, one // per turn, as each answer lands (see askRemainingGap). // // Intents not listed here are never worth a question: note and query act on the // raw utterance, chat and system have nothing to fill in. For those a clarify // decision keeps the canned "не поняла" reply — inventing a question for noise // is worse than admitting she missed it. // A reminder wants BOTH what to remind about and when. Subject first: "напомни // в 11" has a time and nothing to say at 11, and a reminder with no subject is // not worth setting. Order here is the order she asks in. var wantedSlots = map[router.Intent][]dialogue.Slot{ router.IntentReminder: {dialogue.SlotText, dialogue.SlotTime}, router.IntentFact: {dialogue.SlotKey}, router.IntentAct: {dialogue.SlotFn}, } // clarifyQuestions — one short question per missing slot. // // These are fixed templates, not model output. The resident model is a 0.8B; it // would wander, and a question whose wording changes every time is harder to // answer than a blunt one that always reads the same. They are infinitive // questions, so there is no gender agreement to get wrong; the feminine // self-reference lives in the reply she gives when she drops the request. var clarifyQuestions = map[dialogue.Slot]string{ dialogue.SlotTime: "Когда?", dialogue.SlotText: "О чём напомнить?", dialogue.SlotKey: "Что записать?", dialogue.SlotFn: "Что сделать?", } // clarifyGaveUp — she is out of questions and still does not have the slot. She // says so out loud: dropping the request in silence would leave him thinking it // landed. Feminine self-reference ("поняла"), as everywhere. const clarifyGaveUp = "Прости, я не поняла. Скажи, пожалуйста, по-другому." // clarifyExpiredVariants — his answer came after the TTL, so the parked request // is already gone. Same tone as clarifyGaveUp, different reason: too much time // passed, not "I did not understand". Feminine self-reference ("ждала", // "отпустила"); he is addressed with a plain imperative. // // Five phrasings, not one. This is the line he hears whenever he walks off // mid-request, so it is the line that repeats most — and the same sentence every // time is what makes a house assistant sound like a kiosk. They all carry the // same two facts (the old request is gone; say it again if it still matters), // because the wording may vary and the meaning may not. // // Fixed templates rather than model output, for the same reason as // clarifyQuestions: this text has to be right every time, and it is not worth a // generation to say something this small. var clarifyExpiredVariants = []string{ "Прости, я слишком долго ждала ответа и отпустила прошлую просьбу. Если она ещё нужна, скажи заново.", "Кажется, прошлая просьба уже не важна — я её отпустила. Если я ошибаюсь, повтори.", "Ты как-то резко замолчал, и я не стала ждать дальше. Если та просьба ещё нужна, скажи заново.", "Я не дождалась ответа и убрала прошлую просьбу. Повтори, если она всё ещё нужна.", "Столько времени прошло, что я отпустила прошлую просьбу. Скажи заново, если она в силе.", } // clarifyExpiredLine picks one of them at random. func clarifyExpiredLine() string { return clarifyExpiredVariants[rand.Intn(len(clarifyExpiredVariants))] } // isClarifyExpired reports whether s opens with any of the expiry lines. The // 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 } // 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)) } } return strings.TrimSpace(s) } // clarifyExpiredNotice returns that line when a parked question had just timed // out, and "" when nothing was parked. Call it right after // resolveClarifyAnswer: a live question is answered there, an expired one is // only reported here — the words themselves still go on to be routed fresh. func (h *reactiveHandler) clarifyExpiredNotice() string { if h.clarifyStore == nil { return "" } if !h.clarifyStore.TakeExpired(voiceDialogueID, h.now()) { return "" } log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh") return clarifyExpiredLine() } // withNotice glues the expiry notice in front of this turn's reply. One turn // carries one reply on the wire, so the notice cannot be a message of its own — // but neither the notice nor the fresh answer may be dropped. func withNotice(notice, reply string) string { if notice == "" { return reply } if reply == "" { return notice } return notice + " " + reply } // 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 { return dialogue.StillMissing(wantedSlots[dec.Intent], toDialogueSlots(dec.Slots)) } // clarifyQuestion picks the one question to ask for a clarify decision. Returns // ("", false) when she has no idea what is missing. // // One question about one thing: if two slots are missing she asks about the // first only. Two questions in one breath is an interrogation. The second gap // is picked up on the turn after the first one is answered (askRemainingGap). func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) { missing := missingFor(dec) if len(missing) == 0 { return "", "", false } q, ok := clarifyQuestions[missing[0]] if !ok { return "", "", false } return missing[0], q, true } // askClarify parks the request and returns the question to ask instead of the // canned "не поняла". Returns ("", false) when there is nothing to ask about, so // the caller falls back to the canned reply. func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) { if h.clarifyStore == nil { return "", false } slot, question, ok := clarifyQuestion(dec) if !ok { return "", false } h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{ Intent: dialogue.Intent(dec.Intent), Slots: toDialogueSlots(dec.Slots), Missing: []dialogue.Slot{slot}, Utterance: dec.Utterance, Asked: h.now(), TTL: clarifyTTL, Attempts: 1, // this ask MaxAttempts: h.clarifyMaxAttempts, }) log.Printf("voice: clarify — asked about %s for intent=%s", slot, dec.Intent) 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. func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) (string, bool) { if h.clarifyStore == nil { return "", false } q := h.clarifyStore.Get(voiceDialogueID, h.now()) if q == nil { return "", false } intent := router.Intent(q.Intent) answer := h.extractor.Extract(ctx, intent, text, h.now()) 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 "напомни" // would fire at 11:00 saying "напомни" and nothing else. q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text) if len(dialogue.StillMissing(q.Missing, merged)) > 0 { return h.reaskOrGiveUp(q, merged, text), true } h.clarifyStore.Delete(voiceDialogueID) // One gap filled is not the same as a complete request. askClarify parks // only the first gap, because one question per turn is the rule, but a // reminder wants both a subject and a time. "напомни" with neither used to // ask "О чём напомнить?", accept "позвонить маме", and then hand applyAction // a reminder with no time, which answered "не получилось разобрать время // напоминания." — an error for a request she never finished asking about. // Re-enter the loop instead, one question at a time as before. if reply, asked := h.askRemainingGap(q, intent, merged); asked { return reply, true } // Rebuild the decision as if it had routed cleanly, then run it down the // normal path. Clarify is deliberately false and the intent is unchanged: // filling in an argument never grants authority, so the completed decision // still meets the allowlist and the destructive-act confirm gate in // applyAction exactly like any other decision. dec := router.Decision{ Utterance: q.Utterance, Stage: 2, Intent: intent, Slots: applyDialogueSlots(answer, merged), } return h.finishClarified(ctx, dec), true } // 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 // subject is empty or already present, so re-asking the same question twice // cannot grow the utterance. func foldAnswerIntoUtterance(utterance, subject string) string { subject = strings.TrimSpace(subject) if subject == "" || strings.Contains(utterance, subject) { return utterance } if strings.TrimSpace(utterance) == "" { return subject } return strings.TrimSpace(utterance) + " " + subject } // askRemainingGap re-parks the request when the answer closed one gap and // wantedSlots still names another. Returns ("", false) when the request is // complete, when there is no question for what is left, or when she is out of // attempts — in all three the caller runs the decision as it stands, which for // the out-of-attempts case is the old behaviour and is the right one: she has // already asked enough. // // The attempt budget is shared with the re-ask path on purpose. A second gap // costs a question exactly like a second try at the first one does, so the cap // still bounds how many times she can speak before acting or letting go. func (h *reactiveHandler) askRemainingGap(q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) { remaining := dialogue.StillMissing(wantedSlots[intent], merged) if len(remaining) == 0 { return "", false } question, ok := clarifyQuestions[remaining[0]] if !ok || !q.CanAsk() { return "", false } h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{ Intent: q.Intent, Slots: merged, Missing: []dialogue.Slot{remaining[0]}, Utterance: q.Utterance, Asked: h.now(), TTL: clarifyTTL, Attempts: q.Attempts + 1, MaxAttempts: q.MaxAttempts, }) log.Printf("voice: clarify — one gap filled, still missing %s for intent=%s, asking again (attempt %d)", remaining[0], intent, q.Attempts+1) return question, true } // reaskOrGiveUp handles an answer that left the gap open: ask the same question // again while she has attempts left, otherwise say she did not understand and // let the request go. Never returns "" — a mute give-up reads as "done". func (h *reactiveHandler) reaskOrGiveUp(q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string { question := "" if len(q.Missing) > 0 { question = clarifyQuestions[q.Missing[0]] } if question == "" || !q.CanAsk() { h.clarifyStore.Delete(voiceDialogueID) log.Printf("voice: clarify — gave up on %v after %d question(s), answer was %q", q.Missing, q.Attempts, text) return clarifyGaveUp } // Re-park with whatever the answer DID give, the clock restarted and one // more question spent. q.Slots = merged q.Attempts++ q.Asked = h.now() h.clarifyStore.Put(voiceDialogueID, q) log.Printf("voice: clarify — answer %q did not fill %v, asking again (attempt %d)", text, q.Missing, q.Attempts) return question } // finishClarified runs a completed decision through the same steps a freshly // routed one takes: remember the turn, act, then phrase. func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decision) string { if h.dialogueSessions != nil { now := h.now() prev := h.dialogueSessions.Get(voiceDialogueID, now) dec = followUpMerge(prev, dec, now) h.rememberTurn(prev, dec, now) } reply := h.applyAction(ctx, dec) if reply == "" { reply = h.replier.Reply(dec) } if reply == "" { // Belt: an empty reply here would be a silent drop. reply = clarifyGaveUp } return reply } // rememberTurn stores this turn as the dialogue session the next follow-up // inherits from, carrying up to 4 prior turns of history for anaphora. Capped so // one long conversation can't grow the session unboundedly. func (h *reactiveHandler) rememberTurn(prev *dialogue.Session, dec router.Decision, now time.Time) { var history []dialogue.Turn if prev != nil { history = append(history, dialogue.Turn{ Intent: prev.Intent, Slots: prev.Slots, Text: prev.Slots.Text, }) maxHist := len(prev.History) if maxHist > 3 { maxHist = 3 } history = append(history, prev.History[:maxHist]...) } ttl := time.Duration(0) // use the store default (2 min) if dec.Intent == router.IntentChat { ttl = 15 * time.Minute // conversational turns should last longer } h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{ Intent: dialogue.Intent(dec.Intent), Slots: toDialogueSlots(dec.Slots), Timestamp: now, TTL: ttl, History: history, }) }