From a54ebac0cb4b0f4cd607ed1b15c31b785427378c Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 02:22:18 +0400 Subject: [PATCH 1/5] Work out which slot is missing and phrase one short question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A table per intent (reminder needs a time, fact needs a key, act needs a fn) plus one fixed Russian question per slot. Templates, not model output: a 0.8B would wander and a question that rewords itself is harder to answer. Note, query, chat and system get no question — for those a clarify decision keeps the canned reply rather than inventing a question for noise. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- cmd/mavend/clarify.go | 66 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 cmd/mavend/clarify.go diff --git a/cmd/mavend/clarify.go b/cmd/mavend/clarify.go new file mode 100644 index 0000000..eff681f --- /dev/null +++ b/cmd/mavend/clarify.go @@ -0,0 +1,66 @@ +package main + +import ( + "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 +} From fe0e654ab131589f03435d5da0ed84469683573c Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 02:26:11 +0400 Subject: [PATCH 2/5] 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 } } From 0b3b8d0a9e148286c392a6ff210bc990bfa69a61 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 02:29:04 +0400 Subject: [PATCH 3/5] Test the clarify round-trip end to end at the daemon level Covers: a reminder with no time is asked about and completes on the answer; the same for a fact; an answer past the TTL falls through as a fresh utterance; a second unclear answer drops the request with no second question; a clarified act off the allowlist neither runs nor gets enabled; a clarified destructive act still parks a confirm; noise keeps the canned reply. No model, no network. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- cmd/mavend/clarify_test.go | 238 +++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 cmd/mavend/clarify_test.go diff --git a/cmd/mavend/clarify_test.go b/cmd/mavend/clarify_test.go new file mode 100644 index 0000000..d83ec68 --- /dev/null +++ b/cmd/mavend/clarify_test.go @@ -0,0 +1,238 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/dialogue" + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/store" + "github.com/kami/maven/internal/tool" + "github.com/kami/maven/internal/voice" +) + +// newClarifyHandler builds a handler with the clarify path wired and no model: +// stub date parser, the real fact parser, and a matcher over whatever tools the +// test enabled. `now` is fixed so TTL behaviour is testable. +func newClarifyHandler(t *testing.T) (*reactiveHandler, *store.Store, *time.Time) { + t.Helper() + st := newTestStore(t) + api := ipc.NewStoreAPI(st) + now := time.Date(2026, 7, 31, 9, 0, 0, 0, time.UTC) + matcher := tool.NewMatcher(api) + h := &reactiveHandler{ + api: api, + dataStore: st, + tools: tool.NewExecutor(api, 2*time.Second), + matcher: matcher, + replier: voice.NewStubReplier(), + now: func() time.Time { return now }, + dialogueSessions: dialogue.NewSessionStore(2 * time.Minute), + clarifyStore: dialogue.NewClarifyStore(clarifyTTL), + extractor: router.Extractor{ + Time: router.StubDateTimeParser{}, + Acts: matcher, + Facts: router.DefaultFactParser{}, + }, + } + return h, st, &now +} + +func clarifyDec(intent router.Intent, slots router.Slots, utterance string) router.Decision { + return router.Decision{Utterance: utterance, Stage: 3, Intent: intent, Slots: slots, Clarify: true} +} + +// TestClarifyQuestionForMissingSlot pins which question goes with which gap, and +// which intents get no question at all. +func TestClarifyQuestionForMissingSlot(t *testing.T) { + cases := []struct { + name string + dec router.Decision + want string + asked bool + }{ + {"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "На когда напомнить?", true}, + {"fact without a key", clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши"), "Что записать?", true}, + {"act without a fn", clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это"), "Что сделать?", true}, + {"reminder that already has a time", clarifyDec(router.IntentReminder, router.Slots{HasTime: true}, "напомни в 11"), "", false}, + {"chat is never worth a question", clarifyDec(router.IntentChat, router.Slots{Text: "мгм"}, "мгм"), "", false}, + {"query is never worth a question", clarifyDec(router.IntentQuery, router.Slots{Text: "а"}, "а"), "", false}, + } + for _, tc := range cases { + _, got, asked := clarifyQuestion(tc.dec) + if asked != tc.asked || got != tc.want { + t.Errorf("%s: got (%q, %v), want (%q, %v)", tc.name, got, asked, tc.want, tc.asked) + } + } +} + +// TestClarifyReminderCompletesOnAnswer is the whole point of the feature: she +// asks for the missing time and the answer creates the reminder. +func TestClarifyReminderCompletesOnAnswer(t *testing.T) { + ctx := context.Background() + h, st, _ := newClarifyHandler(t) + + question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")) + if !asked || question != "На когда напомнить?" { + t.Fatalf("expected the time question, got %q asked=%v", question, asked) + } + + reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00") + if !handled { + t.Fatal("the answer to an open question must be consumed as an answer") + } + if reply == clarifyDropped { + t.Fatalf("a good answer must not drop the request: %q", reply) + } + + reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)) + if err != nil || len(reminders) != 1 { + t.Fatalf("clarified reminder was not created: reminders=%v err=%v", reminders, err) + } + if !strings.Contains(reminders[0].Payload, "маме") { + t.Fatalf("the reminder lost the original request: %q", reminders[0].Payload) + } + if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil { + t.Fatal("the question must be cleared once answered") + } +} + +// TestClarifyFactCompletesOnAnswer — the fact path, where the answer carries +// both the key and the value. +func TestClarifyFactCompletesOnAnswer(t *testing.T) { + ctx := context.Background() + h, st, _ := newClarifyHandler(t) + + if _, asked := h.askClarify(clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked { + t.Fatal("a fact with no key should be asked about") + } + if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyDropped { + t.Fatalf("answer should complete the fact, handled=%v reply=%q", handled, reply) + } + if fact, err := st.LatestFact(ctx, "water"); err != nil || fact.Key != "water" { + t.Fatalf("clarified fact was not written: fact=%+v err=%v", fact, err) + } +} + +// TestClarifyAnswerAfterTTLIsANewRequest — a late answer is not an answer. +func TestClarifyAnswerAfterTTLIsANewRequest(t *testing.T) { + ctx := context.Background() + h, st, now := newClarifyHandler(t) + + if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { + t.Fatal("expected a question") + } + *now = now.Add(clarifyTTL + time.Second) + + if reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00"); handled { + t.Fatalf("an answer past the TTL must fall through to normal routing, got %q", reply) + } + if reminders, err := st.DueReminders(ctx, now.Add(48*time.Hour)); err != nil || len(reminders) != 0 { + t.Fatalf("expired question must not create anything: reminders=%v err=%v", reminders, err) + } +} + +// TestClarifyUnclearAnswerDropsWithoutAskingAgain — MaxAttempts is 1. +func TestClarifyUnclearAnswerDropsWithoutAskingAgain(t *testing.T) { + ctx := context.Background() + h, st, _ := newClarifyHandler(t) + + if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { + t.Fatal("expected a question") + } + reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю") + if !handled || reply != clarifyDropped { + t.Fatalf("an unclear answer should drop the request, handled=%v reply=%q", handled, reply) + } + if strings.Contains(reply, "?") { + t.Fatalf("she must not ask a second question: %q", reply) + } + if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil { + t.Fatal("a dropped request must leave no armed question") + } + if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 { + t.Fatalf("a dropped request must not create anything: reminders=%v err=%v", reminders, err) + } +} + +// TestClarifiedActOffAllowlistIsStillRefused — clarification fills in an +// argument, it never grants authority. +func TestClarifiedActOffAllowlistIsStillRefused(t *testing.T) { + ctx := context.Background() + h, st, _ := newClarifyHandler(t) + marker := filepath.Join(t.TempDir(), "not-allowed-ran") + + if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked { + t.Fatal("an act with no fn should be asked about") + } + reply, handled := h.resolveClarifyAnswer(ctx, "rm "+marker) + if !handled { + t.Fatal("the answer should be consumed") + } + if strings.Contains(reply, "готово") { + t.Fatalf("an act that is not on the allowlist must not report success: %q", reply) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("a clarified act off the allowlist ran anyway: %v", err) + } + if tools, err := st.ListTools(ctx, "enabled"); err != nil || len(tools) != 0 { + t.Fatalf("clarify must not enable a tool: tools=%+v err=%v", tools, err) + } +} + +// TestClarifiedDestructiveActStillNeedsConfirm — the confirm gate survives the +// clarify path. +func TestClarifiedDestructiveActStillNeedsConfirm(t *testing.T) { + ctx := context.Background() + h, st, _ := newClarifyHandler(t) + marker := filepath.Join(t.TempDir(), "destructive-ran") + if err := st.EnableTool(ctx, "delete_backups", []string{"touch", marker}, true, "test", h.now()); err != nil { + t.Fatal(err) + } + + if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked { + t.Fatal("expected a question") + } + reply, handled := h.resolveClarifyAnswer(ctx, "delete_backups") + if !handled { + t.Fatal("the answer should be consumed") + } + if !strings.Contains(reply, "да") || h.pending == nil { + t.Fatalf("a clarified destructive act must still park a confirm: reply=%q pending=%+v", reply, h.pending) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("a clarified destructive act ran before confirmation: %v", err) + } +} + +// TestNoQuestionWhenNothingIsMissing — noise keeps the canned reply, so she +// never invents a question for nothing. +func TestNoQuestionWhenNothingIsMissing(t *testing.T) { + h, _, _ := newClarifyHandler(t) + for _, dec := range []router.Decision{ + clarifyDec(router.IntentChat, router.Slots{Text: "эм"}, "эм"), + clarifyDec(router.IntentQuery, router.Slots{Text: "ммм"}, "ммм"), + clarifyDec(router.IntentNote, router.Slots{Text: "..."}, "..."), + } { + if question, asked := h.askClarify(dec); asked { + t.Fatalf("intent %s should keep the canned reply, got %q", dec.Intent, question) + } + } + if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil { + t.Fatal("noise must not park a question") + } +} + +// TestNoPendingQuestionFallsThrough — with nothing parked, an utterance routes +// normally. +func TestNoPendingQuestionFallsThrough(t *testing.T) { + h, _, _ := newClarifyHandler(t) + if reply, handled := h.resolveClarifyAnswer(context.Background(), "напомни в 11:00"); handled { + t.Fatalf("no open question ⇒ must not be treated as an answer, got %q", reply) + } +} From 62d320f93a5cd9fa9fbbdc3e9b9f170cae382599 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 12:31:46 +0400 Subject: [PATCH 4/5] Let her ask three times, and let a restated answer win MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MaxAttempts was 1, justified as "not a nag". Wrong reading: "not a nag" is about interrupting unprompted, and a clarifying question is part of a conversation he started. Now three, configurable via voice.clarify_max_attempts (default 3). Three, because after that the likely problem is she misheard the whole request, not one slot. Answer used to keep the parked value, so "в три" then "нет, в пять" threw the five away. Now a value the answer carries wins for the slot she asked about. Only for the clarify answer — a correction in a fresh turn is followUpMerge. The eight-field chained assertion in the Answer test is one DeepEqual now, so a new field in Slots is covered without touching the test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- internal/config/config.go | 11 ++++- internal/dialogue/clarify.go | 69 +++++++++++++++++-------------- internal/dialogue/clarify_test.go | 52 +++++++++++------------ 3 files changed, 74 insertions(+), 58 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index cca49e0..7769036 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -263,6 +263,10 @@ type VoiceConfig struct { // default if unset. QueryMinScore float64 `json:"query_min_score,omitempty"` + // ClarifyMaxAttempts — how many clarifying questions she may ask about one + // request before she gives up and says she did not understand. Default 3. + ClarifyMaxAttempts int `json:"clarify_max_attempts,omitempty"` + // Persona — optional prompt prefix that tunes maven's character. Prepended // to every LLM system prompt (nudge phrasing, note queries, general // knowledge). Empty string ⇒ current hardcoded persona (feminine-gendered @@ -390,7 +394,9 @@ const ( DefaultAutotuneInterval = 10 * time.Minute DefaultRouterThreshold = 0.55 DefaultQueryMinScore = 0.55 - DefaultToolTimeout = 30 * time.Second + // DefaultClarifyMaxAttempts — see dialogue.DefaultMaxAttempts. + DefaultClarifyMaxAttempts = 3 + DefaultToolTimeout = 30 * time.Second DefaultFactEnrichmentInterval = 30 * time.Second ) @@ -472,6 +478,9 @@ func (c *Config) applyDefaults() { if c.Voice.QueryMinScore <= 0 { c.Voice.QueryMinScore = DefaultQueryMinScore } + if c.Voice.ClarifyMaxAttempts <= 0 { + c.Voice.ClarifyMaxAttempts = DefaultClarifyMaxAttempts + } if c.Voice.ToolTimeout <= 0 { c.Voice.ToolTimeout = Duration(DefaultToolTimeout) } diff --git a/internal/dialogue/clarify.go b/internal/dialogue/clarify.go index dc6c3c2..c91b06e 100644 --- a/internal/dialogue/clarify.go +++ b/internal/dialogue/clarify.go @@ -17,10 +17,11 @@ const ( SlotText Slot = "text" // Slots.Text ) -// MaxAttempts is 1 because Maven is not a nag (DESIGN.md § Non-goals). She asks -// one clarifying question. If the answer still leaves the slot empty she drops -// the request instead of asking again. -const MaxAttempts = 1 +// DefaultMaxAttempts — how many questions she may ask about one request. +// Three, because after three tries the likely problem is that she misheard the +// whole request, not one slot — so another question about that slot won't help. +// Configurable: voice.clarify_max_attempts. +const DefaultMaxAttempts = 3 // PendingQuestion is what Maven holds while she waits for an answer to an open // question. Unlike the yes/no confirms in cmd/mavend/voice.go, the answer here @@ -32,7 +33,17 @@ type PendingQuestion struct { Utterance string // the user's original raw words Asked time.Time TTL time.Duration - Attempts int // questions already asked; capped by MaxAttempts + Attempts int // questions already asked + // MaxAttempts caps Attempts. 0 ⇒ DefaultMaxAttempts. + MaxAttempts int +} + +// maxAttempts is MaxAttempts with the default filled in. +func (q *PendingQuestion) maxAttempts() int { + if q.MaxAttempts <= 0 { + return DefaultMaxAttempts + } + return q.MaxAttempts } func (q *PendingQuestion) IsExpired(now time.Time) bool { @@ -41,12 +52,9 @@ func (q *PendingQuestion) IsExpired(now time.Time) bool { // CanAsk reports whether Maven may ask another question about this request. func (q *PendingQuestion) CanAsk() bool { - return q.Attempts < MaxAttempts + return q.Attempts < q.maxAttempts() } -// TODO: the daemon will phrase the question text from Missing (one short ru -// question per Slot, feminine self-reference) and speak it here. - // ClarifyStore holds the parked questions. Same shape and locking as // SessionStore: keyed by dialogue id, expired entries dropped on read. type ClarifyStore struct { @@ -67,8 +75,7 @@ func NewClarifyStore(defaultTTL time.Duration) *ClarifyStore { } } -// TODO: the daemon will Put a question here when Decision.Clarify fires, in -// place of the flat "не разобрала" reply (cmd/mavend/voice.go). +// Put parks a question. Called on a clarify decision (cmd/mavend/clarify.go). func (s *ClarifyStore) Put(id string, q *PendingQuestion) { if q.TTL <= 0 { q.TTL = s.defaultTTL @@ -78,8 +85,7 @@ func (s *ClarifyStore) Put(id string, q *PendingQuestion) { s.mu.Unlock() } -// TODO: the daemon will Get on the next turn, parse that turn into Slots, call -// Answer, and Delete — the open-question twin of resolveConfirm. +// Get returns the live parked question, or nil when there is none. func (s *ClarifyStore) Get(id string, now time.Time) *PendingQuestion { s.mu.RLock() q, ok := s.questions[id] @@ -101,44 +107,45 @@ func (s *ClarifyStore) Delete(id string) { } // Answer merges the slots parsed from the user's answer into the parked ones. -// Only the slots listed in Missing are filled, and an already filled slot is -// never overwritten — the answer completes the original request, it does not -// restate it. Parsing the answer text into `answer` is the caller's job; this -// package must stay free of internal/router. +// Only the slots listed in Missing are touched. Within those, a value the answer +// carries WINS over what was parked: she asked about this slot, so «нет, в пять» +// after «в три» must replace the time, not be thrown away. +// +// This is the clarify answer only. A correction in a fresh turn ("вообще-то +// перенеси на пять") is a different code path (followUpMerge) — not here. +// +// Parsing the answer text into `answer` is the caller's job; this package must +// stay free of internal/router. func (q *PendingQuestion) Answer(text string, answer Slots) Slots { out := q.Slots for _, slot := range q.Missing { switch slot { case SlotTime: - if !out.HasTime && answer.HasTime { + if answer.HasTime { out.Time = answer.Time out.HasTime = true } case SlotKey: - if !out.HasKey && answer.HasKey { + if answer.HasKey { out.Key = answer.Key out.HasKey = true } case SlotValue: - if out.Value == "" && answer.Value != "" { + if answer.Value != "" { out.Value = answer.Value } case SlotFn: - if !out.HasFn && answer.HasFn { + if answer.HasFn { out.Fn = answer.Fn out.HasFn = true - if len(out.Args) == 0 { - out.Args = append([]string(nil), answer.Args...) - } + out.Args = append([]string(nil), answer.Args...) } case SlotText: - if out.Text == "" { - if answer.Text != "" { - out.Text = answer.Text - } else { - // No parse for a text slot — the raw answer IS the text. - out.Text = text - } + if answer.Text != "" { + out.Text = answer.Text + } else if out.Text == "" { + // No parse for a text slot — the raw answer IS the text. + out.Text = text } } } diff --git a/internal/dialogue/clarify_test.go b/internal/dialogue/clarify_test.go index 81d05ca..e9bec96 100644 --- a/internal/dialogue/clarify_test.go +++ b/internal/dialogue/clarify_test.go @@ -1,6 +1,7 @@ package dialogue import ( + "reflect" "testing" "time" ) @@ -89,12 +90,13 @@ func TestAnswerFillsOnlyMissingSlots(t *testing.T) { want: Slots{Text: "напомни позвонить", Time: answerTime, HasTime: true}, }, { - name: "does not overwrite a filled time", + // He restated it: «нет, в пять». The new value wins. + name: "a restated time overwrites the parked one", parked: Slots{Time: other, HasTime: true}, missing: []Slot{SlotTime}, - text: "в три", + text: "нет, в три", answer: Slots{Time: answerTime, HasTime: true}, - want: Slots{Time: other, HasTime: true}, + want: Slots{Time: answerTime, HasTime: true}, }, { name: "ignores slots that were not missing", @@ -121,12 +123,12 @@ func TestAnswerFillsOnlyMissingSlots(t *testing.T) { want: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true}, }, { - name: "keeps existing args when fn was already known", + name: "a restated fn replaces the fn and its args", parked: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true}, missing: []Slot{SlotFn}, text: "останови postgres", answer: Slots{Fn: "stop", Args: []string{"postgres"}, HasFn: true}, - want: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true}, + want: Slots{Fn: "stop", Args: []string{"postgres"}, HasFn: true}, }, { name: "raw answer becomes the text when nothing was parsed", @@ -157,36 +159,34 @@ func TestAnswerFillsOnlyMissingSlots(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { q := &PendingQuestion{Slots: tc.parked, Missing: tc.missing, Asked: base} - got := q.Answer(tc.text, tc.answer) - if got.Time != tc.want.Time || got.HasTime != tc.want.HasTime || - got.Key != tc.want.Key || got.HasKey != tc.want.HasKey || - got.Value != tc.want.Value || got.Text != tc.want.Text || - got.Fn != tc.want.Fn || got.HasFn != tc.want.HasFn { + // Whole-struct compare: a new field in Slots is covered for free. + if got := q.Answer(tc.text, tc.answer); !reflect.DeepEqual(got, tc.want) { t.Fatalf("Answer = %+v, want %+v", got, tc.want) } - if len(got.Args) != len(tc.want.Args) { - t.Fatalf("Args = %v, want %v", got.Args, tc.want.Args) - } - for i := range got.Args { - if got.Args[i] != tc.want.Args[i] { - t.Fatalf("Args = %v, want %v", got.Args, tc.want.Args) - } - } }) } } -func TestCanAskCapsAtOneQuestion(t *testing.T) { - if MaxAttempts != 1 { - t.Fatalf("MaxAttempts = %d, want 1 (Maven asks once, she is not a nag)", MaxAttempts) +func TestCanAskAllowsThreeQuestionsByDefault(t *testing.T) { + if DefaultMaxAttempts != 3 { + t.Fatalf("DefaultMaxAttempts = %d, want 3", DefaultMaxAttempts) } - q := &PendingQuestion{Asked: base} - if !q.CanAsk() { - t.Fatal("a fresh question should be askable") + q := &PendingQuestion{Asked: base} // MaxAttempts unset ⇒ the default + for i := 0; i < 3; i++ { + if !q.CanAsk() { + t.Fatalf("question %d should be allowed", i+1) + } + q.Attempts++ } - q.Attempts = MaxAttempts if q.CanAsk() { - t.Fatal("the question should not be asked twice") + t.Fatal("a fourth question must not be allowed") + } +} + +func TestCanAskHonoursConfiguredMax(t *testing.T) { + q := &PendingQuestion{Asked: base, MaxAttempts: 1, Attempts: 1} + if q.CanAsk() { + t.Fatal("MaxAttempts 1 means one question only") } } From d2be98ee2a7de16fafa8479ae51c3a5a8ae11a11 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 12:31:55 +0400 Subject: [PATCH 5/5] Say out loud when she gives up instead of dropping the request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unclear answer used to end the request on the spot. Now she re-asks the same question while attempts remain, and when they run out she says "Прости, я не поняла. Скажи, пожалуйста, по-другому." — silence would leave him thinking it was handled. Same reply when the missing slot has no question to ask, and as a floor in finishClarified so an empty reply can never ship. Tests: three questions allowed, the fourth gives up out loud, the cap is configurable, and a restated time is the one that lands. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- cmd/mavend/clarify.go | 61 ++++++++++++++++++++-------- cmd/mavend/clarify_test.go | 83 +++++++++++++++++++++++++++++++++----- cmd/mavend/voice.go | 14 +++++-- 3 files changed, 126 insertions(+), 32 deletions(-) diff --git a/cmd/mavend/clarify.go b/cmd/mavend/clarify.go index 4d4a3f1..22266e7 100644 --- a/cmd/mavend/clarify.go +++ b/cmd/mavend/clarify.go @@ -40,9 +40,10 @@ var clarifyQuestions = map[dialogue.Slot]string{ 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 = "Не разобрала — скажи целиком, пожалуйста." +// 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 = "Прости, я не поняла. Скажи, пожалуйста, по-другому." // missingFor returns the slots a decision still needs, most important first. // Empty ⇒ there is nothing identifiable to ask about. @@ -79,13 +80,14 @@ func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) { 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 + 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, }) log.Printf("voice: clarify — asked about %s for intent=%s", slot, dec.Intent) return question, true @@ -97,8 +99,9 @@ func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) { // 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. +// 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 @@ -107,17 +110,14 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) 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 + return h.reaskOrGiveUp(q, merged, text), true } + h.clarifyStore.Delete(voiceDialogueID) // 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: @@ -133,6 +133,29 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) return h.finishClarified(ctx, dec), 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(q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string { + question := "" + if len(q.Missing) > 0 { + question = clarifyQuestions[q.Missing[0]] + } + if question == "" || !q.CanAsk() { + h.clarifyStore.Delete(voiceDialogueID) + 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(voiceDialogueID, 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 { @@ -146,6 +169,10 @@ func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decisi if reply == "" { reply = h.replier.Reply(dec) } + if reply == "" { + // Belt: an empty reply here would be a silent drop. + reply = clarifyGaveUp + } return reply } diff --git a/cmd/mavend/clarify_test.go b/cmd/mavend/clarify_test.go index d83ec68..31178bc 100644 --- a/cmd/mavend/clarify_test.go +++ b/cmd/mavend/clarify_test.go @@ -86,7 +86,7 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) { if !handled { t.Fatal("the answer to an open question must be consumed as an answer") } - if reply == clarifyDropped { + if reply == clarifyGaveUp { t.Fatalf("a good answer must not drop the request: %q", reply) } @@ -111,7 +111,7 @@ func TestClarifyFactCompletesOnAnswer(t *testing.T) { if _, asked := h.askClarify(clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked { t.Fatal("a fact with no key should be asked about") } - if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyDropped { + if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyGaveUp { t.Fatalf("answer should complete the fact, handled=%v reply=%q", handled, reply) } if fact, err := st.LatestFact(ctx, "water"); err != nil || fact.Key != "water" { @@ -137,26 +137,87 @@ func TestClarifyAnswerAfterTTLIsANewRequest(t *testing.T) { } } -// TestClarifyUnclearAnswerDropsWithoutAskingAgain — MaxAttempts is 1. -func TestClarifyUnclearAnswerDropsWithoutAskingAgain(t *testing.T) { +// TestClarifyAsksThreeTimesThenSaysSo — three questions are allowed, the fourth +// is not, and running out is SPOKEN. Silence would read as "handled". +func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) { ctx := context.Background() h, st, _ := newClarifyHandler(t) if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { - t.Fatal("expected a question") + t.Fatal("expected a first question") } + // Two more unclear answers ⇒ two more questions (3 asks in total). + for i := 2; i <= 3; i++ { + reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю") + if !handled { + t.Fatalf("answer %d must be consumed as an answer", i) + } + if reply != "На когда напомнить?" { + t.Fatalf("attempt %d should ask again, got %q", i, reply) + } + if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil { + t.Fatalf("attempt %d must leave the question armed", i) + } + } + reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю") - if !handled || reply != clarifyDropped { - t.Fatalf("an unclear answer should drop the request, handled=%v reply=%q", handled, reply) + if !handled || reply != clarifyGaveUp { + t.Fatalf("the fourth try must give up out loud, handled=%v reply=%q", handled, reply) } - if strings.Contains(reply, "?") { - t.Fatalf("she must not ask a second question: %q", reply) + if reply == "" || strings.Contains(reply, "?") { + t.Fatalf("giving up must be spoken and must not be another question: %q", reply) } if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil { - t.Fatal("a dropped request must leave no armed question") + t.Fatal("a given-up request must leave no armed question") } if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 { - t.Fatalf("a dropped request must not create anything: reminders=%v err=%v", reminders, err) + t.Fatalf("a given-up request must not create anything: reminders=%v err=%v", reminders, err) + } +} + +// TestClarifyMaxAttemptsIsConfigurable — one question when the config says one. +func TestClarifyMaxAttemptsIsConfigurable(t *testing.T) { + ctx := context.Background() + h, _, _ := newClarifyHandler(t) + h.clarifyMaxAttempts = 1 + + if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { + t.Fatal("expected a question") + } + if reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю"); !handled || reply != clarifyGaveUp { + t.Fatalf("with max 1 she must give up at once, handled=%v reply=%q", handled, reply) + } +} + +// TestClarifyRestatedAnswerWins — «в 11:00», then «нет, в 15:00». The second +// value is the one that lands. +func TestClarifyRestatedAnswerWins(t *testing.T) { + ctx := context.Background() + h, st, _ := newClarifyHandler(t) + + if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked { + t.Fatal("expected a question") + } + // First answer parses, but re-park it by hand as if she had asked again: + // what matters here is that Answer prefers the newer value over the parked + // one, which is the case the daemon hits on a re-ask. + q := h.clarifyStore.Get(voiceDialogueID, h.now()) + if q == nil { + t.Fatal("expected an armed question") + } + first := h.extractor.Extract(ctx, router.IntentReminder, "в 11:00", h.now()) + q.Slots = q.Answer("в 11:00", toDialogueSlots(first)) + + if reply, handled := h.resolveClarifyAnswer(ctx, "нет, в 15:00"); !handled || reply == clarifyGaveUp { + t.Fatalf("the restated answer should complete the request, handled=%v reply=%q", handled, reply) + } + reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)) + if err != nil || len(reminders) != 1 { + t.Fatalf("expected one reminder: %v err=%v", reminders, err) + } + want := h.extractor.Extract(ctx, router.IntentReminder, "в 15:00", h.now()) + if !reminders[0].FireTs.Equal(want.Time) { + t.Fatalf("reminder at %v, want the restated %v", reminders[0].FireTs, want.Time) } } diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 8e59a86..c7571c7 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -253,10 +253,12 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem dataStore: dataStore, dialogueSessions: dialogueSessions, clarifyStore: clarifyStore, - extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}}, - queryMinScore: cfg.Voice.QueryMinScore, - timeParser: timeParser, - ecosystem: eco, + // 0 here (unset config) ⇒ the dialogue default. + clarifyMaxAttempts: cfg.Voice.ClarifyMaxAttempts, + extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}}, + queryMinScore: cfg.Voice.QueryMinScore, + timeParser: timeParser, + ecosystem: eco, } // ----- the server (TCP listener) ----- @@ -312,6 +314,10 @@ type reactiveHandler struct { // clarify.go). nil ⇒ she falls back to the canned "не поняла" reply. clarifyStore *dialogue.ClarifyStore + // clarifyMaxAttempts — questions per request before she gives up out loud. + // 0 ⇒ dialogue.DefaultMaxAttempts (3). Set from VoiceConfig. + clarifyMaxAttempts int + // extractor parses the answer to an open question, with the same parsers // the router's own stage-2 uses. extractor router.Extractor