Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39d83a33e8 | |||
| 925ce223a0 |
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package dialogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var base = time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
func TestPendingQuestionIsExpired(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
ttl time.Duration
|
||||||
|
now time.Time
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"fresh", time.Minute, base.Add(10 * time.Second), false},
|
||||||
|
{"exactly at ttl", time.Minute, base.Add(time.Minute), false},
|
||||||
|
{"past ttl", time.Minute, base.Add(2 * time.Minute), true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
q := &PendingQuestion{Asked: base, TTL: tc.ttl}
|
||||||
|
if got := q.IsExpired(tc.now); got != tc.want {
|
||||||
|
t.Fatalf("IsExpired = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClarifyStoreGetPutDelete(t *testing.T) {
|
||||||
|
s := NewClarifyStore(time.Minute)
|
||||||
|
|
||||||
|
if got := s.Get("voice", base); got != nil {
|
||||||
|
t.Fatalf("empty store returned %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
q := &PendingQuestion{Intent: IntentReminder, Missing: []Slot{SlotTime}, Asked: base}
|
||||||
|
s.Put("voice", q)
|
||||||
|
if q.TTL != time.Minute {
|
||||||
|
t.Fatalf("Put did not apply the default TTL, got %v", q.TTL)
|
||||||
|
}
|
||||||
|
if got := s.Get("voice", base.Add(time.Second)); got != q {
|
||||||
|
t.Fatalf("Get returned %+v, want the parked question", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired questions are dropped on read, not returned.
|
||||||
|
if got := s.Get("voice", base.Add(2*time.Minute)); got != nil {
|
||||||
|
t.Fatalf("expired Get returned %+v", got)
|
||||||
|
}
|
||||||
|
if got := s.Get("voice", base); got != nil {
|
||||||
|
t.Fatalf("expired question was not deleted: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Put("voice", &PendingQuestion{Asked: base, TTL: time.Hour})
|
||||||
|
s.Delete("voice")
|
||||||
|
if got := s.Get("voice", base); got != nil {
|
||||||
|
t.Fatalf("Delete left %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClarifyStoreDefaultTTL(t *testing.T) {
|
||||||
|
s := NewClarifyStore(0)
|
||||||
|
q := &PendingQuestion{Asked: base}
|
||||||
|
s.Put("voice", q)
|
||||||
|
if q.TTL != 90*time.Second {
|
||||||
|
t.Fatalf("TTL = %v, want 90s", q.TTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnswerFillsOnlyMissingSlots(t *testing.T) {
|
||||||
|
answerTime := base.Add(3 * time.Hour)
|
||||||
|
other := base.Add(9 * time.Hour)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
parked Slots
|
||||||
|
missing []Slot
|
||||||
|
text string
|
||||||
|
answer Slots
|
||||||
|
want Slots
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "fills the missing time",
|
||||||
|
parked: Slots{Text: "напомни позвонить"},
|
||||||
|
missing: []Slot{SlotTime},
|
||||||
|
text: "в три",
|
||||||
|
answer: Slots{Time: answerTime, HasTime: true},
|
||||||
|
want: Slots{Text: "напомни позвонить", Time: answerTime, HasTime: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "does not overwrite a filled time",
|
||||||
|
parked: Slots{Time: other, HasTime: true},
|
||||||
|
missing: []Slot{SlotTime},
|
||||||
|
text: "в три",
|
||||||
|
answer: Slots{Time: answerTime, HasTime: true},
|
||||||
|
want: Slots{Time: other, HasTime: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ignores slots that were not missing",
|
||||||
|
parked: Slots{Key: "water", HasKey: true},
|
||||||
|
missing: []Slot{SlotValue},
|
||||||
|
text: "два литра",
|
||||||
|
answer: Slots{Key: "sleep", HasKey: true, Value: "2l"},
|
||||||
|
want: Slots{Key: "water", HasKey: true, Value: "2l"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fills key when empty",
|
||||||
|
parked: Slots{},
|
||||||
|
missing: []Slot{SlotKey, SlotValue},
|
||||||
|
text: "воды",
|
||||||
|
answer: Slots{Key: "water", HasKey: true, Value: `"drank"`},
|
||||||
|
want: Slots{Key: "water", HasKey: true, Value: `"drank"`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fills fn and its args",
|
||||||
|
parked: Slots{},
|
||||||
|
missing: []Slot{SlotFn},
|
||||||
|
text: "перезапусти nginx",
|
||||||
|
answer: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
||||||
|
want: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "keeps existing args when fn was already known",
|
||||||
|
parked: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
||||||
|
missing: []Slot{SlotFn},
|
||||||
|
text: "останови postgres",
|
||||||
|
answer: Slots{Fn: "stop", Args: []string{"postgres"}, HasFn: true},
|
||||||
|
want: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "raw answer becomes the text when nothing was parsed",
|
||||||
|
parked: Slots{},
|
||||||
|
missing: []Slot{SlotText},
|
||||||
|
text: "купить хлеб",
|
||||||
|
answer: Slots{},
|
||||||
|
want: Slots{Text: "купить хлеб"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parsed text wins over the raw answer",
|
||||||
|
parked: Slots{},
|
||||||
|
missing: []Slot{SlotText},
|
||||||
|
text: "запиши купить хлеб",
|
||||||
|
answer: Slots{Text: "купить хлеб"},
|
||||||
|
want: Slots{Text: "купить хлеб"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty answer leaves the slot missing",
|
||||||
|
parked: Slots{Text: "напомни"},
|
||||||
|
missing: []Slot{SlotTime},
|
||||||
|
text: "не знаю",
|
||||||
|
answer: Slots{},
|
||||||
|
want: Slots{Text: "напомни"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
q := &PendingQuestion{Slots: tc.parked, Missing: tc.missing, Asked: base}
|
||||||
|
got := q.Answer(tc.text, tc.answer)
|
||||||
|
if got.Time != tc.want.Time || got.HasTime != tc.want.HasTime ||
|
||||||
|
got.Key != tc.want.Key || got.HasKey != tc.want.HasKey ||
|
||||||
|
got.Value != tc.want.Value || got.Text != tc.want.Text ||
|
||||||
|
got.Fn != tc.want.Fn || got.HasFn != tc.want.HasFn {
|
||||||
|
t.Fatalf("Answer = %+v, want %+v", got, tc.want)
|
||||||
|
}
|
||||||
|
if len(got.Args) != len(tc.want.Args) {
|
||||||
|
t.Fatalf("Args = %v, want %v", got.Args, tc.want.Args)
|
||||||
|
}
|
||||||
|
for i := range got.Args {
|
||||||
|
if got.Args[i] != tc.want.Args[i] {
|
||||||
|
t.Fatalf("Args = %v, want %v", got.Args, tc.want.Args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanAskCapsAtOneQuestion(t *testing.T) {
|
||||||
|
if MaxAttempts != 1 {
|
||||||
|
t.Fatalf("MaxAttempts = %d, want 1 (Maven asks once, she is not a nag)", MaxAttempts)
|
||||||
|
}
|
||||||
|
q := &PendingQuestion{Asked: base}
|
||||||
|
if !q.CanAsk() {
|
||||||
|
t.Fatal("a fresh question should be askable")
|
||||||
|
}
|
||||||
|
q.Attempts = MaxAttempts
|
||||||
|
if q.CanAsk() {
|
||||||
|
t.Fatal("the question should not be asked twice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStillMissing(t *testing.T) {
|
||||||
|
want := []Slot{SlotTime, SlotKey, SlotValue, SlotFn, SlotText}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
slots Slots
|
||||||
|
want []Slot
|
||||||
|
}{
|
||||||
|
{"all empty", Slots{}, want},
|
||||||
|
{
|
||||||
|
name: "all filled",
|
||||||
|
slots: Slots{Time: base, HasTime: true, Key: "water", HasKey: true, Value: "1l", Fn: "restart", HasFn: true, Text: "t"},
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only value left",
|
||||||
|
slots: Slots{Time: base, HasTime: true, Key: "water", HasKey: true, Fn: "restart", HasFn: true, Text: "t"},
|
||||||
|
want: []Slot{SlotValue},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := StillMissing(want, tc.slots)
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Fatalf("StillMissing = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tc.want[i] {
|
||||||
|
t.Fatalf("StillMissing = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ type Slots struct {
|
|||||||
Time time.Time
|
Time time.Time
|
||||||
HasTime bool
|
HasTime bool
|
||||||
Key string
|
Key string
|
||||||
|
Value string // payload for a fact key, mirrors router.Slots.Value
|
||||||
HasKey bool
|
HasKey bool
|
||||||
Text string
|
Text string
|
||||||
Fn string
|
Fn string
|
||||||
@@ -104,6 +105,9 @@ func InheritSlots(prev, cur Slots) Slots {
|
|||||||
out.Key = prev.Key
|
out.Key = prev.Key
|
||||||
out.HasKey = true
|
out.HasKey = true
|
||||||
}
|
}
|
||||||
|
if out.Value == "" && prev.Value != "" {
|
||||||
|
out.Value = prev.Value
|
||||||
|
}
|
||||||
if out.Text == "" && prev.Text != "" {
|
if out.Text == "" && prev.Text != "" {
|
||||||
out.Text = prev.Text
|
out.Text = prev.Text
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,4 +113,14 @@ func TestInheritSlots(t *testing.T) {
|
|||||||
if inherited6.Text != "какая погода в москве" {
|
if inherited6.Text != "какая погода в москве" {
|
||||||
t.Error("should inherit text when current is empty")
|
t.Error("should inherit text when current is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prevValue := Slots{Key: "water", HasKey: true, Value: `"drank"`}
|
||||||
|
inherited7 := InheritSlots(prevValue, Slots{})
|
||||||
|
if inherited7.Value != `"drank"` {
|
||||||
|
t.Error("should inherit value when current is empty")
|
||||||
|
}
|
||||||
|
kept := InheritSlots(prevValue, Slots{Value: "2l"})
|
||||||
|
if kept.Value != "2l" {
|
||||||
|
t.Error("should keep current value")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user