diff --git a/docs/design.md b/docs/design.md index 95e441f..858feea 100644 --- a/docs/design.md +++ b/docs/design.md @@ -223,6 +223,24 @@ Not alternatives — layers: Router contract: `[{"intent":, key?, value?, text?, verb?}, ...]` over 7 intents (`fact, reminder, note, query, act, chat, system`). +#### "второй" points at the list she just read + +Landed 2026-08-04 (Vikunja #448). The dialogue session carried the intent, the +slots and the history, and not the list. She recited five tasks, he said +"второй", and the word had nothing to point at. + +`Session.Candidates` holds what she just offered, bound at the moment she speaks +it and in the order she speaks it (`tasks.Spoken`). Binding afterwards would +resolve the word against a fresh query, and the list changes between two turns. +`cmd/mavend/ordinal.go` reads the position before routing and dispatches on the +candidate's kind. + +An ordinal with no verb is read back, not acted on — "второй" names a task, it +does not say what to do with it. With a verb ("первую сделал", "последнюю +убери") the task moves and the list is spent, because a second ordinal against a +list that no longer holds closes the wrong work. A position she never read is +answered with how many she did read, not routed as a fresh sentence. + #### Saying she got it wrong is a feature Landed 2026-08-04 (Vikunja #455). `Router.CorrectMisroute` could always append a diff --git a/internal/dialogue/session.go b/internal/dialogue/session.go index d7203f3..d71cbf1 100644 --- a/internal/dialogue/session.go +++ b/internal/dialogue/session.go @@ -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) diff --git a/internal/tasks/rank.go b/internal/tasks/rank.go index e2f88fd..d3fe75e 100644 --- a/internal/tasks/rank.go +++ b/internal/tasks/rank.go @@ -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 +}