Add the clarify data layer: a parked question with one missing slot

The router can already say "I am not sure" (Decision.Clarify) but the daemon
had nowhere to keep the request while it asked. PendingQuestion holds the
original slots, ClarifyStore parks one per dialogue id with a 90s TTL, and
Answer fills only the slots that were missing so an answer can never rewrite
what she already understood. Logic that uses this comes next.

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 02:20:28 +04:00
parent abe9b28719
commit 9145b83100
2 changed files with 242 additions and 0 deletions
+93
View File
@@ -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")
}
}