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.
130 lines
4.8 KiB
Go
130 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/phraser"
|
|
"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 phraser.Ack(phraser.FailAck, nil), true
|
|
}
|
|
log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
|
return phraser.Ack(phraser.AckNudge, nil), 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
|
|
}
|