package dialogue import ( "context" "encoding/json" "sync" "time" "github.com/kami/maven/internal/store" ) type Intent string const ( IntentAct Intent = "act" IntentReminder Intent = "reminder" IntentFact Intent = "fact" IntentNote Intent = "note" IntentQuery Intent = "query" IntentChat Intent = "chat" IntentSystem Intent = "system" ) type Slots struct { Time time.Time HasTime bool Key string Value string // payload for a fact key, mirrors router.Slots.Value HasKey bool Text string Fn string Args []string HasFn bool } // Turn represents one utterance in a multi-turn dialogue history. // Carried by Session.History for cross-intent reference and anaphora // resolution (a later turn's pronoun points to an earlier turn's entity). type Turn struct { Intent Intent Slots Slots Text string // raw utterance } // Candidate — one item she just read out loud, kept so his next words can // pick it ("второй", "первую сделал"). Vikunja #448. // // Bound at the moment she speaks the list, not resolved afterwards: the list // can change between two turns, and "второй" means the second thing she said, // not the second row of a fresh query. type Candidate struct { Kind string // what it is, e.g. "task" — the resolver dispatches on this Ref int64 // the row it points at Label string // what she called it, so she can repeat it back } type Session struct { Intent Intent Slots Slots // Utterance is what he actually said on this turn. It is deliberately // independent of Slots.Text: Text is an intent payload and several valid // routes leave it empty or replace it with a normalized subject. Dialogue // history needs the original words so a later pronoun can refer across // fact, query and chat boundaries without pretending a storage key is a // transcript. Utterance string // Conversational keeps an explicitly opened or chat-routed exchange alive // across later fact/query routes. It does not change what those turns do; // it only says their shared transcript uses the longer dialogue TTL. Conversational bool Timestamp time.Time TTL time.Duration History []Turn // prior turns in speaking order, oldest first; used for anaphora + cross-intent // Candidates — the list she just offered, in the order she said it. Empty // on every turn that offered no choice, which is most of them. Candidates []Candidate } func (s *Session) IsExpired(now time.Time) bool { return now.After(s.Timestamp.Add(s.TTL)) } // SessionPersister — the bit of the store the session needs, as an interface // so tests can swap it out. Data is an opaque blob: the store never looks // inside, we encode the session as JSON here. type SessionPersister interface { SaveDialogueSession(ctx context.Context, id string, data []byte, ts time.Time, ttl time.Duration) error DeleteDialogueSession(ctx context.Context, id string) error LoadDialogueSessions(ctx context.Context, now time.Time) ([]store.DialogueSessionRow, error) } // SessionStore keeps the live sessions in a map (the fast path) and mirrors // every write to the persister, so a daemon restart can load them back. type SessionStore struct { mu sync.RWMutex sessions map[string]*Session defaultTTL time.Duration persist SessionPersister // may be nil: memory only (tests, no-store paths) } // DefaultSessionTTL — how long a turn stays available to inherit from. Longer // than DefaultClarifyTTL because this is not a question waiting on an answer: // it is the last thing said, and a follow-up may land after a real pause. const DefaultSessionTTL = 2 * time.Minute func NewSessionStore(defaultTTL time.Duration) *SessionStore { if defaultTTL <= 0 { defaultTTL = DefaultSessionTTL } return &SessionStore{ sessions: make(map[string]*Session), defaultTTL: defaultTTL, } } // NewPersistentSessionStore — same store, but writes also go to the DB. // Call Load once after this to bring back sessions from a previous run. func NewPersistentSessionStore(defaultTTL time.Duration, p SessionPersister) *SessionStore { s := NewSessionStore(defaultTTL) s.persist = p return s } // Load — read the saved sessions back into memory. Anything past its TTL is // dropped (and deleted from the DB by the store), never revived. func (s *SessionStore) Load(ctx context.Context, now time.Time) error { if s.persist == nil { return nil } rows, err := s.persist.LoadDialogueSessions(ctx, now) if err != nil { return err } s.mu.Lock() defer s.mu.Unlock() for _, r := range rows { var sess Session if err := json.Unmarshal(r.Data, &sess); err != nil { // A blob we can't read is not worth failing a startup over. continue } if sess.TTL <= 0 { sess.TTL = r.TTL } if sess.IsExpired(now) { continue } s.sessions[r.ID] = &sess } return nil } 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() s.save(id, sess) } // SetCandidates attaches a just-spoken list to the live session. // // In place rather than through Put, because the turn was already remembered by // the time the answer was built: replacing the session here would drop the // slots the next follow-up inherits. No session, no candidates — a choice with // no turn behind it has nothing to be a choice about. func (s *SessionStore) SetCandidates(id string, now time.Time, cands []Candidate) { s.mu.Lock() sess, ok := s.sessions[id] if ok && !sess.IsExpired(now) { sess.Candidates = cands if len(cands) > 0 { // The list was spoken now. Its reference window begins with this // turn, not with whichever older turn created the session. Clearing // a spent list must not revive unrelated, stale dialogue slots. sess.Timestamp = now } } else { ok = false } s.mu.Unlock() if ok { s.save(id, sess) } } func (s *SessionStore) Delete(id string) { s.mu.Lock() delete(s.sessions, id) s.mu.Unlock() if s.persist != nil { _ = s.persist.DeleteDialogueSession(context.Background(), id) } } // save — mirror one session to the DB. Best effort: memory already has it, so // a write error costs us the restart safety net, not the current turn. func (s *SessionStore) save(id string, sess *Session) { if s.persist == nil { return } data, err := json.Marshal(sess) if err != nil { return } ts := sess.Timestamp if ts.IsZero() { ts = time.Now() } _ = s.persist.SaveDialogueSession(context.Background(), id, data, ts, sess.TTL) } 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.Value == "" && prev.Value != "" { out.Value = prev.Value } 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 }