Files
Maven/internal/dialogue/session.go
T
claude 85a3397bf4 Cancel a reminder by voice, and honour a refusal (V-719)
reminder_cancel.go is a stateful pre-route resolver ahead of a parked
clarification and the statistical cascade. It accepts only an addressed
command-position imperative plus the reminder or alarm noun, so questions,
reported speech, past-tense reports and prohibitions establish no mutation
authority. Subject terms keep negation and quantity, and a parsed time
passes the same resolved-hour gate as capture.

One match cancels through the typed IPC method. Several are stored as
session candidates in the spoken order, capped at five, and only a whole
affirmative ordinal consumes that list: re-querying on the follow-up would
let a state change move the ordinal underneath him. No match, an unread
time, a spent ordinal and an ambiguous delivery result are all explicit
no-ops.

command_prohibition.go is the first mutation boundary in a turn. A direct
prohibition clears the three confirmation slots under their shared mutex,
so a later bare "да" cannot revive authority he has just revoked. A parked
clarify question is not authority and survives, suspended and repeated.
refusesCommand is the same belt at the executor entry points, checked
against the original utterance so a model rewriting Slots.Text cannot get
around it.

The rung is named in preRouteLadder, so /trace records whether it won or
declined on every surface.

--no-verify: master is the working branch this session by the owner's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:19:25 +04:00

254 lines
7.2 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
}
// 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
}