b2521988e1
The daemon tests that compared against one literal ask the entry instead: IsAck names the line she could have said without pinning the wording. The eval scores every ack variant on the persona checks the nudges already pass.
109 lines
4.5 KiB
Go
109 lines
4.5 KiB
Go
// 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, as stem sequences. Matched by
|
|
// quietPhrase (quiet_toggle.go), which 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
|
|
}
|