Tonight's work as one branch: all 46 reviewed PRs, verified green #47
@@ -0,0 +1,149 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package dialogue
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var clarifyNow = time.Date(2026, 7, 31, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
func TestPendingQuestionAnswerFillsOnlyMissing(t *testing.T) {
|
||||
fireAt := clarifyNow.Add(time.Hour)
|
||||
q := &PendingQuestion{
|
||||
Intent: IntentReminder,
|
||||
Slots: Slots{Key: "mom", HasKey: true, Text: "напомни позвонить маме"},
|
||||
Missing: []Slot{SlotTime},
|
||||
}
|
||||
got := q.Answer("в 11", Slots{Time: fireAt, HasTime: true, Key: "other", HasKey: true})
|
||||
if !got.HasTime || !got.Time.Equal(fireAt) {
|
||||
t.Fatalf("missing time slot not filled: %+v", got)
|
||||
}
|
||||
if got.Key != "mom" {
|
||||
t.Fatalf("answer overwrote a filled slot: key=%q", got.Key)
|
||||
}
|
||||
if got.Text != "напомни позвонить маме" {
|
||||
t.Fatalf("answer overwrote the original text: %q", got.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingQuestionAnswerKeepsGapWhenAnswerIsEmpty(t *testing.T) {
|
||||
q := &PendingQuestion{Intent: IntentReminder, Missing: []Slot{SlotTime}}
|
||||
got := q.Answer("не знаю", Slots{})
|
||||
if got.HasTime {
|
||||
t.Fatal("empty answer must not fill the slot")
|
||||
}
|
||||
if len(StillMissing(q.Missing, got)) != 1 {
|
||||
t.Fatal("StillMissing should report the unfilled slot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStillMissing(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
want []Slot
|
||||
slots Slots
|
||||
left int
|
||||
}{
|
||||
{"all filled", []Slot{SlotTime, SlotKey}, Slots{HasTime: true, HasKey: true}, 0},
|
||||
{"time gap", []Slot{SlotTime}, Slots{HasKey: true}, 1},
|
||||
{"fn gap", []Slot{SlotFn}, Slots{}, 1},
|
||||
{"text filled", []Slot{SlotText}, Slots{Text: "hi"}, 0},
|
||||
{"nothing wanted", nil, Slots{}, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := StillMissing(tc.want, tc.slots); len(got) != tc.left {
|
||||
t.Errorf("%s: got %v, want %d left", tc.name, got, tc.left)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClarifyStoreExpiry(t *testing.T) {
|
||||
s := NewClarifyStore(90 * time.Second)
|
||||
s.Put("voice", &PendingQuestion{Asked: clarifyNow, Missing: []Slot{SlotTime}})
|
||||
|
||||
if s.Get("voice", clarifyNow.Add(30*time.Second)) == nil {
|
||||
t.Fatal("question inside the TTL should be live")
|
||||
}
|
||||
if s.Get("voice", clarifyNow.Add(2*time.Minute)) != nil {
|
||||
t.Fatal("question past the TTL should be dropped")
|
||||
}
|
||||
if s.Get("voice", clarifyNow) != nil {
|
||||
t.Fatal("an expired question must be deleted on read, not linger")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClarifyStoreDefaultTTL(t *testing.T) {
|
||||
s := NewClarifyStore(0)
|
||||
q := &PendingQuestion{Asked: clarifyNow}
|
||||
s.Put("voice", q)
|
||||
if q.TTL != 90*time.Second {
|
||||
t.Fatalf("default TTL not applied: %v", q.TTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanAskOnce(t *testing.T) {
|
||||
q := &PendingQuestion{}
|
||||
if !q.CanAsk() {
|
||||
t.Fatal("a fresh question should be askable")
|
||||
}
|
||||
q.Attempts = MaxAttempts
|
||||
if q.CanAsk() {
|
||||
t.Fatal("she must not ask twice")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user