Files
Maven/internal/dialogue/clarify.go
T
kami 9145b83100 Add the clarify data layer: a parked question with one missing slot
The router can already say "I am not sure" (Decision.Clarify) but the daemon
had nowhere to keep the request while it asked. PendingQuestion holds the
original slots, ClarifyStore parks one per dialogue id with a 90s TTL, and
Answer fills only the slots that were missing so an answer can never rewrite
what she already understood. Logic that uses this comes next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 02:20:28 +04:00

150 lines
3.6 KiB
Go

package dialogue
import (
"sync"
"time"
)
// Slot names one piece of information a turn needs. A question is always about
// exactly one of these.
type Slot string
const (
SlotTime Slot = "time"
SlotKey Slot = "key"
SlotFn Slot = "fn"
SlotText Slot = "text"
)
// MaxAttempts — she asks once and then drops the request. Asking twice about
// the same utterance reads as nagging, and the non-goals forbid that.
const MaxAttempts = 1
// PendingQuestion — a request she could not act on, parked while she waits for
// the one missing piece. The original slots are kept so the answer only has to
// carry the gap, not the whole request again.
type PendingQuestion struct {
Intent Intent
Slots Slots
Missing []Slot
Utterance string // the original request, so the answer inherits its wording
Asked time.Time
TTL time.Duration
Attempts int
}
// IsExpired — an answer that arrives after the TTL is a new request, not an
// answer. Same reasoning as the confirm gate: a stale question must not eat an
// unrelated later utterance.
func (q *PendingQuestion) IsExpired(now time.Time) bool {
return now.After(q.Asked.Add(q.TTL))
}
func (q *PendingQuestion) CanAsk() bool {
return q.Attempts < MaxAttempts
}
// Answer merges the parsed answer into the parked slots. It fills only the
// slots that were missing when the question was asked — an answer can never
// overwrite something she already understood, so a stray word in the answer
// cannot silently change the request.
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, out.HasTime = answer.Time, true
}
case SlotKey:
if !out.HasKey && answer.HasKey {
out.Key, out.HasKey = answer.Key, true
}
case SlotFn:
if !out.HasFn && answer.HasFn {
out.Fn, out.Args, out.HasFn = answer.Fn, answer.Args, true
}
case SlotText:
if out.Text == "" {
out.Text = text
}
}
}
if out.Text == "" {
out.Text = text
}
return out
}
// StillMissing returns the wanted slots that the given slots do not fill, in
// the order they were wanted. Empty result ⇒ the request can be acted on.
func StillMissing(want []Slot, s Slots) []Slot {
var out []Slot
for _, slot := range want {
filled := false
switch slot {
case SlotTime:
filled = s.HasTime
case SlotKey:
filled = s.HasKey
case SlotFn:
filled = s.HasFn
case SlotText:
filled = s.Text != ""
}
if !filled {
out = append(out, slot)
}
}
return out
}
// ClarifyStore holds the parked questions. One entry per dialogue id; a new
// question overwrites the old one (last-asked wins, single-user box).
type ClarifyStore struct {
mu sync.Mutex
questions map[string]*PendingQuestion
defaultTTL time.Duration
}
func NewClarifyStore(defaultTTL time.Duration) *ClarifyStore {
if defaultTTL <= 0 {
defaultTTL = 90 * time.Second
}
return &ClarifyStore{
questions: make(map[string]*PendingQuestion),
defaultTTL: defaultTTL,
}
}
// Get returns the live question for id, or nil. An expired question is dropped
// on read so the caller never sees one.
func (c *ClarifyStore) Get(id string, now time.Time) *PendingQuestion {
c.mu.Lock()
defer c.mu.Unlock()
q, ok := c.questions[id]
if !ok {
return nil
}
if q.IsExpired(now) {
delete(c.questions, id)
return nil
}
return q
}
func (c *ClarifyStore) Put(id string, q *PendingQuestion) {
if q.TTL <= 0 {
q.TTL = c.defaultTTL
}
c.mu.Lock()
c.questions[id] = q
c.mu.Unlock()
}
func (c *ClarifyStore) Delete(id string) {
c.mu.Lock()
delete(c.questions, id)
c.mu.Unlock()
}