diff --git a/internal/dialogue/clarify.go b/internal/dialogue/clarify.go new file mode 100644 index 0000000..699fb3c --- /dev/null +++ b/internal/dialogue/clarify.go @@ -0,0 +1,149 @@ +package dialogue + +import ( + "sync" + "time" +) + +// Slot names one piece of information a turn needs. A question is always about +// exactly one of these. +type Slot string + +const ( + SlotTime Slot = "time" + SlotKey Slot = "key" + SlotFn Slot = "fn" + SlotText Slot = "text" +) + +// MaxAttempts — she asks once and then drops the request. Asking twice about +// the same utterance reads as nagging, and the non-goals forbid that. +const MaxAttempts = 1 + +// PendingQuestion — a request she could not act on, parked while she waits for +// the one missing piece. The original slots are kept so the answer only has to +// carry the gap, not the whole request again. +type PendingQuestion struct { + Intent Intent + Slots Slots + Missing []Slot + Utterance string // the original request, so the answer inherits its wording + Asked time.Time + TTL time.Duration + Attempts int +} + +// IsExpired — an answer that arrives after the TTL is a new request, not an +// answer. Same reasoning as the confirm gate: a stale question must not eat an +// unrelated later utterance. +func (q *PendingQuestion) IsExpired(now time.Time) bool { + return now.After(q.Asked.Add(q.TTL)) +} + +func (q *PendingQuestion) CanAsk() bool { + return q.Attempts < MaxAttempts +} + +// Answer merges the parsed answer into the parked slots. It fills only the +// slots that were missing when the question was asked — an answer can never +// overwrite something she already understood, so a stray word in the answer +// cannot silently change the request. +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, out.HasTime = answer.Time, true + } + case SlotKey: + if !out.HasKey && answer.HasKey { + out.Key, out.HasKey = answer.Key, true + } + case SlotFn: + if !out.HasFn && answer.HasFn { + out.Fn, out.Args, out.HasFn = answer.Fn, answer.Args, true + } + case SlotText: + if out.Text == "" { + out.Text = text + } + } + } + if out.Text == "" { + out.Text = text + } + return out +} + +// StillMissing returns the wanted slots that the given slots do not fill, in +// the order they were wanted. Empty result ⇒ the request can be acted on. +func StillMissing(want []Slot, s Slots) []Slot { + var out []Slot + for _, slot := range want { + filled := false + switch slot { + case SlotTime: + filled = s.HasTime + case SlotKey: + filled = s.HasKey + case SlotFn: + filled = s.HasFn + case SlotText: + filled = s.Text != "" + } + if !filled { + out = append(out, slot) + } + } + return out +} + +// ClarifyStore holds the parked questions. One entry per dialogue id; a new +// question overwrites the old one (last-asked wins, single-user box). +type ClarifyStore struct { + mu sync.Mutex + questions map[string]*PendingQuestion + defaultTTL time.Duration +} + +func NewClarifyStore(defaultTTL time.Duration) *ClarifyStore { + if defaultTTL <= 0 { + defaultTTL = 90 * time.Second + } + return &ClarifyStore{ + questions: make(map[string]*PendingQuestion), + defaultTTL: defaultTTL, + } +} + +// Get returns the live question for id, or nil. An expired question is dropped +// on read so the caller never sees one. +func (c *ClarifyStore) Get(id string, now time.Time) *PendingQuestion { + c.mu.Lock() + defer c.mu.Unlock() + q, ok := c.questions[id] + if !ok { + return nil + } + if q.IsExpired(now) { + delete(c.questions, id) + return nil + } + return q +} + +func (c *ClarifyStore) Put(id string, q *PendingQuestion) { + if q.TTL <= 0 { + q.TTL = c.defaultTTL + } + c.mu.Lock() + c.questions[id] = q + c.mu.Unlock() +} + +func (c *ClarifyStore) Delete(id string) { + c.mu.Lock() + delete(c.questions, id) + c.mu.Unlock() +} diff --git a/internal/dialogue/clarify_test.go b/internal/dialogue/clarify_test.go new file mode 100644 index 0000000..f991e02 --- /dev/null +++ b/internal/dialogue/clarify_test.go @@ -0,0 +1,93 @@ +package dialogue + +import ( + "testing" + "time" +) + +var clarifyNow = time.Date(2026, 7, 31, 10, 0, 0, 0, time.UTC) + +func TestPendingQuestionAnswerFillsOnlyMissing(t *testing.T) { + fireAt := clarifyNow.Add(time.Hour) + q := &PendingQuestion{ + Intent: IntentReminder, + Slots: Slots{Key: "mom", HasKey: true, Text: "напомни позвонить маме"}, + Missing: []Slot{SlotTime}, + } + got := q.Answer("в 11", Slots{Time: fireAt, HasTime: true, Key: "other", HasKey: true}) + if !got.HasTime || !got.Time.Equal(fireAt) { + t.Fatalf("missing time slot not filled: %+v", got) + } + if got.Key != "mom" { + t.Fatalf("answer overwrote a filled slot: key=%q", got.Key) + } + if got.Text != "напомни позвонить маме" { + t.Fatalf("answer overwrote the original text: %q", got.Text) + } +} + +func TestPendingQuestionAnswerKeepsGapWhenAnswerIsEmpty(t *testing.T) { + q := &PendingQuestion{Intent: IntentReminder, Missing: []Slot{SlotTime}} + got := q.Answer("не знаю", Slots{}) + if got.HasTime { + t.Fatal("empty answer must not fill the slot") + } + if len(StillMissing(q.Missing, got)) != 1 { + t.Fatal("StillMissing should report the unfilled slot") + } +} + +func TestStillMissing(t *testing.T) { + cases := []struct { + name string + want []Slot + slots Slots + left int + }{ + {"all filled", []Slot{SlotTime, SlotKey}, Slots{HasTime: true, HasKey: true}, 0}, + {"time gap", []Slot{SlotTime}, Slots{HasKey: true}, 1}, + {"fn gap", []Slot{SlotFn}, Slots{}, 1}, + {"text filled", []Slot{SlotText}, Slots{Text: "hi"}, 0}, + {"nothing wanted", nil, Slots{}, 0}, + } + for _, tc := range cases { + if got := StillMissing(tc.want, tc.slots); len(got) != tc.left { + t.Errorf("%s: got %v, want %d left", tc.name, got, tc.left) + } + } +} + +func TestClarifyStoreExpiry(t *testing.T) { + s := NewClarifyStore(90 * time.Second) + s.Put("voice", &PendingQuestion{Asked: clarifyNow, Missing: []Slot{SlotTime}}) + + if s.Get("voice", clarifyNow.Add(30*time.Second)) == nil { + t.Fatal("question inside the TTL should be live") + } + if s.Get("voice", clarifyNow.Add(2*time.Minute)) != nil { + t.Fatal("question past the TTL should be dropped") + } + if s.Get("voice", clarifyNow) != nil { + t.Fatal("an expired question must be deleted on read, not linger") + } +} + +func TestClarifyStoreDefaultTTL(t *testing.T) { + s := NewClarifyStore(0) + q := &PendingQuestion{Asked: clarifyNow} + s.Put("voice", q) + if q.TTL != 90*time.Second { + t.Fatalf("default TTL not applied: %v", q.TTL) + } +} + +func TestCanAskOnce(t *testing.T) { + q := &PendingQuestion{} + if !q.CanAsk() { + t.Fatal("a fresh question should be askable") + } + q.Attempts = MaxAttempts + if q.CanAsk() { + t.Fatal("she must not ask twice") + } +}