From f5c71b87f3b28c2590b4946a318580ef50914582 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 23:51:33 +0400 Subject: [PATCH] Move the confirm/park gate out of voice.go --- cmd/mavend/confirm.go | 205 ++++++++++++++++++++++++++++++++++++++++++ cmd/mavend/voice.go | 195 ---------------------------------------- 2 files changed, 205 insertions(+), 195 deletions(-) create mode 100644 cmd/mavend/confirm.go diff --git a/cmd/mavend/confirm.go b/cmd/mavend/confirm.go new file mode 100644 index 0000000..bd62509 --- /dev/null +++ b/cmd/mavend/confirm.go @@ -0,0 +1,205 @@ +package main + +import ( + "context" + "log" + "strings" + "time" + + "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 +// unrecognised answer cancels the pending and routes normally — a confirm that +// can't be answered clearly is safer abandoned than left armed. +func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (string, bool) { + h.mu.Lock() + defer h.mu.Unlock() + + // Check routine proposal first (newer feature; checked before tool confirm + // so a routine confirm doesn't get eaten by a stale tool pending). + pr := h.pendingRoutine + if pr != nil && !h.now().After(pr.expiry) { + switch classifyConfirm(text) { + case confirmYes: + h.pendingRoutine = nil + // Only record the acceptance. The tick loop reads accepted + // routines and nudges on their own interval. Building a reminder + // here made a routine fire exactly once (Vikunja #366). + if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, h.now()); err != nil { + log.Printf("voice: accept proposed routine: %v", err) + return "не получилось запомнить рутину.", true + } + return "буду напоминать.", true + case confirmNo: + h.pendingRoutine = nil + if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil { + log.Printf("voice: dismiss proposed routine: %v", err) + } + return "хорошо, не буду.", true + default: + // unclear: abandon the routine proposal, route normally. + h.pendingRoutine = nil + return "", false + } + } + // Clear expired routine if it existed. + if pr != nil { + h.pendingRoutine = nil + } + + // Check pending Hexis execution confirm. Bound to the exact capability + + // target that was proposed; a stray "да" can only run that, nothing else. + if hx := h.pendingHexis; hx != nil { + if h.now().After(hx.expiry) { + h.pendingHexis = nil + } else { + switch classifyConfirm(text) { + case confirmYes: + h.pendingHexis = nil + return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName), true + case confirmNo: + h.pendingHexis = nil + return "отменила.", true + default: + h.pendingHexis = nil + return "", false + } + } + } + + // Check tool confirm (existing behavior). + p := h.pending + if p == nil { + return "", false + } + if h.now().After(p.expiry) { + h.pending = nil + return "", false + } + switch classifyConfirm(text) { + case confirmYes: + h.pending = nil + 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 "не получилось выполнить команду: " + firstLine(out), true + } + return "не получилось выполнить команду.", true + } + if out != "" { + return "готово: " + firstLine(out), true + } + return "готово.", true + case confirmNo: + h.pending = nil + return "отменила.", true + default: + // unclear answer: abandon the confirm, route this utterance normally. + h.pending = nil + return "", false + } +} + +// 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 "не разобрала команду — попробуй иначе." + } + newly, err := h.api.ProposeTool(ctx, name, dec.Utterance, "", h.now()) + if err != nil { + log.Printf("voice: propose tool %q: %v", name, err) + return "команды «" + name + "» нет в списке разрешённых." + } + if newly { + return "команды «" + name + "» нет в списке. Предложила её добавить — включи через клиент." + } + return "команды «" + name + "» пока нет в списке — она уже предложена, включи через клиент." +} + +// confirmVerdict — the parse of a y/n confirm answer. +type confirmVerdict int + +const ( + confirmUnknown confirmVerdict = iota + confirmYes + confirmNo +) + +// classifyConfirm reads a short ru/en yes-or-no answer. Substring match on the +// stems so inflections/fillers ("да, давай", "нет, отмени") still land. +func classifyConfirm(text string) confirmVerdict { + t := strings.ToLower(strings.TrimSpace(text)) + // negatives first — "не надо" contains no "да", but check no-stems before + // yes so a leading "нет" isn't shadowed. + for _, no := range []string{"нет", "не надо", "отмен", "стоп", "no", "cancel", "stop", "don't"} { + if strings.Contains(t, no) { + return confirmNo + } + } + for _, yes := range []string{"да", "ага", "давай", "подтвер", "конечно", "yes", "yeah", "yep", "confirm", "ок", "okay", "ok"} { + if strings.Contains(t, yes) { + return confirmYes + } + } + return confirmUnknown +} + +// 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, " ") +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index b48d7e9..930018b 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -349,42 +349,6 @@ type reactiveHandler struct { ecosystem *ecosystemWiring // nexus + hexis + praxis clients } -// 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 - // HandlePushToTalk — the full reactive round-trip. Each step's failure // surfaces as a short reply text + empty audio OR an error; the voice // server translates an error into a wire RpcError. Today the handler @@ -849,165 +813,6 @@ func loadSeedFile(c *router.Classifier, intent router.Intent) (int, error) { return count, nil } -// 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 -// unrecognised answer cancels the pending and routes normally — a confirm that -// can't be answered clearly is safer abandoned than left armed. -func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (string, bool) { - h.mu.Lock() - defer h.mu.Unlock() - - // Check routine proposal first (newer feature; checked before tool confirm - // so a routine confirm doesn't get eaten by a stale tool pending). - pr := h.pendingRoutine - if pr != nil && !h.now().After(pr.expiry) { - switch classifyConfirm(text) { - case confirmYes: - h.pendingRoutine = nil - // Only record the acceptance. The tick loop reads accepted - // routines and nudges on their own interval. Building a reminder - // here made a routine fire exactly once (Vikunja #366). - if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, h.now()); err != nil { - log.Printf("voice: accept proposed routine: %v", err) - return "не получилось запомнить рутину.", true - } - return "буду напоминать.", true - case confirmNo: - h.pendingRoutine = nil - if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil { - log.Printf("voice: dismiss proposed routine: %v", err) - } - return "хорошо, не буду.", true - default: - // unclear: abandon the routine proposal, route normally. - h.pendingRoutine = nil - return "", false - } - } - // Clear expired routine if it existed. - if pr != nil { - h.pendingRoutine = nil - } - - // Check pending Hexis execution confirm. Bound to the exact capability + - // target that was proposed; a stray "да" can only run that, nothing else. - if hx := h.pendingHexis; hx != nil { - if h.now().After(hx.expiry) { - h.pendingHexis = nil - } else { - switch classifyConfirm(text) { - case confirmYes: - h.pendingHexis = nil - return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName), true - case confirmNo: - h.pendingHexis = nil - return "отменила.", true - default: - h.pendingHexis = nil - return "", false - } - } - } - - // Check tool confirm (existing behavior). - p := h.pending - if p == nil { - return "", false - } - if h.now().After(p.expiry) { - h.pending = nil - return "", false - } - switch classifyConfirm(text) { - case confirmYes: - h.pending = nil - 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 "не получилось выполнить команду: " + firstLine(out), true - } - return "не получилось выполнить команду.", true - } - if out != "" { - return "готово: " + firstLine(out), true - } - return "готово.", true - case confirmNo: - h.pending = nil - return "отменила.", true - default: - // unclear answer: abandon the confirm, route this utterance normally. - h.pending = nil - return "", false - } -} - -// 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 "не разобрала команду — попробуй иначе." - } - newly, err := h.api.ProposeTool(ctx, name, dec.Utterance, "", h.now()) - if err != nil { - log.Printf("voice: propose tool %q: %v", name, err) - return "команды «" + name + "» нет в списке разрешённых." - } - if newly { - return "команды «" + name + "» нет в списке. Предложила её добавить — включи через клиент." - } - return "команды «" + name + "» пока нет в списке — она уже предложена, включи через клиент." -} - -// confirmVerdict — the parse of a y/n confirm answer. -type confirmVerdict int - -const ( - confirmUnknown confirmVerdict = iota - confirmYes - confirmNo -) - -// classifyConfirm reads a short ru/en yes-or-no answer. Substring match on the -// stems so inflections/fillers ("да, давай", "нет, отмени") still land. -func classifyConfirm(text string) confirmVerdict { - t := strings.ToLower(strings.TrimSpace(text)) - // negatives first — "не надо" contains no "да", but check no-stems before - // yes so a leading "нет" isn't shadowed. - for _, no := range []string{"нет", "не надо", "отмен", "стоп", "no", "cancel", "stop", "don't"} { - if strings.Contains(t, no) { - return confirmNo - } - } - for _, yes := range []string{"да", "ага", "давай", "подтвер", "конечно", "yes", "yeah", "yep", "confirm", "ок", "okay", "ok"} { - if strings.Contains(t, yes) { - return confirmYes - } - } - return confirmUnknown -} - -// 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, " ") -} - // seedTools upserts the config-declared tools into the store as enabled. Editing // mavend.json is a human act, so a config tool is enabled by definition; this // makes the declarative config the reproducible bootstrap while the store stays