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:
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user