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 ) // DefaultMaxAttempts — how many questions she may ask about one request. // Three, because after three tries the likely problem is that she misheard the // whole request, not one slot — so another question about that slot won't help. // Configurable: voice.clarify_max_attempts. const DefaultMaxAttempts = 3 // 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 // MaxAttempts caps Attempts. 0 ⇒ DefaultMaxAttempts. MaxAttempts int } // maxAttempts is MaxAttempts with the default filled in. func (q *PendingQuestion) maxAttempts() int { if q.MaxAttempts <= 0 { return DefaultMaxAttempts } return q.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 < q.maxAttempts() } // 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, } } // Put parks a question. Called on a clarify decision (cmd/mavend/clarify.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() } // Get returns the live parked question, or nil when there is none. 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 } // TakeExpired reports whether a question was parked here but its TTL ran out, // and drops it. Get drops such a question silently, which leaves the user // thinking his request is still alive — the caller uses this to tell him it is // gone before treating his words as a fresh utterance. func (s *ClarifyStore) TakeExpired(id string, now time.Time) bool { s.mu.Lock() defer s.mu.Unlock() q, ok := s.questions[id] if !ok || !q.IsExpired(now) { return false } delete(s.questions, id) return true } 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 touched. Within those, a value the answer // carries WINS over what was parked: she asked about this slot, so «нет, в пять» // after «в три» must replace the time, not be thrown away. // // This is the clarify answer only. A correction in a fresh turn ("вообще-то // перенеси на пять") is a different code path (followUpMerge) — not here. // // 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 answer.HasTime { out.Time = answer.Time out.HasTime = true } case SlotKey: if answer.HasKey { out.Key = answer.Key out.HasKey = true } case SlotValue: if answer.Value != "" { out.Value = answer.Value } case SlotFn: if answer.HasFn { out.Fn = answer.Fn out.HasFn = true out.Args = append([]string(nil), answer.Args...) } case SlotText: if answer.Text != "" { out.Text = answer.Text } else if out.Text == "" { // 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 }