217 lines
7.3 KiB
Go
217 lines
7.3 KiB
Go
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()
|
|
|
|
for _, r := range h.confirmResolvers(ctx) {
|
|
if !r.claim() {
|
|
continue
|
|
}
|
|
// The slot is already cleared by claim(): every branch below drops the
|
|
// pending, including the unclear one — a confirm that can't be
|
|
// answered clearly is safer abandoned than left armed.
|
|
switch classifyConfirm(text) {
|
|
case confirmYes:
|
|
return r.yes(), true
|
|
case confirmNo:
|
|
return r.no(), true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
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 {
|
|
// 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 "не получилось запомнить рутину."
|
|
}
|
|
return "буду напоминать."
|
|
},
|
|
no: func() string {
|
|
if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil {
|
|
log.Printf("voice: dismiss proposed routine: %v", err)
|
|
}
|
|
return "хорошо, не буду."
|
|
},
|
|
},
|
|
// 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 "отменила." },
|
|
},
|
|
// 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 "не получилось выполнить команду: " + firstLine(out)
|
|
}
|
|
return "не получилось выполнить команду."
|
|
}
|
|
if out != "" {
|
|
return "готово: " + firstLine(out)
|
|
}
|
|
return "готово."
|
|
},
|
|
no: func() string { return "отменила." },
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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, " ")
|
|
}
|