diff --git a/internal/dialogue/clarify.go b/internal/dialogue/clarify.go new file mode 100644 index 0000000..dc6c3c2 --- /dev/null +++ b/internal/dialogue/clarify.go @@ -0,0 +1,171 @@ +package dialogue + +import ( + "sync" + "time" +) + +// Slot names one field of Slots. Named type, not a free string, so a missing +// slot cannot be misspelled — the question phrasing switches on these. +type Slot string + +const ( + SlotTime Slot = "time" // Slots.Time / HasTime + SlotKey Slot = "key" // Slots.Key / HasKey + SlotValue Slot = "value" // Slots.Value (paired with Key) + SlotFn Slot = "fn" // Slots.Fn / HasFn + 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 + +// 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 +// is free text that fills a missing slot rather than a verdict. +type PendingQuestion struct { + Intent Intent // what the router already guessed + Slots Slots // what it already filled + Missing []Slot // what is still empty, in the order to ask about + Utterance string // the user's original raw words + Asked time.Time + TTL time.Duration + Attempts int // questions already asked; capped by MaxAttempts +} + +func (q *PendingQuestion) IsExpired(now time.Time) bool { + return now.After(q.Asked.Add(q.TTL)) +} + +// CanAsk reports whether Maven may ask another question about this request. +func (q *PendingQuestion) CanAsk() bool { + return q.Attempts < 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 { + mu sync.RWMutex + questions map[string]*PendingQuestion + defaultTTL time.Duration +} + +func NewClarifyStore(defaultTTL time.Duration) *ClarifyStore { + if defaultTTL <= 0 { + // Short, like confirmTTL in voice.go: a clarifying question is a + // same-breath gesture, a stale one should not eat a later utterance. + defaultTTL = 90 * time.Second + } + return &ClarifyStore{ + questions: make(map[string]*PendingQuestion), + defaultTTL: defaultTTL, + } +} + +// TODO: the daemon will Put a question here when Decision.Clarify fires, in +// place of the flat "не разобрала" reply (cmd/mavend/voice.go). +func (s *ClarifyStore) Put(id string, q *PendingQuestion) { + if q.TTL <= 0 { + q.TTL = s.defaultTTL + } + s.mu.Lock() + s.questions[id] = q + 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. +func (s *ClarifyStore) Get(id string, now time.Time) *PendingQuestion { + s.mu.RLock() + q, ok := s.questions[id] + s.mu.RUnlock() + if !ok { + return nil + } + if q.IsExpired(now) { + s.Delete(id) + return nil + } + return q +} + +func (s *ClarifyStore) Delete(id string) { + s.mu.Lock() + delete(s.questions, id) + s.mu.Unlock() +} + +// 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. +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 { + out.Time = answer.Time + out.HasTime = true + } + case SlotKey: + if !out.HasKey && answer.HasKey { + out.Key = answer.Key + out.HasKey = true + } + case SlotValue: + if out.Value == "" && answer.Value != "" { + out.Value = answer.Value + } + case SlotFn: + if !out.HasFn && answer.HasFn { + out.Fn = answer.Fn + out.HasFn = true + if len(out.Args) == 0 { + 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 + } + } + } + } + return out +} + +// StillMissing lists the slots that are empty in s, out of the ones asked for. +// The caller uses it to decide between acting and dropping the request. +func StillMissing(want []Slot, s Slots) []Slot { + var out []Slot + for _, slot := range want { + empty := false + switch slot { + case SlotTime: + empty = !s.HasTime + case SlotKey: + empty = !s.HasKey + case SlotValue: + empty = s.Value == "" + case SlotFn: + empty = !s.HasFn + case SlotText: + empty = s.Text == "" + } + if empty { + out = append(out, slot) + } + } + return out +} diff --git a/internal/dialogue/clarify_test.go b/internal/dialogue/clarify_test.go new file mode 100644 index 0000000..81d05ca --- /dev/null +++ b/internal/dialogue/clarify_test.go @@ -0,0 +1,225 @@ +package dialogue + +import ( + "testing" + "time" +) + +var base = time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC) + +func TestPendingQuestionIsExpired(t *testing.T) { + cases := []struct { + name string + ttl time.Duration + now time.Time + want bool + }{ + {"fresh", time.Minute, base.Add(10 * time.Second), false}, + {"exactly at ttl", time.Minute, base.Add(time.Minute), false}, + {"past ttl", time.Minute, base.Add(2 * time.Minute), true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + q := &PendingQuestion{Asked: base, TTL: tc.ttl} + if got := q.IsExpired(tc.now); got != tc.want { + t.Fatalf("IsExpired = %v, want %v", got, tc.want) + } + }) + } +} + +func TestClarifyStoreGetPutDelete(t *testing.T) { + s := NewClarifyStore(time.Minute) + + if got := s.Get("voice", base); got != nil { + t.Fatalf("empty store returned %+v", got) + } + + q := &PendingQuestion{Intent: IntentReminder, Missing: []Slot{SlotTime}, Asked: base} + s.Put("voice", q) + if q.TTL != time.Minute { + t.Fatalf("Put did not apply the default TTL, got %v", q.TTL) + } + if got := s.Get("voice", base.Add(time.Second)); got != q { + t.Fatalf("Get returned %+v, want the parked question", got) + } + + // Expired questions are dropped on read, not returned. + if got := s.Get("voice", base.Add(2*time.Minute)); got != nil { + t.Fatalf("expired Get returned %+v", got) + } + if got := s.Get("voice", base); got != nil { + t.Fatalf("expired question was not deleted: %+v", got) + } + + s.Put("voice", &PendingQuestion{Asked: base, TTL: time.Hour}) + s.Delete("voice") + if got := s.Get("voice", base); got != nil { + t.Fatalf("Delete left %+v", got) + } +} + +func TestNewClarifyStoreDefaultTTL(t *testing.T) { + s := NewClarifyStore(0) + q := &PendingQuestion{Asked: base} + s.Put("voice", q) + if q.TTL != 90*time.Second { + t.Fatalf("TTL = %v, want 90s", q.TTL) + } +} + +func TestAnswerFillsOnlyMissingSlots(t *testing.T) { + answerTime := base.Add(3 * time.Hour) + other := base.Add(9 * time.Hour) + + cases := []struct { + name string + parked Slots + missing []Slot + text string + answer Slots + want Slots + }{ + { + name: "fills the missing time", + parked: Slots{Text: "напомни позвонить"}, + missing: []Slot{SlotTime}, + text: "в три", + answer: Slots{Time: answerTime, HasTime: true}, + want: Slots{Text: "напомни позвонить", Time: answerTime, HasTime: true}, + }, + { + name: "does not overwrite a filled time", + parked: Slots{Time: other, HasTime: true}, + missing: []Slot{SlotTime}, + text: "в три", + answer: Slots{Time: answerTime, HasTime: true}, + want: Slots{Time: other, HasTime: true}, + }, + { + name: "ignores slots that were not missing", + parked: Slots{Key: "water", HasKey: true}, + missing: []Slot{SlotValue}, + text: "два литра", + answer: Slots{Key: "sleep", HasKey: true, Value: "2l"}, + want: Slots{Key: "water", HasKey: true, Value: "2l"}, + }, + { + name: "fills key when empty", + parked: Slots{}, + missing: []Slot{SlotKey, SlotValue}, + text: "воды", + answer: Slots{Key: "water", HasKey: true, Value: `"drank"`}, + want: Slots{Key: "water", HasKey: true, Value: `"drank"`}, + }, + { + name: "fills fn and its args", + parked: Slots{}, + missing: []Slot{SlotFn}, + text: "перезапусти nginx", + answer: 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", + 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}, + }, + { + name: "raw answer becomes the text when nothing was parsed", + parked: Slots{}, + missing: []Slot{SlotText}, + text: "купить хлеб", + answer: Slots{}, + want: Slots{Text: "купить хлеб"}, + }, + { + name: "parsed text wins over the raw answer", + parked: Slots{}, + missing: []Slot{SlotText}, + text: "запиши купить хлеб", + answer: Slots{Text: "купить хлеб"}, + want: Slots{Text: "купить хлеб"}, + }, + { + name: "empty answer leaves the slot missing", + parked: Slots{Text: "напомни"}, + missing: []Slot{SlotTime}, + text: "не знаю", + answer: Slots{}, + want: Slots{Text: "напомни"}, + }, + } + + 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 { + 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) + } + q := &PendingQuestion{Asked: base} + if !q.CanAsk() { + t.Fatal("a fresh question should be askable") + } + q.Attempts = MaxAttempts + if q.CanAsk() { + t.Fatal("the question should not be asked twice") + } +} + +func TestStillMissing(t *testing.T) { + want := []Slot{SlotTime, SlotKey, SlotValue, SlotFn, SlotText} + cases := []struct { + name string + slots Slots + want []Slot + }{ + {"all empty", Slots{}, want}, + { + name: "all filled", + slots: Slots{Time: base, HasTime: true, Key: "water", HasKey: true, Value: "1l", Fn: "restart", HasFn: true, Text: "t"}, + want: nil, + }, + { + name: "only value left", + slots: Slots{Time: base, HasTime: true, Key: "water", HasKey: true, Fn: "restart", HasFn: true, Text: "t"}, + want: []Slot{SlotValue}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := StillMissing(want, tc.slots) + if len(got) != len(tc.want) { + t.Fatalf("StillMissing = %v, want %v", got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("StillMissing = %v, want %v", got, tc.want) + } + } + }) + } +} diff --git a/internal/dialogue/session.go b/internal/dialogue/session.go index 452d27f..a573c99 100644 --- a/internal/dialogue/session.go +++ b/internal/dialogue/session.go @@ -21,6 +21,7 @@ type Slots struct { Time time.Time HasTime bool Key string + Value string // payload for a fact key, mirrors router.Slots.Value HasKey bool Text string Fn string @@ -104,6 +105,9 @@ func InheritSlots(prev, cur Slots) Slots { out.Key = prev.Key out.HasKey = true } + if out.Value == "" && prev.Value != "" { + out.Value = prev.Value + } if out.Text == "" && prev.Text != "" { out.Text = prev.Text } diff --git a/internal/dialogue/session_test.go b/internal/dialogue/session_test.go index 819efa7..735896d 100644 --- a/internal/dialogue/session_test.go +++ b/internal/dialogue/session_test.go @@ -113,4 +113,14 @@ func TestInheritSlots(t *testing.T) { if inherited6.Text != "какая погода в москве" { t.Error("should inherit text when current is empty") } + + prevValue := Slots{Key: "water", HasKey: true, Value: `"drank"`} + inherited7 := InheritSlots(prevValue, Slots{}) + if inherited7.Value != `"drank"` { + t.Error("should inherit value when current is empty") + } + kept := InheritSlots(prevValue, Slots{Value: "2l"}) + if kept.Value != "2l" { + t.Error("should keep current value") + } }