diff --git a/cmd/mavend/confirm.go b/cmd/mavend/confirm.go index 67070c6..2b840f4 100644 --- a/cmd/mavend/confirm.go +++ b/cmd/mavend/confirm.go @@ -3,9 +3,13 @@ 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" ) @@ -57,10 +61,18 @@ func (h *reactiveHandler) park(fn string, args []string, phrase string) { // 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. +// 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() @@ -68,16 +80,12 @@ func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (stri 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) { + // The slot is already cleared by claim(). + switch verdict { case confirmYes: return r.yes(), true - case confirmNo: - return r.no(), true default: - return "", false + return r.no(), true } } return "", false @@ -194,23 +202,92 @@ const ( confirmNo ) -// classifyConfirm reads a short ru/en yes-or-no answer. Substring match on the -// stems so inflections/fillers ("да, давай", "нет, отмени") still land. +// 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 { - 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) { + 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 } - for _, yes := range []string{"да", "ага", "давай", "подтвер", "конечно", "yes", "yeah", "yep", "confirm", "ок", "okay", "ok"} { - if strings.Contains(t, yes) { - return confirmYes + 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 confirmUnknown + return 0 } // actPhrase renders "fn arg1 arg2" for the confirm prompt. diff --git a/cmd/mavend/confirm_test.go b/cmd/mavend/confirm_test.go new file mode 100644 index 0000000..d5f8e77 --- /dev/null +++ b/cmd/mavend/confirm_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/tool" +) + +// TestClassifyConfirmRejectsSubstrings — V-567. The old matcher tested bare +// stems with strings.Contains, so every word below answered a question she had +// asked about something else: "погода", "дальше", "надо" and "давление" carry +// "да"; "покажи", "около" and "окно" carry "ок". A parked destructive act fired +// on a question about the weather. +func TestClassifyConfirmRejectsSubstrings(t *testing.T) { + for _, text := range []string{ + "погода", + "какая погода", + "что дальше", + "надо ещё", + "покажи заметки", + "около окна", + "давление", + "давай посмотрим погоду", + "не забудь купить хлеб", + "окно открыто", + "стоит ли брать зонт", + "", + } { + if got := classifyConfirm(text); got != confirmUnknown { + t.Errorf("classifyConfirm(%q) = %v, want confirmUnknown", text, got) + } + } +} + +// TestClassifyConfirmAcceptsAnswers keeps every genuine answer the substring +// matcher accepted, and pins the pair the fix could most easily get wrong: +// "надо" is not an answer and "не надо" is the opposite of one. +func TestClassifyConfirmAcceptsAnswers(t *testing.T) { + yes := []string{"да", "Да!", "ага", "давай", "да, давай", "конечно", "подтверждаю", "ну да", "yes", "yeah", "ok", "okay", "confirm"} + no := []string{"нет", "Нет.", "не надо", "не нужно", "не сейчас", "отмена", "отмени", "стоп", "нет, отмени", "no", "nope", "cancel", "stop", "don't"} + + for _, text := range yes { + if got := classifyConfirm(text); got != confirmYes { + t.Errorf("classifyConfirm(%q) = %v, want confirmYes", text, got) + } + } + for _, text := range no { + if got := classifyConfirm(text); got != confirmNo { + t.Errorf("classifyConfirm(%q) = %v, want confirmNo", text, got) + } + } +} + +// TestUnrelatedTurnLeavesConfirmParked — the whole point of V-567. An utterance +// that is not an answer must not execute the parked act, must not consume the +// turn, and must not disarm the confirm either: the answer he has not given yet +// is still answerable until it expires. +func TestUnrelatedTurnLeavesConfirmParked(t *testing.T) { + ctx := context.Background() + st := newTestStore(t) + api := ipc.NewStoreAPI(st) + now := time.Date(2026, 8, 6, 9, 0, 0, 0, time.UTC) + h := &reactiveHandler{ + api: api, + dataStore: st, + now: func() time.Time { return now }, + tools: tool.NewExecutor(api, time.Second), + } + + marker := filepath.Join(t.TempDir(), "destructive-tool-ran") + if err := st.EnableTool(ctx, "delete_backups", []string{"touch", marker}, true, "test", h.now()); err != nil { + t.Fatal(err) + } + h.park("delete_backups", nil, "delete_backups") + + if reply, handled := h.resolveConfirm(ctx, "какая погода"); handled { + t.Fatalf("the weather question was consumed as a confirm: %q", reply) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("the parked destructive command ran on an unrelated turn: %v", err) + } + if h.pending == nil { + t.Fatal("the confirm was disarmed by a turn that did not answer it") + } + + // It is still answerable, and answering it still runs the act. + reply, handled := h.resolveConfirm(ctx, "да") + if !handled || !strings.Contains(reply, "готово") { + t.Fatalf("the still-parked confirm did not resolve: handled=%v reply=%q", handled, reply) + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("confirmed destructive command did not run: %v", err) + } + if h.pending != nil { + t.Fatal("the confirm stayed parked after being answered") + } +} diff --git a/internal/lexicon/lexicon.go b/internal/lexicon/lexicon.go index 1889d7c..6c82fa9 100644 --- a/internal/lexicon/lexicon.go +++ b/internal/lexicon/lexicon.go @@ -65,6 +65,7 @@ func mustLoad() lexiconFile { "day_offsets", "weekdays", "months_genitive", "hours_spoken", "not_place_after_v", "parts_of_day", "reminder_verbs", "half_hour", "filler_particles", "task_done_words", "task_drop_words", + "confirm_yes", "confirm_no", } { s, ok := f.Sets[name] if !ok || (len(s.Words) == 0 && len(s.Values) == 0) { @@ -121,6 +122,16 @@ func ReminderVerbs() []string { return words("reminder_verbs") } // notes say: an imperative exactly, a stative by lemma. func TaskDoneWords() []string { return words("task_done_words") } +// ConfirmYes returns the words that answer a parked confirm with yes, and +// ConfirmNo the ones that answer it with no. Some members are multi-word ("не +// надо"), so a caller matches longest-first over tokens rather than looking up +// one word at a time. See the sets' notes for why neither may be matched as a +// substring. +func ConfirmYes() []string { return words("confirm_yes") } + +// ConfirmNo — see ConfirmYes. +func ConfirmNo() []string { return words("confirm_no") } + // TaskDropWords — see TaskDoneWords. func TaskDropWords() []string { return words("task_drop_words") } diff --git a/internal/lexicon/lexicon_ru_v1.json b/internal/lexicon/lexicon_ru_v1.json index 8feaf89..9a03d01 100644 --- a/internal/lexicon/lexicon_ru_v1.json +++ b/internal/lexicon/lexicon_ru_v1.json @@ -197,6 +197,22 @@ "drop", "remove", "cancel", "передумал", "передумала", "неактуально" ] + }, + "confirm_yes": { + "note": "The whole vocabulary of saying yes to a parked confirm, Russian and English. Closed because it is her question that is being answered: she asked \"да или нет\", and the answers to that question can be listed. Matched as whole tokens and never as substrings — \"погода\", \"давление\" and \"дальше\" all contain \"да\", and a substring test executed a destructive act when he asked about the weather (V-567). Words that merely sound agreeable — \"хорошо\", \"ладно\", \"точно\" — are deliberately absent: they open a sentence about something else as often as they answer, and an unclear answer must route rather than execute.", + "words": [ + "да", "ага", "угу", "давай", "давайте", "конечно", + "подтверждаю", "подтверди", "подтвердить", "выполняй", "валяй", + "yes", "yeah", "yep", "yup", "ok", "okay", "sure", "confirm", "affirmative" + ] + }, + "confirm_no": { + "note": "The answers that decline a parked confirm. Same matching rule as confirm_yes and the same reason. The multi-word members are here rather than assembled by a caller because \"надо\" alone is not an answer and \"не надо\" is the opposite of one: the two must land on opposite sides, and only the phrase says which. \"не\" on its own is NOT a member — \"не забудь купить хлеб\" is a reminder, not a refusal.", + "words": [ + "нет", "неа", "нельзя", "отмена", "отмени", "отменить", "отставить", + "стоп", "стой", "не надо", "не нужно", "не стоит", "не сейчас", "не хочу", + "no", "nope", "nah", "negative", "cancel", "stop", "don't", "dont" + ] } } }