Files
Maven/cmd/mavend/reminder_cancel.go
claude 85a3397bf4 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>
2026-08-15 17:19:25 +04:00

384 lines
13 KiB
Go

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
}
}