1d02ba8936
Five disjoint values tracking which component selected the exact function: grammar_fixed, grammar_matcher, extractor_raw, extractor_llm_text, fallback_matcher. Slots.ResolvedBy carries provenance at the selection point.
258 lines
7.4 KiB
Go
258 lines
7.4 KiB
Go
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
|
|
// ResolvedBy — which component selected the exact function. Mirrors
|
|
// router.Slots.ResolvedBy. Carried as a string because dialogue cannot
|
|
// import router (import cycle).
|
|
ResolvedBy string
|
|
}
|
|
|
|
// 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
|
|
}
|