package main import ( "context" "log" "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; the rest are only used to decide act-vs-drop. // // 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. var wantedSlots = map[router.Intent][]dialogue.Slot{ router.IntentReminder: {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.SlotKey: "Что записать?", dialogue.SlotFn: "Что сделать?", } // clarifyDropped — she asked once, the answer still did not fill the gap, so // the request is gone. Said plainly, once, with no second question. const clarifyDropped = "Не разобрала — скажи целиком, пожалуйста." // 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 and lets the rest go. Two questions in a row is an interrogation. 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, // asked once; MaxAttempts is 1, so there is no second ask }) 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 the request // is dropped: she does not ask again. 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 } // One shot either way: the question is consumed whether or not the answer // works, so a failed answer can't leave the question armed. h.clarifyStore.Delete(voiceDialogueID) intent := router.Intent(q.Intent) answer := h.extractor.Extract(ctx, intent, text, h.now()) merged := q.Answer(text, toDialogueSlots(answer)) if len(dialogue.StillMissing(q.Missing, merged)) > 0 { log.Printf("voice: clarify — answer %q did not fill %v, dropping", text, q.Missing) return clarifyDropped, 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 } // 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) } 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, }) }