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