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 ) // MaxAttempts is 1 because Maven is not a nag (DESIGN.md § Non-goals). She asks // one clarifying question. If the answer still leaves the slot empty she drops // the request instead of asking again. const MaxAttempts = 1 // 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; capped by 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 < MaxAttempts } // TODO: the daemon will phrase the question text from Missing (one short ru // question per Slot, feminine self-reference) and speak it here. // 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, } } // TODO: the daemon will Put a question here when Decision.Clarify fires, in // place of the flat "не разобрала" reply (cmd/mavend/voice.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() } // TODO: the daemon will Get on the next turn, parse that turn into Slots, call // Answer, and Delete — the open-question twin of resolveConfirm. 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 } 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 filled, and an already filled slot is // never overwritten — the answer completes the original request, it does not // restate it. 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 !out.HasTime && answer.HasTime { out.Time = answer.Time out.HasTime = true } case SlotKey: if !out.HasKey && answer.HasKey { out.Key = answer.Key out.HasKey = true } case SlotValue: if out.Value == "" && answer.Value != "" { out.Value = answer.Value } case SlotFn: if !out.HasFn && answer.HasFn { out.Fn = answer.Fn out.HasFn = true if len(out.Args) == 0 { out.Args = append([]string(nil), answer.Args...) } } case SlotText: if out.Text == "" { if answer.Text != "" { out.Text = answer.Text } else { // 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 }