From 7ab38cd7f72154db51e3fd8a67bec5f4cbb8c0a8 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 04:26:10 +0400 Subject: [PATCH 1/2] 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 +} From a1f811d4c882432f410f85134e04eecef7b9bf8d Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 04:26:10 +0400 Subject: [PATCH 2/2] mavend: he can pick one by position (V-448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read before routing and only when a list is bound: with nothing offered, "второй" is an ordinary word and keeps routing. No verb reads it back rather than guessing what to do with it. --- cmd/mavend/actions_task.go | 12 ++- cmd/mavend/ordinal.go | 145 +++++++++++++++++++++++++++++++++++++ cmd/mavend/ordinal_test.go | 128 ++++++++++++++++++++++++++++++++ cmd/mavend/voice.go | 8 ++ 4 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 cmd/mavend/ordinal.go create mode 100644 cmd/mavend/ordinal_test.go diff --git a/cmd/mavend/actions_task.go b/cmd/mavend/actions_task.go index 99187f0..024cb46 100644 --- a/cmd/mavend/actions_task.go +++ b/cmd/mavend/actions_task.go @@ -4,6 +4,7 @@ import ( "context" "log" + "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" @@ -70,7 +71,16 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string, log.Printf("voice: list tasks: %v", err) return "не получилось посмотреть задачи.", true } - return tasks.FormatRU(tasks.Rank(taskItems(live), h.now())), true + ranked := tasks.Rank(taskItems(live), h.now()) + // Bind what she is about to say, in the order she says it, so "второй" + // means the second task he heard (ordinal.go). + spoken := tasks.Spoken(ranked) + cands := make([]dialogue.Candidate, 0, len(spoken)) + for _, r := range spoken { + cands = append(cands, dialogue.Candidate{Kind: "task", Ref: r.ID, Label: r.Text}) + } + h.offerCandidates(cands) + return tasks.FormatRU(ranked), true } // taskItems maps wire rows onto the ranker's input. Written here rather than in diff --git a/cmd/mavend/ordinal.go b/cmd/mavend/ordinal.go new file mode 100644 index 0000000..6b2cf61 --- /dev/null +++ b/cmd/mavend/ordinal.go @@ -0,0 +1,145 @@ +package main + +import ( + "context" + "fmt" + "log" + "strings" + "unicode" + + "github.com/kami/maven/internal/dialogue" + "github.com/kami/maven/internal/store" +) + +// Ordinal selection over a list she just read (Vikunja #448). +// +// The dialogue session already carried the intent, the slots and the history. +// What it did not carry was the list: she recited five tasks, he said "второй", +// and there was nothing for that word to point at, so it routed as a fresh +// utterance and meant nothing. +// +// Candidates are bound when she speaks the list, in the order she spoke it (see +// tasks.Spoken). Binding afterwards would resolve "второй" against a fresh +// query, and the list can change between two turns. +// +// An ordinal with no verb is read back, not acted on: "второй" names a task, it +// does not say what to do with it. Acting on the bare word would guess, and a +// wrong guess here closes work he never finished. + +// candidateOrdinals — the words that pick a position, by index. Prefix match, +// because Russian declines them: "первый", "первую", "первое". +var candidateOrdinals = []struct { + word string + nth int +}{ + {"перв", 1}, {"втор", 2}, {"трет", 3}, {"четв", 4}, {"пят", 5}, + {"first", 1}, {"second", 2}, {"third", 3}, +} + +// candidateDigits — "второй" said as a number. Matched whole, never by prefix: +// "15" starts with "1" and is a time, not a position. +var candidateDigits = map[string]int{"1": 1, "2": 2, "3": 3, "4": 4, "5": 5} + +// candidateLast — "последний" picks the end of the list whatever its length. +var candidateLast = []string{"последн", "last"} + +// parseOrdinal reads which position he named. 0 and false when he named none. +// A negative result means the last one. +func parseOrdinal(text string) (int, bool) { + // Token by token, not substring: " 1" would otherwise match inside + // "напомни в 15:00" and turn a reminder into a selection. + for _, tok := range strings.FieldsFunc(strings.ToLower(text), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) { + for _, w := range candidateLast { + if strings.HasPrefix(tok, w) { + return -1, true + } + } + if n, ok := candidateDigits[tok]; ok { + return n, true + } + for _, o := range candidateOrdinals { + // Prefix, because Russian declines them: "первый", "первую". + if strings.HasPrefix(tok, o.word) { + return o.nth, true + } + } + } + return 0, false +} + +// candidateVerbs — what he wants done with the one he picked. Nothing here is +// destructive: a task moves forward or is dropped, and both are recorded with a +// provenance the /tasks page shows. +var candidateVerbs = []struct { + words []string + status string + say string +}{ + {[]string{"готов", "сделал", "выполнил", "закрыл", "done"}, store.TaskDone, "закрыла"}, + {[]string{"не надо", "убери", "отмени", "не буду", "drop"}, store.TaskDropped, "убрала"}, + {[]string{"подтвержда", "беру", "да,", "буду делать"}, store.TaskOpen, "взяла в работу"}, +} + +func parseCandidateVerb(text string) (status, say string, ok bool) { + s := strings.ToLower(strings.TrimSpace(text)) + for _, v := range candidateVerbs { + for _, w := range v.words { + if strings.Contains(s, w) { + return v.status, v.say, true + } + } + } + return "", "", false +} + +// offerCandidates records the list she just read, so his next words can pick +// from it. Best effort: no session store, or a session that expired between the +// question and the answer, means the words route normally. +func (h *reactiveHandler) offerCandidates(cands []dialogue.Candidate) { + if h.dialogueSessions == nil || len(cands) == 0 { + return + } + h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), cands) +} + +// resolveCandidate handles "второй", "первую сделал", "последнюю убери" against +// the list she just read. +func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string) (string, bool) { + if h.dialogueSessions == nil { + return "", false + } + sess := h.dialogueSessions.Get(voiceDialogueID, h.now()) + if sess == nil || len(sess.Candidates) == 0 { + return "", false + } + nth, ok := parseOrdinal(text) + if !ok { + return "", false + } + if nth < 0 { + nth = len(sess.Candidates) + } + if nth > len(sess.Candidates) { + // Claim the turn: he is picking from her list and named a position she + // did not read. Routing it fresh would answer something else entirely. + return fmt.Sprintf("я назвала только %d.", len(sess.Candidates)), true + } + pick := sess.Candidates[nth-1] + status, say, hasVerb := parseCandidateVerb(text) + if !hasVerb || pick.Kind != "task" { + // Read it back and keep the list: naming one is often the first half of + // a sentence, and the second half is the next turn. + return pick.Label, true + } + if err := h.api.SetTaskStatus(ctx, pick.Ref, status, h.now(), "tap:voice"); err != nil { + log.Printf("voice: candidate %d → %s: %v", pick.Ref, status, err) + return "не получилось изменить задачу.", true + } + // Spent: the list she read is no longer the list, and a second ordinal + // against it would close the wrong task. + h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), nil) + log.Printf("voice: candidate %d (%q) → %s", pick.Ref, pick.Label, status) + return say + ": " + pick.Label, true +} diff --git a/cmd/mavend/ordinal_test.go b/cmd/mavend/ordinal_test.go new file mode 100644 index 0000000..cf63bd0 --- /dev/null +++ b/cmd/mavend/ordinal_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/dialogue" + "github.com/kami/maven/internal/store" +) + +func TestParseOrdinalReadsThePosition(t *testing.T) { + cases := []struct { + text string + want int + ok bool + }{ + {"второй", 2, true}, + {"вторую сделал", 2, true}, + {"первую убери", 1, true}, + {"последнюю не надо", -1, true}, + {"3", 3, true}, + {"the second one", 2, true}, + // No position named. + {"какие у меня задачи", 0, false}, + {"", 0, false}, + // A digit inside a time is not a position. + {"напомни в 15:00", 0, false}, + } + for _, c := range cases { + got, ok := parseOrdinal(c.text) + if ok != c.ok || (ok && got != c.want) { + t.Errorf("parseOrdinal(%q) = %d,%v; want %d,%v", c.text, got, ok, c.want, c.ok) + } + } +} + +func TestOrdinalPassesWithNothingOffered(t *testing.T) { + h, _, _ := newClarifyHandler(t) + if _, handled := h.resolveCandidate(context.Background(), "второй"); handled { + t.Error("an ordinal with no list behind it was claimed") + } +} + +func TestOrdinalReadsBackWithoutAVerb(t *testing.T) { + h, st, _ := newClarifyHandler(t) + ctx := context.Background() + ids := seedTasks(t, st, "купить хлеб", "позвонить маме") + putCandidates(h, ids, "купить хлеб", "позвонить маме") + + reply, handled := h.resolveCandidate(ctx, "второй") + if !handled || !strings.Contains(reply, "позвонить маме") { + t.Fatalf("a bare ordinal did not read the task back: %q handled=%v", reply, handled) + } + // Still live: naming one is often the first half of a sentence. + if _, handled := h.resolveCandidate(ctx, "первый"); !handled { + t.Error("the list was spent by a read-back") + } +} + +func TestOrdinalWithAVerbMovesTheTask(t *testing.T) { + h, st, _ := newClarifyHandler(t) + ctx := context.Background() + ids := seedTasks(t, st, "купить хлеб", "позвонить маме") + putCandidates(h, ids, "купить хлеб", "позвонить маме") + + reply, handled := h.resolveCandidate(ctx, "первую сделал") + if !handled || !strings.Contains(reply, "купить хлеб") { + t.Fatalf("the pick was not acted on: %q handled=%v", reply, handled) + } + live, err := st.ListTasks(ctx, "live") + if err != nil { + t.Fatalf("list tasks: %v", err) + } + for _, task := range live { + if task.ID == ids[0] { + t.Fatalf("task %d is still live after he closed it", task.ID) + } + } + // Spent: a second ordinal against a list that no longer holds would close + // the wrong task. + if _, handled := h.resolveCandidate(ctx, "второй"); handled { + t.Error("the list survived the pick it was spent on") + } +} + +func TestOrdinalPastTheEndSaysHowMany(t *testing.T) { + h, st, _ := newClarifyHandler(t) + ids := seedTasks(t, st, "купить хлеб") + putCandidates(h, ids, "купить хлеб") + + reply, handled := h.resolveCandidate(context.Background(), "третий") + if !handled || !strings.Contains(reply, "1") { + t.Fatalf("a position she never read was not answered: %q handled=%v", reply, handled) + } +} + +// ordinalNow — a fixed capture time; the ranker only needs the rows to exist. +var ordinalNow = time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) + +func seedTasks(t *testing.T, st *store.Store, texts ...string) []int64 { + t.Helper() + ctx := context.Background() + var ids []int64 + for _, text := range texts { + res, err := st.CaptureTask(ctx, store.Task{ + Text: text, + Source: "tap:voice", + Status: store.TaskOpen, + CreatedTs: ordinalNow, + }) + if err != nil { + t.Fatalf("capture task: %v", err) + } + ids = append(ids, res.ID) + } + return ids +} + +func putCandidates(h *reactiveHandler, ids []int64, labels ...string) { + cands := make([]dialogue.Candidate, 0, len(ids)) + for i, id := range ids { + cands = append(cands, dialogue.Candidate{Kind: "task", Ref: id, Label: labels[i]}) + } + h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{Timestamp: h.now()}) + h.offerCandidates(cands) +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index a13dc6f..cf97920 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -308,6 +308,14 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour return withNotice(expiredNotice, reply) } + // 4e. ordinal selection — "второй", "первую сделал" pick from the list she + // just read (ordinal.go). Before routing, and only when a list is actually + // bound to the session: with nothing offered, "второй" is an ordinary word + // and keeps routing. + if reply, handled := h.resolveCandidate(ctx, text); handled { + return withNotice(expiredNotice, reply) + } + // 5. route. An elliptical follow-up — "а завтра?" — is answered from the // previous turn instead (continuation.go): the intent is the part it is // missing, so no amount of routing recovers it, and the model's guess