confirm answers match whole words from the lexicon (V-567)

classifyConfirm was a substring test over bare stems, so "погода",
"дальше", "надо" and "давление" all read as "да", and "покажи" and
"около" read as "ок". resolveConfirm runs before routing, so a question
about the weather executed a parked destructive act. Reproduced on the box:
with "restart nonexistent-xyz" parked, "какая погода" answered "не
получилось выполнить команду".

The yes and no answers are now two closed sets in internal/lexicon, matched
as whole tokens longest-first, and the WHOLE utterance must be answer words
and filler — a leading "давай" does not make "давай посмотрим погоду" an
answer. Anything else is confirmUnknown, which now leaves the confirm parked
instead of disarming it: an utterance that is not an answer is not a
cancellation either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 01:05:50 +04:00
parent b6305f1b6e
commit 31deb7d565
4 changed files with 228 additions and 21 deletions
+98 -21
View File
@@ -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.
+103
View File
@@ -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")
}
}
+11
View File
@@ -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") }
+16
View File
@@ -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"
]
}
}
}