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() }