9d80a39a30
The clarify store was keyed per reach in V-466. The dialogue session was not: five call sites read and wrote the constant voiceDialogueID, so anaphora, history and the ordinal candidate list were one slot for the whole daemon. The candidate list is the half that cost something. She recites tasks at the mic, he types "первую сделал" on /chat, and it closes the second task he heard out loud on a surface that never showed him a list. Now every one of those sites reads dialogueIDOf(ctx), which handleText and the voice path already set. resolveCandidate also wrote resolved_by "tap:voice" for every pick, including a typed one. It takes the turn's source now. A row that lies about where it came from is worse than no row. Anaphora across surfaces was the other reading — one continuous conversation with her, any surface. Rejected: a phone open while he talks is the case this box hits, and two clients sharing one slot trample each other.
146 lines
5.4 KiB
Go
146 lines
5.4 KiB
Go
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(ctx context.Context, cands []dialogue.Candidate) {
|
|
if h.dialogueSessions == nil || len(cands) == 0 {
|
|
return
|
|
}
|
|
h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), cands)
|
|
}
|
|
|
|
// resolveCandidate handles "второй", "первую сделал", "последнюю убери" against
|
|
// the list she just read.
|
|
func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string, src turnSource) (string, bool) {
|
|
if h.dialogueSessions == nil {
|
|
return "", false
|
|
}
|
|
sess := h.dialogueSessions.Get(dialogueIDOf(ctx), 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(), string(src)); 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(dialogueIDOf(ctx), h.now(), nil)
|
|
log.Printf("voice: candidate %d (%q) → %s", pick.Ref, pick.Label, status)
|
|
return say + ": " + pick.Label, true
|
|
}
|