Files
Maven/cmd/mavend/clarify.go
T
kami fe0e654ab1 Ask the question, then act on the answer
On a clarify decision with one identifiable gap she now asks instead of saying
"не поняла", and parks the request. The next utterance is parsed as the answer
with the router's own extractor and the completed decision runs through
applyAction like any other — so a clarified act still needs the allowlist and
still hits the destructive confirm gate. An answer that does not fill the gap
drops the request; she never asks twice. Also pulls the session-store block
that HandlePushToTalk and handleText both had into rememberTurn, since the
clarify path needed a third copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 02:31:07 +04:00

181 lines
6.7 KiB
Go

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,
})
}