d0d0021659
The snooze wire had one end. "готово" and "выпил воды" both left the nudge pending, so the auto-tuner only ever learned from deferrals and from silence — never from the rule working. Two entry points, because the two utterances are different acts. A bare "готово" carries no content and is intercepted before the router, sharing pendingNudge and the twenty-minute window with the snooze. "выпил воды" IS content: it routes normally, writes its fact, and only then closes the nudge (ackFromFact, after applyAction). Folding the second into a pre-route intercept would have thrown away the thing he actually said. ackFromFact is silent. The fact reply stands; "отлично, отметила" on top would be her congratulating him for obeying, which is the nag she is not. Which fact answers which rule comes from the rule's own InertWhenNoData, so a rule added later is covered the day it lands. "да" and "ок" stay out of the ack vocabulary: the clarify gate upstream has the stronger claim on them, and a stray "да" must not rewrite the feedback signal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
129 lines
4.8 KiB
Go
129 lines
4.8 KiB
Go
// 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
|
|
}
|