// Spoken ack — the other half of the snooze wire. "готово" said out loud // resolves a live nudge as `acted`, and a fact that answers the nudge on its // own ("выпил воды" after the water rule fired) closes it without him having // to say anything extra. // // Two entry points rather than one, because the two utterances are different // acts. A bare "готово" carries no content and is intercepted before the // router, exactly like the snooze. "выпил воды" IS content: it has to route // normally and write its fact, and only then close the nudge. Folding the // second into a pre-route intercept would have thrown the fact away, which is // the thing he actually said. package main import ( "context" "log" "github.com/kami/maven/internal/loop" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" ) // resolveAck — pre-route keyword check for a contentless acknowledgement, // run after the snooze. Same window and same fall-through rule: the words only // count when a nudge is actually live, so "готово" with nothing pending routes // normally. func (h *reactiveHandler) resolveAck(ctx context.Context, text string, src turnSource) (string, bool) { if !classifyAck(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.NudgeActed, now); err != nil { log.Printf("voice: ack nudge %d (%s, %s): %v", target.ID, target.Rule, src, err) return "не получилось отметить.", true } log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src) return "отлично, отметила.", true } // ackFromFact — post-action hook, called once the turn's decision has been // applied. A fact whose key is the substrate of a live nudge's rule answers // that nudge, so the nudge is resolved `acted` and the auto-tuner learns the // rule is working. // // Silent by design: it returns nothing and never changes the reply. He said // "выпил воды" and the fact reply is what he is owed; "отлично, отметила" on // top would be her congratulating him for obeying, which is the nag she is // explicitly not. // // Best-effort throughout. A failure here loses one feedback signal and must // never turn a written fact into an error the user hears. func (h *reactiveHandler) ackFromFact(ctx context.Context, dec router.Decision) { if dec.Clarify || dec.Intent != router.IntentFact || !dec.Slots.HasKey { return } rules := ackRulesForKey(dec.Slots.Key) if len(rules) == 0 { return } now := h.now() target, ok := h.pendingNudge(ctx, now) if !ok || !rules[target.Rule] { return } if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeActed, now); err != nil { log.Printf("voice: ack nudge %d from fact %q: %v", target.ID, dec.Slots.Key, err) return } log.Printf("voice: nudge %d (rule %s) acked by fact %q", target.ID, target.Rule, dec.Slots.Key) } // ackRulesForKey — which rules a fact under this key answers. // // Derived from each rule's InertWhenNoData rather than written out as a map, // so a rule added later is covered the day it lands. That field already names // the substrate the rule reads; a fresh fact under one of those keys is by // definition the thing the rule was complaining about the absence of. // // DefaultRules, not the daemon's wired set: a rule disabled in config cannot // have a pending nudge to close anyway, and reading the canonical set here // keeps this free of the config plumbing. func ackRulesForKey(key string) map[string]bool { if key == "" { return nil } var out map[string]bool for _, r := range loop.DefaultRules() { for _, k := range r.InertWhenNoData { if k != key { continue } if out == nil { out = map[string]bool{} } out[r.Name] = true } } return out } // ackPhrases — the acknowledgement vocabulary, as stem sequences. Matched by // quietPhrase (quiet_toggle.go), so a single-word pattern matches only a // single-word utterance. // // "да" and "ок" are deliberately absent. Both are answers to a question she // asked, and the clarify gate upstream (resolveClarifyAnswer) has the stronger // claim on them; letting them close a nudge as well would mean a stray "да" // silently rewrites the feedback the auto-tuner learns from. var ackPhrases = [][]string{ {"готово"}, {"сделал"}, {"сделано"}, {"выполнил"}, {"уже"}, {"уже", "сделал"}, {"уже", "готово"}, {"всё", "сделал"}, {"done"}, {"already", "did"}, } // classifyAck reads an utterance as a contentless acknowledgement. func classifyAck(text string) bool { tokens := quietTokens(text) for _, p := range ackPhrases { if quietPhrase(tokens, p) { return true } } return false }