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 }