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 } // Action reads the parked question as the typed action it is assembling // (pending.go). Derived rather than stored: the question's fields stay the one // copy of the truth, so a caller that fills them the old way cannot end up with // a capability that disagrees with the intent. func (q *PendingQuestion) Action() PendingAction { return PendingAction{ Capability: CapabilityFor(q.Intent), Slots: q.Slots, Missing: q.Missing, Utterance: q.Utterance, Asked: q.Asked, TTL: q.TTL, Attempts: q.Attempts, MaxAttempts: q.MaxAttempts, } } // IsExpired and CanAsk answer through the action, so there is exactly one copy // of the TTL and attempt-cap rules and the widening cannot drift from them. func (q *PendingQuestion) IsExpired(now time.Time) bool { a := q.Action() return a.IsExpired(now) } // CanAsk reports whether Maven may ask another question about this request. func (q *PendingQuestion) CanAsk() bool { a := q.Action() return a.CanAsk() } // ClarifyStore holds the parked questions. Same shape and locking as // SessionStore: keyed by dialogue id, expired entries dropped on read. // // Memory only, deliberately, unlike SessionStore — a restart expires every // parked question and she does not announce that it happened (Vikunja #385, // written down in docs/design.md). The 90s TTL and the attempt count measure a // pause in one conversation, and a restart is a gap of unknown length, so a // restored question would either be dead already or lying about its age. His // next words route fresh, which is the right answer with or without a notice. // Do not give this store a persister without re-arguing that. type ClarifyStore struct { mu sync.RWMutex // stacks — one stack of parked questions per dialogue id, newest last. It // was a single question per id until V-559; a side query has to be able to // suspend the active flow and find it still there afterwards (V-561 does // the suspending, this only holds the room for it). stacks map[string][]*PendingQuestion defaultTTL time.Duration } // MaxStackDepth — how many parked questions one dialogue id may hold. // // Two, not three. One is the flow he is in, one is the thing he interrupted it // with, and out loud he does not nest deeper than that: a side query inside a // side query is a shape typed conversation has and spoken conversation does // not. The bound is also a promise — every level she keeps is a level she must // be able to SPEAK when it dies (clarifyGaveUp, clarifyExpiredVariants), and // two lines of "and the other thing I dropped" is already the limit of what a // reply can carry. const MaxStackDepth = 2 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{ stacks: make(map[string][]*PendingQuestion), defaultTTL: defaultTTL, } } // Put parks a question, replacing the one on top. Called on a clarify decision // (cmd/mavend/clarify.go), and it is still what the daemon uses: re-asking the // same request is a new question about the SAME action, so it overwrites rather // than growing the stack. Push is the deeper one, and nothing calls it yet. func (s *ClarifyStore) Put(id string, q *PendingQuestion) { s.fillTTL(q) s.mu.Lock() defer s.mu.Unlock() stack := s.stacks[id] if len(stack) == 0 { s.stacks[id] = []*PendingQuestion{q} return } stack[len(stack)-1] = q } // Push suspends whatever is parked and puts q on top. The returned question is // one the depth bound forced out of the bottom of the stack, and the caller MUST // tell him about it — a parked request that dies without a word leaves him // thinking it landed, which is the whole reason clarifyGaveUp exists. nil is the // ordinary case. func (s *ClarifyStore) Push(id string, q *PendingQuestion) *PendingQuestion { s.fillTTL(q) s.mu.Lock() defer s.mu.Unlock() stack := append(s.stacks[id], q) var dropped *PendingQuestion if len(stack) > MaxStackDepth { dropped = stack[0] stack = stack[1:] } s.stacks[id] = stack return dropped } // Peek returns the live question on top, or nil when there is none. Expired // entries below it are left alone: TakeExpired is what reports those, and // dropping one here would be the silent death this store is careful about. func (s *ClarifyStore) Peek(id string, now time.Time) *PendingQuestion { s.mu.RLock() stack := s.stacks[id] var q *PendingQuestion if len(stack) > 0 { q = stack[len(stack)-1] } s.mu.RUnlock() if q == nil { return nil } if q.IsExpired(now) { s.Delete(id) return nil } return q } // Get is Peek under the name every caller already uses. Kept because a clarify // answer is always about the top of the stack, so the two are the same call. func (s *ClarifyStore) Get(id string, now time.Time) *PendingQuestion { return s.Peek(id, now) } // Pop takes the live question off the top and returns it, so the flow beneath // becomes current again. nil when the top is empty or expired — an expired top // is dropped along with the rest of the stack, exactly as Peek does, because the // clock that killed it has been running for everything underneath too. func (s *ClarifyStore) Pop(id string, now time.Time) *PendingQuestion { s.mu.Lock() stack := s.stacks[id] if len(stack) == 0 { s.mu.Unlock() return nil } q := stack[len(stack)-1] if q.IsExpired(now) { delete(s.stacks, id) s.mu.Unlock() return nil } if len(stack) == 1 { delete(s.stacks, id) } else { s.stacks[id] = stack[:len(stack)-1] } s.mu.Unlock() return q } // Depth — how many questions are parked for this id, expired ones included. // Diagnostic; the arbiter in V-560 reads it to know it is inside a flow. func (s *ClarifyStore) Depth(id string) int { s.mu.RLock() defer s.mu.RUnlock() return len(s.stacks[id]) } // 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. // // It looks at the top only, and drops the whole stack when that one is dead: one // notice is what a reply can carry, and anything parked under a question that // timed out has been waiting at least as long. func (s *ClarifyStore) TakeExpired(id string, now time.Time) bool { s.mu.Lock() defer s.mu.Unlock() stack := s.stacks[id] if len(stack) == 0 || !stack[len(stack)-1].IsExpired(now) { return false } delete(s.stacks, id) return true } // Delete drops every question parked for this id. The old single-slot Delete // under the old name: at depth one the two are the same, and a caller that means // "this exchange is over" means all of it. func (s *ClarifyStore) Delete(id string) { s.mu.Lock() delete(s.stacks, id) s.mu.Unlock() } // fillTTL applies the store default to a question parked without one. func (s *ClarifyStore) fillTTL(q *PendingQuestion) { if q.TTL <= 0 { q.TTL = s.defaultTTL } } // 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 }