Tonight's work as one branch: all 46 reviewed PRs, verified green #47

Merged
kami merged 135 commits from integration/jul31 into master 2026-07-31 19:41:52 +02:00
2 changed files with 242 additions and 0 deletions
Showing only changes of commit 9145b83100 - Show all commits
+149
View File
@@ -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()
}
+93
View File
@@ -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")
}
}