// Spoken snooze — "не сейчас", "потом", "отложи" said out loud after a nudge // resolves it as `snoozed`, the same outcome the Telegram buttons and the web // UI write. Until this existed, a nudge could only be deferred by touching a // screen: the voice path had no way to reach store.ResolveNudge at all, so the // one channel she nudges on hardest was the one channel he could not answer. package main import ( "context" "log" "time" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/store" ) // snoozeWindow — how long after a send "потом" still means "that nudge". // // A window is what makes this safe to run before the router. "потом" is an // ordinary Russian word; eating every one of them would break real sentences. // Bounded to the minutes right after she spoke, the word is almost always an // answer to what she just said, and outside the window the utterance falls // through and routes normally. // // Twenty minutes rather than the two hours of store.SnoozeDuration: those // measure different things. SnoozeDuration is how long the quiet lasts, // snoozeWindow is how long an unanswered nudge stays the topic of the // conversation. const snoozeWindow = 20 * time.Minute // snoozeScan — how many recent nudges to look at when finding the target. The // newest pending one is nearly always the first row; a handful of resolved // rows can sit in front of it when he acked a few in a row. const snoozeScan = 10 // resolveSnooze — pre-route keyword check, run after the quiet toggle. Returns // (reply, true) when the utterance defers a nudge she recently sent. // // It returns ("", false) in two different situations, on purpose: the words do // not read as a deferral, or they do but there is nothing pending to defer. In // both the turn keeps routing, so "потом посмотрю что там с бэкапом" is still // a query when no nudge is outstanding. func (h *reactiveHandler) resolveSnooze(ctx context.Context, text string, src turnSource) (string, bool) { if !classifySnooze(text) { return "", false } now := h.now() target, ok := h.pendingNudge(ctx, now) if !ok { return "", false } if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeSnoozed, now); err != nil { log.Printf("voice: snooze nudge %d (%s, %s): %v", target.ID, target.Rule, src, err) return phraser.Ack(phraser.FailSnooze, nil), true } log.Printf("voice: snoozed nudge %d (rule %s) from %s", target.ID, target.Rule, src) return phraser.Ack(phraser.AckSnooze, nil), true } // pendingNudge — the newest still-pending nudge sent inside snoozeWindow. // // Channel is deliberately not filtered. A nudge that went to Telegram is still // the thing he is answering when he says "потом" at the microphone, and making // the reply channel decide which nudges are answerable would mean the ops page // he actually read could not be dismissed by voice. func (h *reactiveHandler) pendingNudge(ctx context.Context, now time.Time) (ipc.Nudge, bool) { recent, err := h.api.RecentNudges(ctx, snoozeScan) if err != nil { log.Printf("voice: recent nudges for snooze: %v", err) return ipc.Nudge{}, false } for _, n := range recent { if n.Outcome != store.NudgePending { continue } if now.Sub(n.Ts) > snoozeWindow || n.Ts.After(now) { continue } return n, true } return ipc.Nudge{}, false } // snoozePhrases — the deferral vocabulary. Matched by quietPhrase and quietStem // (quiet_toggle.go), so an adverb is matched through the dictionary and a verb // exactly, prefixed with "=": "напомни" is a request and "напомнил" is a report // about earlier, and the dictionary files both under напомнить (Vikunja #526). // The verbs used to be truncated stems — "напомн", "отлож" — which is what the // deleted ending list existed to complete. // // quietPhrase carries the rule that matters here: // a single-word pattern matches only a single-word utterance. Bare "потом" is // an answer; "потом схожу за водой" is a plan, and reporting a plan must not // silence the rule that prompted it. var snoozePhrases = [][]string{ {"не", "сейчас"}, {"не", "могу", "сейчас"}, {"не", "до", "этого"}, {"=напомни|=напоминай", "позже"}, {"=напомни|=напоминай", "потом"}, {"=спроси|=спрашивай", "позже"}, {"=отложи|=отложим|=откладывай"}, {"позже"}, {"потом"}, {"попозже"}, {"=погоди|=погодите"}, {"not", "now"}, {"later"}, {"snooze"}, {"remind", "me", "later"}, } // classifySnooze reads an utterance as a deferral. Unlike the quiet toggle // there is no negation arm: "не потом" is not something anyone says, and the // leading "не" of "не сейчас" is part of the phrase itself. func classifySnooze(text string) bool { tokens := quietTokens(text) for _, p := range snoozePhrases { if quietPhrase(tokens, p) { return true } } return false }