package main import ( "context" "log" "math/rand" "strings" "time" "unicode/utf8" "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}, } // The questions themselves live in clarifytemplates.go, one list per slot, // picked by attempt (Vikunja #457). The first ask is the short one this map // used to hold; a re-ask says it differently, because a question he already // failed to answer is the worst one to repeat unchanged. // 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{ "Прости, я слишком долго ждала ответа и отпустила прошлую просьбу. Если она ещё нужна, скажи заново.", "Кажется, прошлая просьба уже не важна — я её отпустила. Если я ошибаюсь, повтори.", "Ты как-то резко замолчал, и я не стала ждать дальше. Если та просьба ещё нужна, скажи заново.", "Я не дождалась ответа и убрала прошлую просьбу. Повтори, если она всё ещё нужна.", "Столько времени прошло, что я отпустила прошлую просьбу. Скажи заново, если она в силе.", } // 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))] } // 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 { _, 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 { 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 "", false } // 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(ctx context.Context) string { if h.clarifyStore == nil { return "" } n := h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now()) if n == 0 { return "" } 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 // 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 } // 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. // // Two sentences, not one (V-654). This used to fold the answer's full stop into // a comma, on the strength of the owner having written it that way once. Spliced // onto a real answer it reads as one run-on thought — "вот что я нашла: вайфай // пароль лежит в ящике стола, на какое время поставить напоминание?" — and the // question disappears into the tail of a sentence about something else. A reply // with no terminator of its own is given one, so the join never depends on how // the phraser chose to end. // // 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 !endsSentence(reply) { reply += "." } return reply + " " + resumed } // endsSentence reports whether s already closes itself. The ellipsis counts: a // trailing "…" is a deliberate end, and a full stop after it reads as a typo. func endsSentence(s string) bool { r, _ := utf8.DecodeLastRuneInString(s) return strings.ContainsRune(".!?…", r) } // 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 stillMissingFor(dec.Intent, dec.Utterance, 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 (h *reactiveHandler) clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) { missing := missingFor(dec) if len(missing) == 0 { return "", "", false } q, ok := h.questionFor(missing[0], 1, dec.Utterance, toDialogueSlots(dec.Slots), "") if !ok { return "", "", false } return missing[0], q, true } // questionFor picks the wording for one gap. Every slot but the reminder's time // reads its deck by attempt; the time asks about whichever of the hour, the half // of the day and the day he has not said, and states the clock while it does // (V-579). // // taken is the acknowledgement of what his last turn added, empty when it added // nothing and empty for a first ask, which has no turn behind it (V-593). func (h *reactiveHandler) questionFor(slot dialogue.Slot, attempt int, utterance string, s dialogue.Slots, taken string) (string, bool) { if slot != dialogue.SlotTime { return clarifyQuestionFor(slot, attempt) } return whenQuestion(whenGapOf(utterance, s.HasTime), attempt, h.now(), taken) } // 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(ctx context.Context, dec router.Decision) (string, bool) { if h.clarifyStore == nil { return "", false } slot, question, ok := h.clarifyQuestion(dec) if !ok { return "", false } // An act she could not resolve is a refusal, not a question (Vikunja #556). if slot == dialogue.SlotFn { log.Printf("voice: clarify — act %q matched no capability; saying so instead of asking", dec.Utterance) return actNotRecognized, true } q := &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, } // 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 } // 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. // // 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 // 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 // not_applicable: nothing is pending } intent := router.Intent(q.Intent) answer := h.extractor.Extract(ctx, intent, text, h.now()) 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)) return clarifyCancelled, true 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 roleAside: // He stated something in the middle of the flow. Same machinery as a // side query and for the same reason: the words are answered as // themselves, so the note or the fact is stored, and the question comes // back on the end of the same reply (V-577 shape 2). Storing it in // silence and dropping it in silence are both wrong, and dropping it is // what she did. 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 // (#558). Drop the question, say so, and let these words be themselves. h.clarifyStore.Delete(dialogueIDOf(ctx)) h.noteDropped(ctx) return "", false } // He is answering, so the run of step-asides is over (V-654). Reset here // rather than where a gap is FILLED: "позвонить маме" against a question // about the time gives her nothing she asked for and still means he is in // the exchange, and the retry it costs is bound enough on its own. The // counter is for the case the bounds miss — he asked for other things and // never came back. q.Suspends = 0 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) // A fact answers with a key and a value and fills no Text slot at all, so // the fold above leaves the utterance at the bare "запиши" — and that is // what the confirmation now reads back to him (V-592). His raw words are the // only record of what he said, so they are what is folded. Never for a time // question: what he says about when is kept apart in WhenText on purpose, // or the reminder would read the day back at him when it fires. if merged.Text == "" && !asksAboutTime(q.Missing) { q.Utterance = foldAnswerIntoUtterance(q.Utterance, text) } // An answer about the time joins everything else he has said about the time, // and the whole of it is re-read as one request (V-579). "завтра" names the // day of an hour she is already holding, and read alone it names no hour at // all, so the parser would have nothing and she would ask for ever. // What he had already said about the time, read BEFORE this answer joins it. // A re-ask that cannot tell the two apart is the one that repeats itself // byte for byte (V-593). var taken string if asksAboutTime(q.Missing) { before := whenKnownOf(whenTextOf(q), q.Slots.HasTime) q.WhenText = strings.TrimSpace(q.WhenText + " " + text) if t, ok := h.readWhen(ctx, intent, q, text); ok { merged.Time, merged.HasTime = t, true } if before.movedForward(whenKnownOf(whenTextOf(q), merged.HasTime)) { taken = whenTakenLine(text) } } if stillOpen(q.Missing, whenTextOf(q), merged) { return h.reaskOrGiveUp(ctx, q, merged, text, taken), true } h.clarifyStore.Delete(dialogueIDOf(ctx)) // 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(ctx, 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 } // 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 } } // 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. // // Suspension is bounded, since V-654. Neither of the two things above is a // limit: no attempt is spent, and restarting the clock means the TTL cannot // arrive while he keeps talking. So the count is the only thing that ends it, // and past MaxSuspends she lets the request go and says so with the same line // every other drop uses. The rule is unchanged — a question ends by being // answered or by being let go out loud — this only recognises three unrelated // requests in a row as the second of those. 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 } if !q.CanResume() { h.clarifyStore.Delete(dialogueIDOf(ctx)) h.noteDropped(ctx) log.Printf("voice: clarify — the question about %s stepped aside %d times; letting the request go", q.Missing[0], q.Suspends) return } q.Suspends++ 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 (suspend %d of %d)", q.Missing[0], q.Suspends, dialogue.MaxSuspends) } // 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(ctx context.Context, q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) { remaining := stillMissingFor(intent, whenTextOf(q), merged) if len(remaining) == 0 { return "", false } // Attempts+1 is the question she is about to ask, and the budget is shared // with the re-ask path, so the second gap is worded like a second try. question, ok := h.questionFor(remaining[0], q.Attempts+1, whenTextOf(q), merged, "") if !ok || !q.CanAsk() { return "", false } // Suspends is not carried, and by this point it is already zero: the answer // path resets it (V-654). Left off the literal so the zero is stated where // the struct is built, rather than inherited from a field nobody names. h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{ Intent: q.Intent, Slots: merged, Missing: []dialogue.Slot{remaining[0]}, Utterance: q.Utterance, WhenText: q.WhenText, 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". // taken is the acknowledgement of what this answer DID give, empty when it gave // nothing (V-593). The give-up line never carries it: it is not another ask. func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text, taken string) string { question := "" if len(q.Missing) > 0 { question, _ = h.questionFor(q.Missing[0], q.Attempts+1, whenTextOf(q), merged, taken) } if question == "" || !q.CanAsk() { h.clarifyStore.Delete(dialogueIDOf(ctx)) 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(dialogueIDOf(ctx), 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(dialogueIDOf(ctx), now) dec = followUpMerge(prev, dec, now) h.rememberTurn(ctx, prev, dec, now) } reply := h.applyAction(ctx, dec) if reply == "" { reply = h.replier.Reply(ctx, dec) } if reply == "" { // Belt: an empty reply here would be a silent drop. reply = clarifyGaveUp } return reply } // maxCarriedHistory — how many turns of PRIOR history (beyond the immediate // last turn) rememberTurn carries forward. The session ends up holding this // many plus the one just-finished turn, so callers describing the total // depth (chatHistory's doc comment, this one) say "up to 4". const maxCarriedHistory = 3 // 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(ctx context.Context, prev *dialogue.Session, dec router.Decision, now time.Time) { var history []dialogue.Turn if prev != nil { history = append(history, sessionAsTurn(prev)) maxHist := len(prev.History) if maxHist > maxCarriedHistory { maxHist = maxCarriedHistory } 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 } // A system or query turn often carries no Text slot at all — a stage-0 // grammar fills none. The next turn may be an ellipsis ("а завтра?"), // which knows the day but not what was asked ABOUT, so keep the raw // utterance where continuation.go can find it. Only these two intents: // everywhere else Text is a payload and must stay what the router put in. // // Overwritten, not filled: rememberTurn runs AFTER followUpMerge, which // has already inherited a Text from the previous same-intent turn, so a // fill-if-empty rule keeps the OLD topic for ever. Seen on the deployed // daemon 01-08-2026 — "во сколько у меня встреча" then "какие у меня // планы" then "а завтра?" continued the meeting, two turns stale. // // A continuation is the exception and keeps what it inherited: its // utterance is the ellipsis, and the topic it carries is the real one. slots := toDialogueSlots(dec.Slots) if !dec.Continued && (dec.Intent == router.IntentSystem || dec.Intent == router.IntentQuery) { slots.Text = dec.Utterance } h.dialogueSessions.Put(dialogueIDOf(ctx), &dialogue.Session{ Intent: dialogue.Intent(dec.Intent), Slots: slots, Timestamp: now, TTL: ttl, History: history, }) }