From 7ab38cd7f72154db51e3fd8a67bec5f4cbb8c0a8 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 04:26:10 +0400 Subject: [PATCH] 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. --- docs/design.md | 18 ++++++++++++++++++ internal/dialogue/session.go | 35 +++++++++++++++++++++++++++++++++++ internal/tasks/rank.go | 23 +++++++++++++++++++++++ 3 files changed, 76 insertions(+) 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 +}