Cancel a reminder by voice, and honour a refusal (V-719)
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>
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// reminderCancelRequest exists to make the parser's contract explicit: a hit
|
||||
// proves only that the turn is an addressed imperative naming the reminder
|
||||
// store. Subject and time are resolved separately after that safety boundary.
|
||||
type reminderCancelRequest struct{}
|
||||
|
||||
var reminderCancelVerbs = func() map[string]bool {
|
||||
out := make(map[string]bool)
|
||||
for _, word := range lexicon.ReminderCancelVerbs() {
|
||||
out[strings.ToLower(word)] = true
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
var reminderCancelFrame = func() map[string]bool {
|
||||
out := make(map[string]bool)
|
||||
for _, word := range lexicon.ReminderCancelFrame() {
|
||||
out[strings.ToLower(word)] = true
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
// isReminderCancelTarget is deliberately a noun test, not a substring test.
|
||||
// A committed reminder must be named, otherwise "убери со стола" would reach
|
||||
// the reminder store. Russian cases are grammar and go through morph; the
|
||||
// English singular/plural forms are closed command vocabulary.
|
||||
func isReminderCancelTarget(tok string) bool {
|
||||
if morph.SameWord(tok, "напоминание") || morph.SameWord(tok, "будильник") {
|
||||
return true
|
||||
}
|
||||
switch tok {
|
||||
case "reminder", "reminders", "alarm", "alarms":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// reminderCancelLead reports which words may precede the imperative without
|
||||
// becoming a subject of their own. Filler/politeness vocabulary already has
|
||||
// one home in the lexicon; Maven's name is an address, not a Russian class.
|
||||
func reminderCancelLead(tok string) bool {
|
||||
return lexicon.IsFillerParticle(tok) || tok == "мавен" || tok == "maven"
|
||||
}
|
||||
|
||||
// parseReminderCancelRequest recognizes an exact cancel imperative at the
|
||||
// start of the addressed command plus an explicit reminder noun. Both are
|
||||
// whole tokens. Requiring command position is the safety boundary: infinitive
|
||||
// questions ("как отменить ..."), reported speech ("он сказал: отмени ...")
|
||||
// and past-tense remarks never reach the reminder store. A relative clause
|
||||
// after a real command remains valid even though it may contain a question
|
||||
// pronoun, so this is stronger and more precise than a punctuation test.
|
||||
func parseReminderCancelRequest(text string) (reminderCancelRequest, bool) {
|
||||
tokens := turnTokens(text)
|
||||
verbAt := -1
|
||||
for i, tok := range tokens {
|
||||
if reminderCancelVerbs[tok] {
|
||||
verbAt = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if verbAt < 0 {
|
||||
return reminderCancelRequest{}, false
|
||||
}
|
||||
for _, tok := range tokens[:verbAt] {
|
||||
if !reminderCancelLead(tok) {
|
||||
return reminderCancelRequest{}, false
|
||||
}
|
||||
}
|
||||
for _, tok := range tokens[verbAt+1:] {
|
||||
if isReminderCancelTarget(tok) {
|
||||
return reminderCancelRequest{}, true
|
||||
}
|
||||
}
|
||||
return reminderCancelRequest{}, false
|
||||
}
|
||||
|
||||
func reminderCancelNegation(tok string) bool {
|
||||
switch tok {
|
||||
case "не", "ни", "not", "no", "don't", "dont":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func reminderCancelTimeLead(tok string) bool {
|
||||
switch tok {
|
||||
case "в", "во", "на", "к", "ко", "через", "спустя",
|
||||
"at", "in", "by", "until", "after", "before":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func reminderCancelTimeUnit(tok string) bool {
|
||||
if lexicon.IsHourUnit(tok) || lexicon.IsMinuteUnit(tok) {
|
||||
return true
|
||||
}
|
||||
for _, part := range lexicon.PartsOfDay() {
|
||||
if tok == part {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return tok == "утра" || tok == "дня" || tok == "вечера" || tok == "ночи" ||
|
||||
tok == "am" || tok == "pm" || tok == "noon" || tok == "midnight"
|
||||
}
|
||||
|
||||
func reminderCancelNumeral(tok string) (int, bool) {
|
||||
if n, ok := lexicon.Cardinal(tok); ok {
|
||||
return n, true
|
||||
}
|
||||
if n, ok := lexicon.Ordinal(tok); ok && n > 0 {
|
||||
return n, true
|
||||
}
|
||||
n, err := strconv.Atoi(tok)
|
||||
return n, err == nil
|
||||
}
|
||||
|
||||
// reminderClockTokenBudget records the numeric pieces that came from a written
|
||||
// clock. turnTokens deliberately splits 21:30 into 21 and 30, so a small
|
||||
// multiset lets subject extraction ignore exactly those occurrences without
|
||||
// discarding the same number when it also belongs to the reminder text.
|
||||
func reminderClockTokenBudget(text string) map[string]int {
|
||||
out := make(map[string]int)
|
||||
for _, field := range strings.Fields(strings.ToLower(text)) {
|
||||
field = strings.Trim(field, ".,!?;()[]{}«»\"'")
|
||||
hour, minute, ok := strings.Cut(field, ":")
|
||||
if !ok || len(minute) != 2 {
|
||||
continue
|
||||
}
|
||||
h, herr := strconv.Atoi(hour)
|
||||
m, merr := strconv.Atoi(minute)
|
||||
if herr != nil || merr != nil || h < 0 || h > 23 || m < 0 || m > 59 {
|
||||
continue
|
||||
}
|
||||
out[hour]++
|
||||
out[minute]++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// reminderCancellationTerms keeps identity-bearing words, including negation
|
||||
// and quantities. The old ownContent shortcut erased both, so "не звонить" and
|
||||
// "звонить", or "одну таблетку" and "две таблетки", could select the same
|
||||
// row. Time framing is removed only after the shared parser proved that this
|
||||
// turn actually carries a readable time; numerals are removed only in a clock
|
||||
// position, never merely because they are numbers.
|
||||
func reminderCancellationTerms(text string, hasTime bool) []string {
|
||||
tokens := turnTokens(text)
|
||||
clockBudget := reminderClockTokenBudget(text)
|
||||
out := make([]string, 0, len(tokens))
|
||||
for i, tok := range tokens {
|
||||
if reminderCancelVerbs[tok] || isReminderCancelTarget(tok) ||
|
||||
reminderCancelFrame[tok] || lexicon.IsFillerParticle(tok) {
|
||||
continue
|
||||
}
|
||||
if !hasTime || reminderCancelNegation(tok) {
|
||||
out = append(out, tok)
|
||||
continue
|
||||
}
|
||||
if clockBudget[tok] > 0 {
|
||||
clockBudget[tok]--
|
||||
continue
|
||||
}
|
||||
if _, numeric := reminderCancelNumeral(tok); numeric {
|
||||
prevTime := i > 0 && reminderCancelTimeLead(tokens[i-1])
|
||||
nextTime := i+1 < len(tokens) && reminderCancelTimeUnit(tokens[i+1])
|
||||
if prevTime || nextTime {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// frameWords is assembled exclusively from the closed time/grammar
|
||||
// lexicons. At this point a time was parsed, and negation has already
|
||||
// been preserved above, so these words identify the time rather than
|
||||
// the stored reminder body.
|
||||
if frameWords[tok] {
|
||||
continue
|
||||
}
|
||||
out = append(out, tok)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// reminderCancellationTime applies the same parse and resolved-hour gate as a
|
||||
// newly created reminder. A time expression that is present but unread is not
|
||||
// silently discarded: the caller asks for a clearer time instead of cancelling
|
||||
// whichever row happens to match the remaining words.
|
||||
func (h *reactiveHandler) reminderCancellationTime(ctx context.Context, text string) (time.Time, bool) {
|
||||
if slots := h.extractor.Extract(ctx, router.IntentReminder, text, h.now()); slots.HasTime {
|
||||
return slots.Time, true
|
||||
}
|
||||
if h.timeParser == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
parsed, ok, err := h.timeParser.Parse(ctx, text, h.now())
|
||||
if err != nil || !ok || !router.ResolvedTheHour(text, parsed) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func reminderNextFire(r ipc.Reminder) time.Time {
|
||||
if !r.NextFireTs.IsZero() {
|
||||
return r.NextFireTs
|
||||
}
|
||||
return r.FireTs
|
||||
}
|
||||
|
||||
// reminderTimeMatches lets state disambiguate a clock when the day was not
|
||||
// named. "На девять" can therefore select the sole 09:00/21:00 reminder, but
|
||||
// if both exist they both remain candidates and Maven asks. A named day or an
|
||||
// interval denotes an absolute minute and must match that minute exactly.
|
||||
func reminderTimeMatches(text string, parsed, fire time.Time) bool {
|
||||
local := fire.In(parsed.Location())
|
||||
if router.NamesADay(text) || router.NamesAnInterval(text) {
|
||||
return local.Truncate(time.Minute).Equal(parsed.Truncate(time.Minute))
|
||||
}
|
||||
if router.HourIsAmbiguous(text) {
|
||||
return local.Minute() == parsed.Minute() && local.Hour()%12 == parsed.Hour()%12
|
||||
}
|
||||
return local.Hour() == parsed.Hour() && local.Minute() == parsed.Minute()
|
||||
}
|
||||
|
||||
func reminderTextMatchesTerms(r ipc.Reminder, terms []string) bool {
|
||||
if len(terms) == 0 {
|
||||
return true
|
||||
}
|
||||
words := turnTokens(store.ReminderText(r.Payload))
|
||||
used := make([]bool, len(words))
|
||||
for _, term := range terms {
|
||||
found := false
|
||||
for i, word := range words {
|
||||
if used[i] {
|
||||
continue
|
||||
}
|
||||
tn, tok := reminderCancelNumeral(term)
|
||||
wn, wok := reminderCancelNumeral(word)
|
||||
if term == word || morph.SameWord(term, word) || (tok && wok && tn == wn) {
|
||||
used[i] = true
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func reminderCancellationLabel(r ipc.Reminder, now time.Time) string {
|
||||
fire := reminderNextFire(r).In(now.Location())
|
||||
when := dayPrefix(now, fire)
|
||||
if when == "это" {
|
||||
when = fmt.Sprintf("%d %s", fire.Day(), lexicon.MonthGenitive(int(fire.Month())))
|
||||
}
|
||||
return fmt.Sprintf("%s в %s — %s", when, fire.Format("15:04"), store.ReminderText(r.Payload))
|
||||
}
|
||||
|
||||
// offerReminderCancellations binds exactly the rows Maven names, in that order.
|
||||
// An ordinal on the next turn therefore points at the spoken list, never at a
|
||||
// fresh query whose order may have changed in between.
|
||||
func (h *reactiveHandler) offerReminderCancellations(ctx context.Context, text string, matches []ipc.Reminder) string {
|
||||
const maxSpoken = 5
|
||||
truncated := len(matches) > maxSpoken
|
||||
if len(matches) > maxSpoken {
|
||||
matches = matches[:maxSpoken]
|
||||
}
|
||||
candidates := make([]dialogue.Candidate, 0, len(matches))
|
||||
parts := make([]string, 0, len(matches))
|
||||
for i, r := range matches {
|
||||
label := reminderCancellationLabel(r, h.now())
|
||||
candidates = append(candidates, dialogue.Candidate{Kind: "reminder-cancel", Ref: r.ID, Label: label})
|
||||
parts = append(parts, fmt.Sprintf("%d: %s", i+1, label))
|
||||
}
|
||||
|
||||
if h.dialogueSessions == nil {
|
||||
return "нашла несколько подходящих напоминаний — уточни текст или время."
|
||||
}
|
||||
id, now := dialogueIDOf(ctx), h.now()
|
||||
// This command is its own turn. Reusing an older session would keep stale
|
||||
// intent/slots alive after the choice and let the next utterance inherit
|
||||
// unrelated state, so the offered list gets a fresh system session.
|
||||
h.dialogueSessions.Put(id, &dialogue.Session{
|
||||
Intent: dialogue.IntentSystem, Utterance: text, Timestamp: now,
|
||||
Candidates: candidates,
|
||||
})
|
||||
prefix := "нашла несколько подходящих. какое отменить? "
|
||||
if truncated {
|
||||
prefix = "нашла больше пяти подходящих; называю первые пять. если нужного здесь нет, уточни текст или время. какое отменить? "
|
||||
}
|
||||
return prefix + strings.Join(parts, "; ") + ". ответь одним порядковым словом, например «второе»."
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) clearReminderCandidates(ctx context.Context) {
|
||||
if h.dialogueSessions != nil {
|
||||
h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) cancelReminderChoice(ctx context.Context, id int64, label string) string {
|
||||
if err := h.api.CancelReminder(ctx, id); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ipc.ErrReminderNotFound), errors.Is(err, ipc.ErrReminderState):
|
||||
h.clearReminderCandidates(ctx)
|
||||
return "это напоминание уже не ожидает отправки."
|
||||
case errors.Is(err, ipc.ErrReminderInFlight):
|
||||
h.clearReminderCandidates(ctx)
|
||||
return "я уже начала отправлять это напоминание — надёжно отменить его уже нельзя."
|
||||
default:
|
||||
log.Printf("voice: cancel reminder %d: %v", id, err)
|
||||
return "не получилось отменить напоминание."
|
||||
}
|
||||
}
|
||||
h.clearReminderCandidates(ctx)
|
||||
log.Printf("voice: cancelled reminder %d (%q)", id, label)
|
||||
return "отменила напоминание: " + label + "."
|
||||
}
|
||||
|
||||
// resolveReminderCancellation is the stateful pre-route resolver for a
|
||||
// committed reminder. It claims only the explicit structural command above,
|
||||
// resolves against every pending row, and never ranks an ambiguous set down to
|
||||
// one. One match cancels; more than one is an offered, ordinal-bound question.
|
||||
func (h *reactiveHandler) resolveReminderCancellation(ctx context.Context, text string) (string, bool) {
|
||||
_, ok := parseReminderCancelRequest(text)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
rows, err := h.api.ListPendingReminders(ctx, 0)
|
||||
if err != nil {
|
||||
log.Printf("voice: list reminders for cancellation: %v", err)
|
||||
return "не получилось посмотреть напоминания.", true
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return "ожидающих напоминаний нет.", true
|
||||
}
|
||||
|
||||
parsed, hasTime := h.reminderCancellationTime(ctx, text)
|
||||
if router.MentionsTime(text) && !hasTime {
|
||||
return "не смогла разобрать время напоминания — уточни его.", true
|
||||
}
|
||||
terms := reminderCancellationTerms(text, hasTime)
|
||||
matches := make([]ipc.Reminder, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if !reminderTextMatchesTerms(r, terms) {
|
||||
continue
|
||||
}
|
||||
if hasTime && !reminderTimeMatches(text, parsed, reminderNextFire(r)) {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, r)
|
||||
}
|
||||
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return "не нашла такого ожидающего напоминания.", true
|
||||
case 1:
|
||||
label := reminderCancellationLabel(matches[0], h.now())
|
||||
return h.cancelReminderChoice(ctx, matches[0].ID, label), true
|
||||
default:
|
||||
return h.offerReminderCancellations(ctx, text, matches), true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user