Move the confirm/park gate out of voice.go
This commit is contained in:
@@ -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, " ")
|
||||||
|
}
|
||||||
@@ -349,42 +349,6 @@ type reactiveHandler struct {
|
|||||||
ecosystem *ecosystemWiring // nexus + hexis + praxis clients
|
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
|
// HandlePushToTalk — the full reactive round-trip. Each step's failure
|
||||||
// surfaces as a short reply text + empty audio OR an error; the voice
|
// surfaces as a short reply text + empty audio OR an error; the voice
|
||||||
// server translates an error into a wire RpcError. Today the handler
|
// 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
|
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
|
// 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
|
// 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
|
// makes the declarative config the reproducible bootstrap while the store stays
|
||||||
|
|||||||
Reference in New Issue
Block a user