From a1f811d4c882432f410f85134e04eecef7b9bf8d Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 04:26:10 +0400 Subject: [PATCH] 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