Compare commits

...

2 Commits

Author SHA1 Message Date
claude a1f811d4c8 mavend: he can pick one by position (V-448)
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.
2026-08-04 04:26:10 +04:00
claude 7ab38cd7f7 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.
2026-08-04 04:26:10 +04:00
7 changed files with 368 additions and 1 deletions
+11 -1
View File
@@ -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
+145
View File
@@ -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
}
+128
View File
@@ -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)
}
+8
View File
@@ -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
+18
View File
@@ -223,6 +223,24 @@ Not alternatives — layers:
Router contract: `[{"intent":<enum>, 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
+35
View File
@@ -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)
+23
View File
@@ -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
}