62d320f93a
MaxAttempts was 1, justified as "not a nag". Wrong reading: "not a nag" is about interrupting unprompted, and a clarifying question is part of a conversation he started. Now three, configurable via voice.clarify_max_attempts (default 3). Three, because after that the likely problem is she misheard the whole request, not one slot. Answer used to keep the parked value, so "в три" then "нет, в пять" threw the five away. Now a value the answer carries wins for the slot she asked about. Only for the clarify answer — a correction in a fresh turn is followUpMerge. The eight-field chained assertion in the Answer test is one DeepEqual now, so a new field in Slots is covered without touching the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
179 lines
4.9 KiB
Go
179 lines
4.9 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
|
|
}
|
|
|
|
// maxAttempts is MaxAttempts with the default filled in.
|
|
func (q *PendingQuestion) maxAttempts() int {
|
|
if q.MaxAttempts <= 0 {
|
|
return DefaultMaxAttempts
|
|
}
|
|
return q.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 < q.maxAttempts()
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|
|
|
|
// Put parks a question. Called on a clarify decision (cmd/mavend/clarify.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()
|
|
}
|
|
|
|
// Get returns the live parked question, or nil when there is none.
|
|
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 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
|
|
}
|