Add the pending-question data layer for slot clarification
PendingQuestion plus ClarifyStore: same shape, locking and expiry as SessionStore. Answer fills only the missing slots and never overwrites a filled one. No wiring yet — TODOs mark the daemon hooks. Reviewer: MaxAttempts is 1 on purpose (Maven asks once, she is not a nag). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
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
|
||||
)
|
||||
|
||||
// MaxAttempts is 1 because Maven is not a nag (DESIGN.md § Non-goals). She asks
|
||||
// one clarifying question. If the answer still leaves the slot empty she drops
|
||||
// the request instead of asking again.
|
||||
const MaxAttempts = 1
|
||||
|
||||
// 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; capped by 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 < MaxAttempts
|
||||
}
|
||||
|
||||
// TODO: the daemon will phrase the question text from Missing (one short ru
|
||||
// question per Slot, feminine self-reference) and speak it here.
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: the daemon will Put a question here when Decision.Clarify fires, in
|
||||
// place of the flat "не разобрала" reply (cmd/mavend/voice.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()
|
||||
}
|
||||
|
||||
// TODO: the daemon will Get on the next turn, parse that turn into Slots, call
|
||||
// Answer, and Delete — the open-question twin of resolveConfirm.
|
||||
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 filled, and an already filled slot is
|
||||
// never overwritten — the answer completes the original request, it does not
|
||||
// restate it. 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 !out.HasTime && answer.HasTime {
|
||||
out.Time = answer.Time
|
||||
out.HasTime = true
|
||||
}
|
||||
case SlotKey:
|
||||
if !out.HasKey && answer.HasKey {
|
||||
out.Key = answer.Key
|
||||
out.HasKey = true
|
||||
}
|
||||
case SlotValue:
|
||||
if out.Value == "" && answer.Value != "" {
|
||||
out.Value = answer.Value
|
||||
}
|
||||
case SlotFn:
|
||||
if !out.HasFn && answer.HasFn {
|
||||
out.Fn = answer.Fn
|
||||
out.HasFn = true
|
||||
if len(out.Args) == 0 {
|
||||
out.Args = append([]string(nil), answer.Args...)
|
||||
}
|
||||
}
|
||||
case SlotText:
|
||||
if out.Text == "" {
|
||||
if answer.Text != "" {
|
||||
out.Text = answer.Text
|
||||
} else {
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user