c8a5b5416e
V-560 classified the side query correctly and then dropped the request behind it, saying "Прошлую просьбу отпускаю." The owner rejected that on sight: he asked about the weather in the middle of setting a reminder, and being told the reminder was let go reports a loss he did not ask for. It had not been lost — there was simply nowhere to put it. There is now. ClarifyStore grew a bounded stack in V-559 and nothing called Push; this is the caller it was built for. A side query leaves the question parked exactly as it is, the words are answered as themselves, and the question comes back on the end of the same reply — one utterance, two acts. The resumed question is not the first one again. "Когда?" works in the same breath as "напомни позвонить маме" and does not work after a turn about Rome, so the deck has a second form per slot that names the request: "На какое время поставить напоминание?". No attempt is spent, because he answered the side query and not the parked question, and charging a retry for a turn that was never an answer is the V-554 shape. clarifyDropped stays for new_request and cancel, where something really does die. Two things can now die at once, so TakeExpired reports a count instead of a bool and the expiry notice has a plural wording — "прошлую просьбу" when two were lost would be a lie about the number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
318 lines
10 KiB
Go
318 lines
10 KiB
Go
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 HOW MANY parked questions were dropped because the TTL
|
|
// ran out, and drops them. 0 ⇒ nothing was parked, or what was parked is still
|
|
// live. 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:
|
|
// anything parked under a question that timed out has been waiting at least as
|
|
// long. The COUNT rather than a bool since V-561, because the stack can now
|
|
// hold two — the flow and the side query that suspended it — and a notice
|
|
// saying "прошлую просьбу" when two died is a lie about the count.
|
|
func (s *ClarifyStore) TakeExpired(id string, now time.Time) int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
stack := s.stacks[id]
|
|
if len(stack) == 0 || !stack[len(stack)-1].IsExpired(now) {
|
|
return 0
|
|
}
|
|
delete(s.stacks, id)
|
|
return len(stack)
|
|
}
|
|
|
|
// 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
|
|
}
|