dialogue, tasks: carry the list she just read (V-448)

Session.Candidates holds what she offered, in the order she offered it, and
SetCandidates attaches it in place so the turn already remembered keeps its
slots. tasks.Spoken is the list FormatRU actually named, so an ordinal and
the spoken order cannot drift apart.
This commit is contained in:
2026-08-04 04:26:10 +04:00
parent 6a85e71077
commit 7ab38cd7f7
3 changed files with 76 additions and 0 deletions
+35
View File
@@ -42,12 +42,27 @@ type Turn struct {
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
Timestamp time.Time
TTL time.Duration
History []Turn // most recent turns, newest last; 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 {
@@ -143,6 +158,26 @@ func (s *SessionStore) Put(id string, sess *Session) {
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
} 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)
+23
View File
@@ -268,3 +268,26 @@ func pluralTasksRU(n int) string {
}
return "задач"
}
// Spoken — the tasks FormatRU actually named, in the order it named them
// (Vikunja #448). "второй" has to mean the second thing she said, so the list
// an ordinal resolves against is built here and not by a caller guessing how
// the renderer split and truncated it.
func Spoken(ranked []Ranked) []Ranked {
var open, cands []Ranked
for _, r := range ranked {
if r.Status == StatusCandidate {
cands = append(cands, r)
} else {
open = append(open, r)
}
}
out := make([]Ranked, 0, 2*SpokenLimit)
for _, group := range [][]Ranked{open, cands} {
if len(group) > SpokenLimit {
group = group[:SpokenLimit]
}
out = append(out, group...)
}
return out
}