a1f811d4c8
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.
146 lines
5.3 KiB
Go
146 lines
5.3 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(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
|
|
}
|