package main import ( "context" "fmt" "log" "strings" "unicode" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/lexicon" "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. // The position words come from the lexicon, which lists every form with its // position and "последний" as -1 (V-522). They used to be stem prefixes here — // {"перв", 1}, {"втор", 2} — which is the shape that sweep removed: a stem // decides meaning by guessing where a word ends, and "трет" also opens // "third-party". The lexicon runs to twelve rather than five, so he can pick // past the fifth of a longer list; resolveCandidate already answers a position // she did not read. // candidateDigits — "второй" said as a number. Matched whole, never by prefix: // "15" starts with "1" and is a time, not a position. Digits are not a Russian // word list, so they stay here rather than in the lexicon. var candidateDigits = map[string]int{"1": 1, "2": 2, "3": 3, "4": 4, "5": 5} // 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. toks := strings.FieldsFunc(strings.ToLower(text), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) for i, tok := range toks { if n, ok := candidateDigits[tok]; ok { return n, true } // A spoken half hour names the hour it is entering with the same // genitive ordinal: "в половине восьмого" is 07:30, not the eighth // thing she read out. She reads a list and he answers with a time // often enough that this has to be declined here, or the reminder // becomes a selection. if i > 0 && lexicon.IsHalfHour(toks[i-1]) { continue } if n, ok := lexicon.Ordinal(tok); ok { return n, 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 }