de9884e063
MaxSuspends did not move the number it was written for. Twenty-six of 140 turns carried a parked clarify tail before it landed and twenty-six after. Two bounds rearm each other. An aside spends no attempt, so MaxAttempts never reaches it. A turn reading as a failed answer zeroes Suspends, so MaxSuspends never reaches the asides. Alternating them restores each bound with the other's traffic. Measured on 2026-08-08: one question about a reminder's day rode turns 7 to 13. PendingQuestion.Rides is the same event counted without the resets. Set once, incremented only in noteSuspended, carried across the re-park in askRemainingGap, read by nothing that could lower it. MaxRides is 4, one looser than MaxSuspends so the tighter statement about a run stays reachable. It ends the measured ride one turn early and no more. Most of that ride is attempts, spent because classifyTurnRole reads "спасибо" and "привет" as failed answers. Said so in the constant and in the design doc rather than claiming a fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
404 lines
15 KiB
Go
404 lines
15 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
|
|
// WhenText is every answer he has given about the time, joined in the order
|
|
// he gave them. Kept apart from Utterance because the utterance is the
|
|
// reminder's payload, and because a time answer has to be read against the
|
|
// request rather than alone: "завтра" names a day for an hour said earlier.
|
|
WhenText string
|
|
Asked time.Time
|
|
TTL time.Duration
|
|
Attempts int // questions already asked
|
|
// MaxAttempts caps Attempts. 0 ⇒ DefaultMaxAttempts.
|
|
MaxAttempts int
|
|
// Suspends counts how many times this question has stepped aside for
|
|
// something he asked instead, and come back on the end of the answer. It is
|
|
// deliberately NOT an attempt: a side query is not a failed answer, and
|
|
// charging it a retry is the V-554 shape. See CanResume for why it is
|
|
// counted at all.
|
|
Suspends int
|
|
// Rides counts every turn this question has ridden out on the end of
|
|
// someone else's reply, over the whole life of the request. Unlike Suspends
|
|
// it is never reset and never re-based, which is the only property that
|
|
// matters about it (V-663).
|
|
Rides int
|
|
}
|
|
|
|
// MaxSuspends — how many times one question may step aside and come back before
|
|
// she lets the request go (V-654).
|
|
//
|
|
// It exists because suspension had no bound of any kind. A side query spends no
|
|
// attempt, so MaxAttempts never applies to it, and it restarts the 90s clock, so
|
|
// the TTL never arrives either. Measured on 2026-08-07: one unfilled time slot
|
|
// rode the end of six consecutive unrelated replies and stopped only when a
|
|
// seventh turn happened to read as a failed answer.
|
|
//
|
|
// Three, matching DefaultMaxAttempts, and for the same reason. Once he has
|
|
// asked for three other things without touching the question, the likely truth
|
|
// is that he has moved on and has not said so.
|
|
const MaxSuspends = 3
|
|
|
|
// MaxRides — how many turns one question may ride out on the end of an
|
|
// unrelated reply, counted over its whole life (V-663).
|
|
//
|
|
// MaxSuspends did not move the measurement it was written for. Twenty-six of
|
|
// 140 turns carried a tail before it landed and twenty-six carried one after.
|
|
// Every bound on this question is rearmed by something ordinary:
|
|
//
|
|
// - The TTL is an inactivity timer, and both noteSuspended and reaskOrGiveUp
|
|
// restart it, so it cannot arrive while he keeps talking.
|
|
// - Suspends is zeroed by any turn that reads as an answer, which is where
|
|
// "спасибо" and "привет" land. It resets before anything is known to have
|
|
// been filled.
|
|
// - askRemainingGap builds a fresh question for the second gap, so a reminder
|
|
// with two gaps gets a new allowance halfway through.
|
|
//
|
|
// So Suspends only bites on four strictly consecutive side queries with nothing
|
|
// chat-like between them, which is not the shape real conversation has. Rides is
|
|
// the same idea with the resets taken out: set once, incremented, carried
|
|
// across a re-park, and read by nothing that could lower it.
|
|
//
|
|
// The shape it is aimed at is measured, not imagined. In the 2026-08-08 run one
|
|
// question about a reminder's day rode turns 7 to 13 and ended only because
|
|
// turn 14 was a new request. Three asides, then two turns that read as failed
|
|
// answers, then two more asides. The asides spend no attempt and the answers
|
|
// reset Suspends, so the two bounds take turns being rearmed by the other's
|
|
// traffic.
|
|
//
|
|
// Four, not three. It has to be looser than MaxSuspends or that bound is dead
|
|
// code, because Rides is never lower than Suspends and would always fire first.
|
|
//
|
|
// Do not read this as a fix for the whole ride. It ends the measured one a turn
|
|
// early and no more. Most of that ride's length is attempts, spent by turns
|
|
// like "спасибо" and "привет" being read as failed answers to a question about
|
|
// a day. That is a defect in classifyTurnRole and not in any bound here.
|
|
const MaxRides = 4
|
|
|
|
// CanResume reports whether this question may step aside once more. False ⇒ the
|
|
// caller lets the request go and says so; it must never simply stop resuming,
|
|
// because a question dropped in silence reads as one that was answered.
|
|
//
|
|
// Two bounds, and they answer different questions. Suspends asks whether he has
|
|
// walked away from this exchange in the last few turns. Rides asks whether this
|
|
// question has been riding long enough that the answer is no regardless.
|
|
func (q *PendingQuestion) CanResume() bool {
|
|
return q.Suspends < MaxSuspends && q.Rides < MaxRides
|
|
}
|
|
|
|
// 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
|
|
|
|
// DefaultClarifyTTL — how long a parked question stays his answer to give.
|
|
// Short, like confirmTTL in voice.go: a clarifying question is a same-breath
|
|
// gesture, and a stale one should not eat a later utterance.
|
|
const DefaultClarifyTTL = 90 * time.Second
|
|
|
|
func NewClarifyStore(defaultTTL time.Duration) *ClarifyStore {
|
|
if defaultTTL <= 0 {
|
|
defaultTTL = DefaultClarifyTTL
|
|
}
|
|
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. An expired
|
|
// top takes the whole stack with it, exactly as Pop does: the clock that killed
|
|
// it has been running for everything underneath too.
|
|
//
|
|
// That drop is silent, which is the death this store is otherwise careful
|
|
// about, so TakeExpired has to run BEFORE Peek on a turn — it is what counts the
|
|
// dropped questions and tells him they are gone. cmd/mavend/voice.go calls
|
|
// clarifyExpiredNotice first for that reason, and reordering the two makes the
|
|
// notice unreachable rather than wrong.
|
|
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
|
|
}
|