Files
Maven/internal/dialogue/clarify.go
T
claude c8a5b5416e a side query suspends the flow instead of ending it (V-561)
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>
2026-08-06 01:23:37 +04:00

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
}