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 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 +}