190cf0c794
Two ecosystem defects, both of the shape where a call reports done and nothing of the sort happened. entityAttentionCapability surfaced every item it spoke and remembered none of them, so the previous digest stayed the positional memory. A follow-up "отметь второй как сделанное" then indexed into a list he had not just heard and transitioned somebody else's item, which is the exact harm the position resolver exists to prevent. A parked Hexis confirm did not carry the correlation id of the action that proposed it. The confirm lands on a later turn with a context of its own, so the execution recorded a fresh id and an empty causation: the resolve, the discovery and the thing they authorised sat in the trace as three unrelated calls. The contract mints one id per action.
312 lines
10 KiB
Go
312 lines
10 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
|
||
|
||
// correlationID — the id the proposing turn minted for this action. A
|
||
// confirm arrives on a later turn with a context of its own, so without
|
||
// carrying it here the execution recorded a fresh id and no causation at
|
||
// all, and the resolve, the discovery and the thing they authorised sat in
|
||
// the trace as unrelated calls. The contract mints one id per action, and
|
||
// the action began when she asked.
|
||
correlationID string
|
||
}
|
||
|
||
// 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 {
|
||
execCtx := ctx
|
||
if hx.correlationID != "" {
|
||
execCtx = withCorrelationID(execCtx, hx.correlationID)
|
||
}
|
||
return h.execHexis(execCtx, 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, " ")
|
||
}
|