31deb7d565
classifyConfirm was a substring test over bare stems, so "погода", "дальше", "надо" and "давление" all read as "да", and "покажи" and "около" read as "ок". resolveConfirm runs before routing, so a question about the weather executed a parked destructive act. Reproduced on the box: with "restart nonexistent-xyz" parked, "какая погода" answered "не получилось выполнить команду". The yes and no answers are now two closed sets in internal/lexicon, matched as whole tokens longest-first, and the WHOLE utterance must be answer words and filler — a leading "давай" does not make "давай посмотрим погоду" an answer. Anything else is confirmUnknown, which now leaves the confirm parked instead of disarming it: an utterance that is not an answer is not a cancellation either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
300 lines
9.9 KiB
Go
300 lines
9.9 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"log"
|
||
"slices"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
|
||
"github.com/kami/maven/internal/lexicon"
|
||
"github.com/kami/maven/internal/phraser"
|
||
"github.com/kami/maven/internal/router"
|
||
)
|
||
|
||
// pendingHexisExec — a mutating Hexis capability parked awaiting a spoken
|
||
// confirm. The confirmation is bound to the resolved capability + canonical
|
||
// target entity so a later "да" can only execute exactly what was proposed
|
||
// (ecosystem invariant: protected actions require bound confirmation).
|
||
type pendingHexisExec struct {
|
||
capabilityID string
|
||
capName string
|
||
entityID string
|
||
displayName string
|
||
expiry time.Time
|
||
}
|
||
|
||
// pendingRoutineConfirm — a proposed routine awaiting a spoken y/n to become
|
||
// a recurring reminder. Set by detectPattern after creating a proposal.
|
||
type pendingRoutineConfirm struct {
|
||
routineID int64
|
||
action string
|
||
object string
|
||
interval float64
|
||
phrase string
|
||
expiry time.Time
|
||
}
|
||
|
||
// pendingAct — a destructive act awaiting a spoken confirm.
|
||
type pendingAct struct {
|
||
fn string
|
||
args []string
|
||
phrase string
|
||
expiry time.Time
|
||
}
|
||
|
||
// confirmTTL — how long a parked destructive confirm stays answerable. Short:
|
||
// a confirm is a same-breath gesture; a stale prompt shouldn't fire on an
|
||
// unrelated later "да".
|
||
const confirmTTL = 90 * time.Second
|
||
|
||
// park stores a destructive act awaiting confirmation. Overwrites any prior
|
||
// pending (last-asked wins — single-user box).
|
||
func (h *reactiveHandler) park(fn string, args []string, phrase string) {
|
||
h.mu.Lock()
|
||
h.pending = &pendingAct{fn: fn, args: args, phrase: phrase, expiry: h.now().Add(confirmTTL)}
|
||
h.mu.Unlock()
|
||
}
|
||
|
||
// resolveConfirm interprets an utterance as the answer to a parked destructive
|
||
// act OR a parked routine proposal. Returns (reply, true) when it consumed the
|
||
// utterance as a y/n answer; ("", false) when there's nothing pending (or the
|
||
// parked act expired), so the caller routes the utterance normally.
|
||
//
|
||
// An utterance that is not clearly yes or no is not an answer at all, so it is
|
||
// handed straight back and the pending stays parked until it expires (V-567).
|
||
// This resolver runs before routing and holds the most dangerous trigger on the
|
||
// box; it may only claim a turn it is certain about.
|
||
func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (string, bool) {
|
||
verdict := classifyConfirm(text)
|
||
if verdict == confirmUnknown {
|
||
return "", false
|
||
}
|
||
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
|
||
for _, r := range h.confirmResolvers(ctx) {
|
||
if !r.claim() {
|
||
continue
|
||
}
|
||
// The slot is already cleared by claim().
|
||
switch verdict {
|
||
case confirmYes:
|
||
return r.yes(), true
|
||
default:
|
||
return r.no(), true
|
||
}
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// confirmResolver — one parked-confirm slot in the chain. claim() reports
|
||
// whether this slot holds a live pending, taking it (and dropping an expired
|
||
// one) as it goes; yes/no then run the answer. Only ever called with h.mu held.
|
||
type confirmResolver struct {
|
||
claim func() bool
|
||
yes func() string
|
||
no func() string
|
||
}
|
||
|
||
// confirmResolvers builds the ordered chain resolveConfirm walks. Order is
|
||
// deliberate: the routine proposal is checked before the tool confirm so a
|
||
// routine confirm doesn't get eaten by a stale tool pending.
|
||
func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolver {
|
||
var pr *pendingRoutineConfirm
|
||
var hx *pendingHexisExec
|
||
var p *pendingAct
|
||
|
||
return []confirmResolver{
|
||
// Routine proposal.
|
||
{
|
||
claim: func() bool {
|
||
pr, h.pendingRoutine = h.pendingRoutine, nil
|
||
return pr != nil && !h.now().After(pr.expiry)
|
||
},
|
||
yes: func() string {
|
||
// Voice does NOT accept (Vikunja #367). Accepting hands the
|
||
// tick loop a standing new reason to speak, which is the same
|
||
// tier as enabling a tool — and DESIGN.md § "surface caps
|
||
// authority" says a room mic, reachable by anyone present, is
|
||
// structurally incapable of layer 3. So a spoken "да" leaves
|
||
// the row 'proposed' and points at the authed page, where the
|
||
// accept button is gated at step-up. The convenience of
|
||
// answering out loud stays; the authority does not move.
|
||
//
|
||
// Acceptance itself is recorded by /routines, and the tick
|
||
// loop nudges on the interval from there (Vikunja #366).
|
||
return phraser.C(phraser.ConfirmRoutineAuthed, nil)
|
||
},
|
||
no: func() string {
|
||
if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil {
|
||
log.Printf("voice: dismiss proposed routine: %v", err)
|
||
}
|
||
return phraser.C(phraser.ConfirmRoutineNo, nil)
|
||
},
|
||
},
|
||
// Hexis execution confirm. Bound to the exact capability + target that
|
||
// was proposed; a stray "да" can only run that, nothing else.
|
||
{
|
||
claim: func() bool {
|
||
hx, h.pendingHexis = h.pendingHexis, nil
|
||
return hx != nil && !h.now().After(hx.expiry)
|
||
},
|
||
yes: func() string {
|
||
return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
|
||
},
|
||
no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) },
|
||
},
|
||
// Tool confirm.
|
||
{
|
||
claim: func() bool {
|
||
p, h.pending = h.pending, nil
|
||
return p != nil && !h.now().After(p.expiry)
|
||
},
|
||
yes: func() string {
|
||
out, err := h.tools.Exec(ctx, p.fn, p.args, true) // confirmed
|
||
if err != nil {
|
||
log.Printf("voice: tool %s (confirmed): %v", p.fn, err)
|
||
if out != "" {
|
||
return phraser.A(phraser.ActFailOut, map[string]string{"out": firstLine(out)})
|
||
}
|
||
return phraser.A(phraser.ActFail, nil)
|
||
}
|
||
if out != "" {
|
||
return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)})
|
||
}
|
||
return phraser.A(phraser.ActDone, nil)
|
||
},
|
||
no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) },
|
||
},
|
||
}
|
||
}
|
||
|
||
// proposeGap scaffolds a 'proposed' tool for an act whose verb isn't enabled.
|
||
// maven drafts the registration (name = the verb, provenance = the utterance);
|
||
// a human enables it on the authed surface. She suggests, never enables.
|
||
func (h *reactiveHandler) proposeGap(ctx context.Context, dec router.Decision) string {
|
||
name := firstWord(stripWake(dec.Utterance))
|
||
if name == "" {
|
||
return phraser.C(phraser.ProposeNoVerb, nil)
|
||
}
|
||
vars := map[string]string{"name": name}
|
||
newly, err := h.api.ProposeTool(ctx, name, dec.Utterance, "", h.now())
|
||
if err != nil {
|
||
log.Printf("voice: propose tool %q: %v", name, err)
|
||
return phraser.C(phraser.ProposeFailed, vars)
|
||
}
|
||
if newly {
|
||
return phraser.C(phraser.ProposeNew, vars)
|
||
}
|
||
return phraser.C(phraser.ProposeAlready, vars)
|
||
}
|
||
|
||
// confirmVerdict — the parse of a y/n confirm answer.
|
||
type confirmVerdict int
|
||
|
||
const (
|
||
confirmUnknown confirmVerdict = iota
|
||
confirmYes
|
||
confirmNo
|
||
)
|
||
|
||
// confirmWords are the two closed sets, tokenized once and ordered
|
||
// longest-first so "не надо" is read before "нет" could claim any of it.
|
||
var (
|
||
confirmYesPhrases = confirmPhrases(lexicon.ConfirmYes())
|
||
confirmNoPhrases = confirmPhrases(lexicon.ConfirmNo())
|
||
)
|
||
|
||
// confirmPhrases splits each lexicon member into tokens and sorts the result
|
||
// longest-first, so a walk that tries them in order matches the longest member
|
||
// that fits.
|
||
func confirmPhrases(words []string) [][]string {
|
||
out := make([][]string, 0, len(words))
|
||
for _, w := range words {
|
||
if toks := confirmTokens(w); len(toks) > 0 {
|
||
out = append(out, toks)
|
||
}
|
||
}
|
||
sort.SliceStable(out, func(i, j int) bool { return len(out[i]) > len(out[j]) })
|
||
return out
|
||
}
|
||
|
||
// confirmTokens splits an utterance into lowercase word tokens. Punctuation and
|
||
// spacing are separators; an apostrophe is not, because "don't" is one word.
|
||
func confirmTokens(text string) []string {
|
||
return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||
if r == '\'' || r == '’' {
|
||
return false
|
||
}
|
||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||
})
|
||
}
|
||
|
||
// classifyConfirm reads a short ru/en yes-or-no answer to a parked confirm.
|
||
//
|
||
// The whole utterance must consist of confirmation words and filler, matched as
|
||
// whole tokens against the closed lexicon sets. Anything else is
|
||
// confirmUnknown, which leaves the confirm parked and routes the turn — see
|
||
// resolveConfirm. Both halves of that are the fix for V-567: this used to be a
|
||
// substring test over bare stems, so "погода", "дальше", "надо" and "давление"
|
||
// all read as "да", and "покажи" and "около" read as "ок". A parked destructive
|
||
// act fired on a question about the weather.
|
||
//
|
||
// Requiring the WHOLE utterance is the second half. A leading confirm word does
|
||
// not make a sentence an answer: "давай посмотрим погоду" opens a request, and
|
||
// the only safe reading of a sentence that carries its own subject is that he
|
||
// moved on. Guessing wrong here executes something; guessing wrong the other way
|
||
// asks again.
|
||
func classifyConfirm(text string) confirmVerdict {
|
||
tokens := confirmTokens(text)
|
||
if len(tokens) == 0 {
|
||
return confirmUnknown
|
||
}
|
||
verdict := confirmUnknown
|
||
for i := 0; i < len(tokens); {
|
||
// Negatives first: "не надо" and "не хочу" open with a token that is
|
||
// not itself an answer, and a yes hit must never shadow them.
|
||
if n := matchConfirm(confirmNoPhrases, tokens[i:]); n > 0 {
|
||
return confirmNo
|
||
}
|
||
if n := matchConfirm(confirmYesPhrases, tokens[i:]); n > 0 {
|
||
verdict, i = confirmYes, i+n
|
||
continue
|
||
}
|
||
if lexicon.IsFillerParticle(tokens[i]) {
|
||
i++
|
||
continue
|
||
}
|
||
// A word that is neither an answer nor filler carries a subject of its
|
||
// own, so this utterance is not an answer to her question.
|
||
return confirmUnknown
|
||
}
|
||
return verdict
|
||
}
|
||
|
||
// matchConfirm reports the length of the longest phrase matching at the head of
|
||
// tokens, or 0.
|
||
func matchConfirm(phrases [][]string, tokens []string) int {
|
||
for _, p := range phrases {
|
||
if len(p) > len(tokens) {
|
||
continue
|
||
}
|
||
if slices.Equal(p, tokens[:len(p)]) {
|
||
return len(p)
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// actPhrase renders "fn arg1 arg2" for the confirm prompt.
|
||
func actPhrase(fn string, args []string) string {
|
||
if len(args) == 0 {
|
||
return fn
|
||
}
|
||
return fn + " " + strings.Join(args, " ")
|
||
}
|