From 79eb43e9b9b754e03420f4a2c2e640131184130f Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 6 Jul 2026 04:16:47 +0400 Subject: [PATCH] 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 --- internal/dialogue/session.go | 109 ++++++++++++++++++++++++++++++ internal/dialogue/session_test.go | 98 +++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 internal/dialogue/session.go create mode 100644 internal/dialogue/session_test.go diff --git a/internal/dialogue/session.go b/internal/dialogue/session.go new file mode 100644 index 0000000..4161bd2 --- /dev/null +++ b/internal/dialogue/session.go @@ -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 +} + + diff --git a/internal/dialogue/session_test.go b/internal/dialogue/session_test.go new file mode 100644 index 0000000..a6d13bc --- /dev/null +++ b/internal/dialogue/session_test.go @@ -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") + } +}