maven: dialogue state scaffold (task 6)
- New internal/dialogue/ package: Session, SessionStore, InheritSlots - Session with Intent, Slots, Timestamp, TTL - SessionStore: in-memory map with TTL expiry, thread-safe - InheritSlots: carries forward slots from previous turn - Tests: expiry, store put/get/expiry, default TTL, slot inheritance Co-Authored-By: opencode <opencode@anthropic.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
|||||||
|
package dialogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Intent string
|
||||||
|
|
||||||
|
const (
|
||||||
|
IntentAct Intent = "act"
|
||||||
|
IntentReminder Intent = "reminder"
|
||||||
|
IntentFact Intent = "fact"
|
||||||
|
IntentNote Intent = "note"
|
||||||
|
IntentQuery Intent = "query"
|
||||||
|
IntentSystem Intent = "system"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Slots struct {
|
||||||
|
Time time.Time
|
||||||
|
HasTime bool
|
||||||
|
Key string
|
||||||
|
HasKey bool
|
||||||
|
Text string
|
||||||
|
Fn string
|
||||||
|
Args []string
|
||||||
|
HasFn bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
Intent Intent
|
||||||
|
Slots Slots
|
||||||
|
Timestamp time.Time
|
||||||
|
TTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) IsExpired(now time.Time) bool {
|
||||||
|
return now.After(s.Timestamp.Add(s.TTL))
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
sessions map[string]*Session
|
||||||
|
defaultTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSessionStore(defaultTTL time.Duration) *SessionStore {
|
||||||
|
if defaultTTL <= 0 {
|
||||||
|
defaultTTL = 2 * time.Minute
|
||||||
|
}
|
||||||
|
return &SessionStore{
|
||||||
|
sessions: make(map[string]*Session),
|
||||||
|
defaultTTL: defaultTTL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) Get(id string, now time.Time) *Session {
|
||||||
|
s.mu.RLock()
|
||||||
|
sess, ok := s.sessions[id]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if sess.IsExpired(now) {
|
||||||
|
s.Delete(id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return sess
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) Put(id string, sess *Session) {
|
||||||
|
if sess.TTL <= 0 {
|
||||||
|
sess.TTL = s.defaultTTL
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.sessions[id] = sess
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) Delete(id string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.sessions, id)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func InheritSlots(prev, cur Slots) Slots {
|
||||||
|
out := cur
|
||||||
|
if !out.HasTime && prev.HasTime {
|
||||||
|
out.Time = prev.Time
|
||||||
|
out.HasTime = true
|
||||||
|
}
|
||||||
|
if !out.HasKey && prev.HasKey {
|
||||||
|
out.Key = prev.Key
|
||||||
|
out.HasKey = true
|
||||||
|
}
|
||||||
|
if out.Text == "" && prev.Text != "" {
|
||||||
|
out.Text = prev.Text
|
||||||
|
}
|
||||||
|
if !out.HasFn && prev.HasFn {
|
||||||
|
out.Fn = prev.Fn
|
||||||
|
out.HasFn = true
|
||||||
|
}
|
||||||
|
if len(out.Args) == 0 && len(prev.Args) > 0 {
|
||||||
|
out.Args = append([]string(nil), prev.Args...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package dialogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSessionExpiry(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)
|
||||||
|
sess := &Session{
|
||||||
|
Intent: IntentQuery,
|
||||||
|
Timestamp: now,
|
||||||
|
TTL: 2 * time.Minute,
|
||||||
|
}
|
||||||
|
if sess.IsExpired(now) {
|
||||||
|
t.Error("session should not be expired at creation time")
|
||||||
|
}
|
||||||
|
if sess.IsExpired(now.Add(1 * time.Minute)) {
|
||||||
|
t.Error("session should not be expired after 1 minute")
|
||||||
|
}
|
||||||
|
if !sess.IsExpired(now.Add(3 * time.Minute)) {
|
||||||
|
t.Error("session should be expired after 3 minutes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionStorePutGet(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)
|
||||||
|
store := NewSessionStore(2 * time.Minute)
|
||||||
|
|
||||||
|
if s := store.Get("nonexistent", now); s != nil {
|
||||||
|
t.Error("expected nil for non-existent session")
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := &Session{Intent: IntentQuery, Timestamp: now}
|
||||||
|
store.Put("user1", sess)
|
||||||
|
got := store.Get("user1", now)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected session after Put")
|
||||||
|
}
|
||||||
|
if got.Intent != IntentQuery {
|
||||||
|
t.Errorf("intent = %q, want query", got.Intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
later := now.Add(5 * time.Minute)
|
||||||
|
if s := store.Get("user1", later); s != nil {
|
||||||
|
t.Error("expected nil for expired session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionStoreDefaultTTL(t *testing.T) {
|
||||||
|
store := NewSessionStore(0)
|
||||||
|
if store.defaultTTL != 2*time.Minute {
|
||||||
|
t.Errorf("defaultTTL = %v, want 2m", store.defaultTTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInheritSlots(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
prev := Slots{Text: "Moscow", HasKey: true, Key: "location"}
|
||||||
|
cur := Slots{}
|
||||||
|
inherited := InheritSlots(prev, cur)
|
||||||
|
if !inherited.HasKey || inherited.Key != "location" {
|
||||||
|
t.Error("should inherit key from previous")
|
||||||
|
}
|
||||||
|
|
||||||
|
cur2 := Slots{HasKey: true, Key: "London"}
|
||||||
|
inherited2 := InheritSlots(prev, cur2)
|
||||||
|
if inherited2.Key != "London" {
|
||||||
|
t.Error("should keep current key when present")
|
||||||
|
}
|
||||||
|
|
||||||
|
prevTime := Slots{HasTime: true, Time: now}
|
||||||
|
inherited3 := InheritSlots(prevTime, Slots{})
|
||||||
|
if !inherited3.HasTime || !inherited3.Time.Equal(now) {
|
||||||
|
t.Error("should inherit time")
|
||||||
|
}
|
||||||
|
|
||||||
|
prevFn := Slots{HasFn: true, Fn: "status", Args: []string{"nginx"}}
|
||||||
|
inherited4 := InheritSlots(prevFn, Slots{})
|
||||||
|
if !inherited4.HasFn || inherited4.Fn != "status" {
|
||||||
|
t.Error("should inherit fn")
|
||||||
|
}
|
||||||
|
if len(inherited4.Args) != 1 || inherited4.Args[0] != "nginx" {
|
||||||
|
t.Error("should inherit args")
|
||||||
|
}
|
||||||
|
|
||||||
|
inherited5 := InheritSlots(prevFn, Slots{HasFn: true, Fn: "restart", Args: []string{"docker"}})
|
||||||
|
if len(inherited5.Args) != 1 || inherited5.Args[0] != "docker" {
|
||||||
|
t.Error("should keep current args")
|
||||||
|
}
|
||||||
|
|
||||||
|
prevText := Slots{Text: "какая погода в москве"}
|
||||||
|
inherited6 := InheritSlots(prevText, Slots{Text: ""})
|
||||||
|
if inherited6.Text != "какая погода в москве" {
|
||||||
|
t.Error("should inherit text when current is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user