Merge pull request 'Bounded follow-up state: pending candidates and ordinal selection' (#141) from task/448-bounded-follow-up-state-pending-candidat into master

This commit was merged in pull request #141.
This commit is contained in:
2026-08-04 18:24:27 +02: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
}