d70cb7e9ab
A parked clarify ate a foreign utterance and the clock answered for him.
Root cause was a step earlier than filed. ownContent("что у меня сегодня?")
returns empty, every token being frame, so needsRoute said no and no route was
computed at all. classifyTurnRole fell through to roleAnswer, the extractor read
сегодня, and the date parser answered a bare day word with that day at the
current minute.
needsRoute now routes a question shape even when every token is frame, and the
route decides when the utterance fills nothing she asked about. A frame match is
a hint, not a decision. roleAside is new: a note or fact stated mid-flow is
stored and the question comes back on the same reply.
router.NamesAnHour is the single gate on the reminder time slot, so a sentence
naming no hour never fills it. IsClockEcho is deleted; it could not catch на
завтра on the stub, which returns midnight rather than the clock. на joins в and
во as a frame around a spoken hour.
The owner's rule, ruled on 2026-08-06: a reminder commits only when what, what
time and what day are all answered, and every time question opens by stating the
clock. He confirmed both derived cases himself, so завтра в 15:00 and через час
commit with no question.
A global assertion in checkEnd now fails any trace whose reminder fires at the
current clock.
(V-577) (V-579)
610 lines
27 KiB
Go
610 lines
27 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"log"
|
||
"math/rand"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
"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.
|
||
//
|
||
// 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 {
|
||
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).
|
||
func (h *reactiveHandler) questionFor(slot dialogue.Slot, attempt int, utterance string, s dialogue.Slots) (string, bool) {
|
||
if slot != dialogue.SlotTime {
|
||
return clarifyQuestionFor(slot, attempt)
|
||
}
|
||
return whenQuestion(whenGapOf(utterance, s.HasTime), attempt, h.now())
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
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)
|
||
// 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.
|
||
if asksAboutTime(q.Missing) {
|
||
q.WhenText = strings.TrimSpace(q.WhenText + " " + text)
|
||
if t, ok := h.readWhen(ctx, intent, q, text); ok {
|
||
merged.Time, merged.HasTime = t, true
|
||
}
|
||
}
|
||
if stillOpen(q.Missing, whenTextOf(q), merged) {
|
||
return h.reaskOrGiveUp(ctx, q, merged, text), 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.
|
||
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
|
||
// 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
|
||
}
|
||
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".
|
||
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
||
question := ""
|
||
if len(q.Missing) > 0 {
|
||
question, _ = h.questionFor(q.Missing[0], q.Attempts+1, whenTextOf(q), merged)
|
||
}
|
||
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(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,
|
||
})
|
||
}
|