85a3397bf4
reminder_cancel.go is a stateful pre-route resolver ahead of a parked clarification and the statistical cascade. It accepts only an addressed command-position imperative plus the reminder or alarm noun, so questions, reported speech, past-tense reports and prohibitions establish no mutation authority. Subject terms keep negation and quantity, and a parsed time passes the same resolved-hour gate as capture. One match cancels through the typed IPC method. Several are stored as session candidates in the spoken order, capped at five, and only a whole affirmative ordinal consumes that list: re-querying on the follow-up would let a state change move the ordinal underneath him. No match, an unread time, a spent ordinal and an ambiguous delivery result are all explicit no-ops. command_prohibition.go is the first mutation boundary in a turn. A direct prohibition clears the three confirmation slots under their shared mutex, so a later bare "да" cannot revive authority he has just revoked. A parked clarify question is not authority and survives, suspended and repeated. refusesCommand is the same belt at the executor entry points, checked against the original utterance so a model rewriting Slots.Text cannot get around it. The rung is named in preRouteLadder, so /trace records whether it won or declined on every surface. --no-verify: master is the working branch this session by the owner's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
7.6 KiB
Go
203 lines
7.6 KiB
Go
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/router"
|
|
"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
|
|
}
|
|
|
|
// parseReminderCancelChoice is intentionally narrower than parseOrdinal. A
|
|
// task ordinal may appear inside a sentence carrying its transition verb; the
|
|
// reminder list was already offered specifically for cancellation, so the next
|
|
// mutation requires the whole turn to be one affirmative position answer.
|
|
// Questions, negation, two positions and new requests all decline and route as
|
|
// fresh turns instead of cancelling whichever ordinal happened to appear.
|
|
func parseReminderCancelChoice(text string) (int, bool) {
|
|
if router.IsQuestionShaped(text) {
|
|
return 0, false
|
|
}
|
|
tokens := turnTokens(text)
|
|
nth, positions := 0, 0
|
|
for _, tok := range tokens {
|
|
if reminderCancelNegation(tok) {
|
|
return 0, false
|
|
}
|
|
if n, ok := candidateDigits[tok]; ok {
|
|
nth, positions = n, positions+1
|
|
continue
|
|
}
|
|
if n, ok := lexicon.Ordinal(tok); ok {
|
|
nth, positions = n, positions+1
|
|
continue
|
|
}
|
|
if lexicon.IsFillerParticle(tok) || reminderCancelVerbs[tok] ||
|
|
isReminderCancelTarget(tok) || reminderCancelFrame[tok] {
|
|
continue
|
|
}
|
|
switch tok {
|
|
case "номер", "вариант", "number", "option", "one":
|
|
continue
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
return nth, positions == 1
|
|
}
|
|
|
|
// 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
|
|
}
|
|
reminderList := true
|
|
for _, candidate := range sess.Candidates {
|
|
if candidate.Kind != "reminder-cancel" {
|
|
reminderList = false
|
|
break
|
|
}
|
|
}
|
|
if reminderList && classifyConfirm(text) == confirmNo {
|
|
h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), nil)
|
|
return "хорошо, ничего не отменяю.", true
|
|
}
|
|
nth, ok := parseOrdinal(text)
|
|
if reminderList {
|
|
nth, ok = parseReminderCancelChoice(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]
|
|
if pick.Kind == "reminder-cancel" {
|
|
// Unlike a task list, this list was offered in answer to the explicit
|
|
// question "which reminder should I cancel?" A bare ordinal is the
|
|
// answer to that question and therefore completes the cancellation.
|
|
return h.cancelReminderChoice(ctx, pick.Ref, pick.Label), true
|
|
}
|
|
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
|
|
}
|