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>
384 lines
13 KiB
Go
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
|
|
}
|
|
}
|