Let her ask three times, and let a restated answer win

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
This commit is contained in:
kami
2026-07-31 12:31:46 +04:00
parent 0b3b8d0a9e
commit 62d320f93a
3 changed files with 74 additions and 58 deletions
+10 -1
View File
@@ -263,6 +263,10 @@ type VoiceConfig struct {
// default if unset. // default if unset.
QueryMinScore float64 `json:"query_min_score,omitempty"` 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 // Persona — optional prompt prefix that tunes maven's character. Prepended
// to every LLM system prompt (nudge phrasing, note queries, general // to every LLM system prompt (nudge phrasing, note queries, general
// knowledge). Empty string ⇒ current hardcoded persona (feminine-gendered // knowledge). Empty string ⇒ current hardcoded persona (feminine-gendered
@@ -390,7 +394,9 @@ const (
DefaultAutotuneInterval = 10 * time.Minute DefaultAutotuneInterval = 10 * time.Minute
DefaultRouterThreshold = 0.55 DefaultRouterThreshold = 0.55
DefaultQueryMinScore = 0.55 DefaultQueryMinScore = 0.55
DefaultToolTimeout = 30 * time.Second // DefaultClarifyMaxAttempts — see dialogue.DefaultMaxAttempts.
DefaultClarifyMaxAttempts = 3
DefaultToolTimeout = 30 * time.Second
DefaultFactEnrichmentInterval = 30 * time.Second DefaultFactEnrichmentInterval = 30 * time.Second
) )
@@ -472,6 +478,9 @@ func (c *Config) applyDefaults() {
if c.Voice.QueryMinScore <= 0 { if c.Voice.QueryMinScore <= 0 {
c.Voice.QueryMinScore = DefaultQueryMinScore c.Voice.QueryMinScore = DefaultQueryMinScore
} }
if c.Voice.ClarifyMaxAttempts <= 0 {
c.Voice.ClarifyMaxAttempts = DefaultClarifyMaxAttempts
}
if c.Voice.ToolTimeout <= 0 { if c.Voice.ToolTimeout <= 0 {
c.Voice.ToolTimeout = Duration(DefaultToolTimeout) c.Voice.ToolTimeout = Duration(DefaultToolTimeout)
} }
+38 -31
View File
@@ -17,10 +17,11 @@ const (
SlotText Slot = "text" // Slots.Text SlotText Slot = "text" // Slots.Text
) )
// MaxAttempts is 1 because Maven is not a nag (DESIGN.md § Non-goals). She asks // DefaultMaxAttempts — how many questions she may ask about one request.
// one clarifying question. If the answer still leaves the slot empty she drops // Three, because after three tries the likely problem is that she misheard the
// the request instead of asking again. // whole request, not one slot — so another question about that slot won't help.
const MaxAttempts = 1 // Configurable: voice.clarify_max_attempts.
const DefaultMaxAttempts = 3
// PendingQuestion is what Maven holds while she waits for an answer to an open // 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 // 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 Utterance string // the user's original raw words
Asked time.Time Asked time.Time
TTL time.Duration 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 { 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. // CanAsk reports whether Maven may ask another question about this request.
func (q *PendingQuestion) CanAsk() bool { 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 // ClarifyStore holds the parked questions. Same shape and locking as
// SessionStore: keyed by dialogue id, expired entries dropped on read. // SessionStore: keyed by dialogue id, expired entries dropped on read.
type ClarifyStore struct { 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 // Put parks a question. Called on a clarify decision (cmd/mavend/clarify.go).
// place of the flat "не разобрала" reply (cmd/mavend/voice.go).
func (s *ClarifyStore) Put(id string, q *PendingQuestion) { func (s *ClarifyStore) Put(id string, q *PendingQuestion) {
if q.TTL <= 0 { if q.TTL <= 0 {
q.TTL = s.defaultTTL q.TTL = s.defaultTTL
@@ -78,8 +85,7 @@ func (s *ClarifyStore) Put(id string, q *PendingQuestion) {
s.mu.Unlock() s.mu.Unlock()
} }
// TODO: the daemon will Get on the next turn, parse that turn into Slots, call // Get returns the live parked question, or nil when there is none.
// Answer, and Delete — the open-question twin of resolveConfirm.
func (s *ClarifyStore) Get(id string, now time.Time) *PendingQuestion { func (s *ClarifyStore) Get(id string, now time.Time) *PendingQuestion {
s.mu.RLock() s.mu.RLock()
q, ok := s.questions[id] 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. // 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 // Only the slots listed in Missing are touched. Within those, a value the answer
// never overwritten — the answer completes the original request, it does not // carries WINS over what was parked: she asked about this slot, so «нет, в пять»
// restate it. Parsing the answer text into `answer` is the caller's job; this // after «в три» must replace the time, not be thrown away.
// package must stay free of internal/router. //
// 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 { func (q *PendingQuestion) Answer(text string, answer Slots) Slots {
out := q.Slots out := q.Slots
for _, slot := range q.Missing { for _, slot := range q.Missing {
switch slot { switch slot {
case SlotTime: case SlotTime:
if !out.HasTime && answer.HasTime { if answer.HasTime {
out.Time = answer.Time out.Time = answer.Time
out.HasTime = true out.HasTime = true
} }
case SlotKey: case SlotKey:
if !out.HasKey && answer.HasKey { if answer.HasKey {
out.Key = answer.Key out.Key = answer.Key
out.HasKey = true out.HasKey = true
} }
case SlotValue: case SlotValue:
if out.Value == "" && answer.Value != "" { if answer.Value != "" {
out.Value = answer.Value out.Value = answer.Value
} }
case SlotFn: case SlotFn:
if !out.HasFn && answer.HasFn { if answer.HasFn {
out.Fn = answer.Fn out.Fn = answer.Fn
out.HasFn = true out.HasFn = true
if len(out.Args) == 0 { out.Args = append([]string(nil), answer.Args...)
out.Args = append([]string(nil), answer.Args...)
}
} }
case SlotText: case SlotText:
if out.Text == "" { if answer.Text != "" {
if answer.Text != "" { out.Text = answer.Text
out.Text = answer.Text } else if out.Text == "" {
} else { // No parse for a text slot — the raw answer IS the text.
// No parse for a text slot — the raw answer IS the text. out.Text = text
out.Text = text
}
} }
} }
} }
+26 -26
View File
@@ -1,6 +1,7 @@
package dialogue package dialogue
import ( import (
"reflect"
"testing" "testing"
"time" "time"
) )
@@ -89,12 +90,13 @@ func TestAnswerFillsOnlyMissingSlots(t *testing.T) {
want: Slots{Text: "напомни позвонить", Time: answerTime, HasTime: true}, 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}, parked: Slots{Time: other, HasTime: true},
missing: []Slot{SlotTime}, missing: []Slot{SlotTime},
text: "в три", text: "нет, в три",
answer: Slots{Time: answerTime, HasTime: true}, 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", 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}, 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}, parked: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
missing: []Slot{SlotFn}, missing: []Slot{SlotFn},
text: "останови postgres", text: "останови postgres",
answer: Slots{Fn: "stop", Args: []string{"postgres"}, HasFn: true}, 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", name: "raw answer becomes the text when nothing was parsed",
@@ -157,36 +159,34 @@ func TestAnswerFillsOnlyMissingSlots(t *testing.T) {
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
q := &PendingQuestion{Slots: tc.parked, Missing: tc.missing, Asked: base} q := &PendingQuestion{Slots: tc.parked, Missing: tc.missing, Asked: base}
got := q.Answer(tc.text, tc.answer) // Whole-struct compare: a new field in Slots is covered for free.
if got.Time != tc.want.Time || got.HasTime != tc.want.HasTime || if got := q.Answer(tc.text, tc.answer); !reflect.DeepEqual(got, tc.want) {
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 {
t.Fatalf("Answer = %+v, want %+v", 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) { func TestCanAskAllowsThreeQuestionsByDefault(t *testing.T) {
if MaxAttempts != 1 { if DefaultMaxAttempts != 3 {
t.Fatalf("MaxAttempts = %d, want 1 (Maven asks once, she is not a nag)", MaxAttempts) t.Fatalf("DefaultMaxAttempts = %d, want 3", DefaultMaxAttempts)
} }
q := &PendingQuestion{Asked: base} q := &PendingQuestion{Asked: base} // MaxAttempts unset ⇒ the default
if !q.CanAsk() { for i := 0; i < 3; i++ {
t.Fatal("a fresh question should be askable") if !q.CanAsk() {
t.Fatalf("question %d should be allowed", i+1)
}
q.Attempts++
} }
q.Attempts = MaxAttempts
if q.CanAsk() { 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")
} }
} }