From cfd38d53cfc05eae79624035d987efc182d76e14 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 02:26:11 +0400 Subject: [PATCH] Ask the question, then act on the answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- cmd/mavend/clarify.go | 114 ++++++++++++++++++++++++++++++++++++++++++ cmd/mavend/voice.go | 96 ++++++++++++++++------------------- 2 files changed, 157 insertions(+), 53 deletions(-) diff --git a/cmd/mavend/clarify.go b/cmd/mavend/clarify.go index eff681f..4d4a3f1 100644 --- a/cmd/mavend/clarify.go +++ b/cmd/mavend/clarify.go @@ -1,6 +1,8 @@ package main import ( + "context" + "log" "time" "github.com/kami/maven/internal/dialogue" @@ -64,3 +66,115 @@ func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) { } 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, + }) +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 9bbd226..8e59a86 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -226,6 +226,8 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem // ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) ----- dialogueSessions := dialogue.NewSessionStore(2 * time.Minute) + clarifyStore := dialogue.NewClarifyStore(clarifyTTL) + timeParser := router.NewPythonDateParser() // ----- replier (LLM-backed when the engine is on, Stub floor otherwise) ----- replier := voice.Replier(voice.NewStubReplier()) @@ -250,8 +252,10 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem memStore: memStore, dataStore: dataStore, dialogueSessions: dialogueSessions, + clarifyStore: clarifyStore, + extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}}, queryMinScore: cfg.Voice.QueryMinScore, - timeParser: router.NewPythonDateParser(), + timeParser: timeParser, ecosystem: eco, } @@ -304,6 +308,14 @@ type reactiveHandler struct { // box → one session slot, keyed voiceDialogueID). nil ⇒ no carry-over. dialogueSessions *dialogue.SessionStore + // clarifyStore parks the request behind an open question she asked (see + // clarify.go). nil ⇒ she falls back to the canned "не поняла" reply. + clarifyStore *dialogue.ClarifyStore + + // extractor parses the answer to an open question, with the same parsers + // the router's own stage-2 uses. + extractor router.Extractor + // pending destructive-act confirmation. A destructive act replies with a // "выполнить X? да/нет" prompt and parks here; the NEXT utterance is read as // the y/n answer. ponytail: single slot, single-user box — a second act @@ -378,6 +390,13 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo return h.reply(ctx, reply, nil) } + // 1b2. clarify answer — if she asked a question last turn, this utterance is + // its answer, not a fresh command. After the confirm check: a y/n gate is + // armed by her own prompt and is the narrower claim on the utterance. + if reply, handled := h.resolveClarifyAnswer(ctx, text); handled { + return h.reply(ctx, reply, nil) + } + // 1c. quiet-hours toggle — keyword match, not classifier-dependent. // "тихий режим" / "quiet on" would route through the classifier // unreliably (it's a command, not a free-form query), so we match it @@ -407,34 +426,16 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo prev := h.dialogueSessions.Get(voiceDialogueID, now) dec = followUpMerge(prev, dec, now) if !dec.Clarify { - // Build history: carry over up to 4 prior turns for cross-intent - // reference. The most recent prior turn is prepended to history. - var history []dialogue.Turn - if prev != nil { - history = append(history, dialogue.Turn{ - Intent: prev.Intent, - Slots: prev.Slots, - Text: prev.Slots.Text, // the prior turn's utterance - }) - // Cap history depth so one long conversation can't grow - // the session unboundedly. - maxHist := len(prev.History) - if maxHist > 3 { - maxHist = 3 - } - history = append(history, prev.History[:maxHist]...) - } - ttl := time.Duration(0) // use 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, - }) + h.rememberTurn(prev, dec, now) + } + } + + // 2c. clarify — she is not sure. If one named thing is missing, ask about it + // and park the request (clarify.go); otherwise the replier's canned reply + // stands. + if dec.Clarify { + if question, asked := h.askClarify(dec); asked { + return h.reply(ctx, question, nil) } } @@ -465,6 +466,11 @@ func (h *reactiveHandler) handleText(ctx context.Context, text string) string { return reply } + // 1b2. clarify answer — same check as HandlePushToTalk. + if reply, handled := h.resolveClarifyAnswer(ctx, text); handled { + return reply + } + // 2. router — classify the utterance. dec, err := h.router.Route(ctx, text, h.now()) if err != nil { @@ -482,30 +488,14 @@ func (h *reactiveHandler) handleText(ctx context.Context, text string) string { prev := h.dialogueSessions.Get(voiceDialogueID, now) dec = followUpMerge(prev, dec, now) if !dec.Clarify { - 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) - if dec.Intent == router.IntentChat { - ttl = 15 * time.Minute - } - h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{ - Intent: dialogue.Intent(dec.Intent), - Slots: toDialogueSlots(dec.Slots), - Timestamp: now, - TTL: ttl, - History: history, - }) + h.rememberTurn(prev, dec, now) + } + } + + // 2c. clarify — same as HandlePushToTalk: ask about the one missing thing. + if dec.Clarify { + if question, asked := h.askClarify(dec); asked { + return question } }