From a886217223976aeb76504667b4fceea3a275135d Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 8 Aug 2026 21:43:15 +0400 Subject: [PATCH] A greeting is not a failed answer (V-663) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit classifyTurnRole read "спасибо" and "привет" as answers to whatever was parked, so she re-asked "В какой день?" at a man saying thank you and spent one of three attempts doing it. That attempt is a bound meant to end the ride, so the pleasantry both produced the worst reply in the corpus and paid for the privilege. They are asides now: answered as themselves, the question resumed on the tail, no attempt spent, one ride counted. The set is a new closed lexicon entry, matched as WHOLE utterances. Every token rule tried was wrong on something. "вечер" answers "это утра или вечера?" and "нет" answers a confirm, so anything that could fill a slot stays out. The control words stay out too, because isCancel owns them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN --- cmd/mavend/turnrole.go | 32 +++++++++++++++++++++++++++++ cmd/mavend/turnrole_test.go | 27 ++++++++++++------------ internal/lexicon/lexicon.go | 5 +++++ internal/lexicon/lexicon_ru_v1.json | 13 ++++++++++++ 4 files changed, 64 insertions(+), 13 deletions(-) diff --git a/cmd/mavend/turnrole.go b/cmd/mavend/turnrole.go index 677b1d8..7b88a70 100644 --- a/cmd/mavend/turnrole.go +++ b/cmd/mavend/turnrole.go @@ -186,6 +186,28 @@ func carriesReminderVerb(text string) bool { return false } +// isPleasantry matches the WHOLE utterance against lexicon.Pleasantries, after +// lowercasing and dropping the punctuation a greeting carries. +// +// Whole utterance and not tokens. Every token rule tried here was wrong on +// something: "вечер" answers "это утра или вечера?", "нет" answers a confirm, +// and "спокойной" alone is not an utterance at all. A greeting is a fixed +// phrase, so matching it as one costs nothing and claims nothing else. +func isPleasantry(text string) bool { + t := strings.ToLower(strings.TrimSpace(text)) + t = strings.Trim(t, " .,!?…") + t = strings.Join(strings.Fields(t), " ") + if t == "" { + return false + } + for _, p := range lexicon.Pleasantries() { + if t == p { + return true + } + } + return false +} + // offlineOwnRequest is the shape half of the evidence: the offline token tests, // which cost nothing and never depend on the model that produced the routing. // It is also the whole answer when there is no route to read — the classifier @@ -225,6 +247,16 @@ func classifyTurnRole(q *dialogue.PendingQuestion, text string, answer dialogue. // hour, and no route saying "question" changes that. It works because the // extractor no longer reads a day word as the current clock, so a sentence // that names no hour now fills nothing to weigh. + // A pleasantry is neither (V-663). "спасибо" and "привет" fell through to + // roleAnswer, so a question about a reminder's DAY was re-asked at a man + // saying thank you, and the retry it spent was one of the three bounds + // meant to end the ride. It is an aside: answered as itself, the question + // resumed on the tail, no attempt spent, one ride counted. Placed above the + // content gate because "доброе утро" has content and states nothing, so + // neither half of the evidence below can reach it. + if q != nil && isPleasantry(text) { + return roleAside + } own := false if len(ownContent(text)) > 0 { own = offlineOwnRequest(text) || (ok && carriesOwnRequest(routed, text)) diff --git a/cmd/mavend/turnrole_test.go b/cmd/mavend/turnrole_test.go index 6243033..767b0ae 100644 --- a/cmd/mavend/turnrole_test.go +++ b/cmd/mavend/turnrole_test.go @@ -411,22 +411,23 @@ func TestTwoBoundsCannotRearmEachOther(t *testing.T) { t.Fatalf("after two asides: %+v", q) } - // A failed answer. It spends an attempt and puts Suspends back to zero, so - // the old bound starts over. Rides does not. - h.handleText(ctx, "web", "спасибо") - if q = h.clarifyStore.Get(id, h.now()); q == nil || q.Suspends != 0 { - t.Fatalf("the failed answer did not rearm the old bound: %+v", q) + // A pleasantry. It used to read as a failed answer, so she re-asked the + // question at a man saying thank you and spent an attempt doing it. Now it + // is an aside: answered as itself, question on the tail, one more ride. + reply := h.handleText(ctx, "web", "спасибо") + if !strings.HasSuffix(reply, resumed) { + t.Fatalf("a pleasantry lost the parked question: %q", reply) } - if q.Rides != 2 { - t.Fatalf("the rides it already took were forgotten: %+v", q) + q = h.clarifyStore.Get(id, h.now()) + if q == nil || q.Attempts != 1 { + t.Fatalf("a pleasantry spent an attempt: %+v", q) + } + if q.Rides != 3 { + t.Fatalf("a pleasantry rode free: %+v", q) } - // Three more asides. Suspends only reaches three, one short of firing, and - // would go back to zero on the next pleasantry. Rides passes MaxRides and - // the request goes, out loud. - h.handleText(ctx, "web", "какие у меня напоминания?") - h.handleText(ctx, "web", "какие у меня напоминания?") - reply := h.handleText(ctx, "web", "какие у меня напоминания?") + // One more ride of any kind and the request goes, out loud. + reply = h.handleText(ctx, "web", "какие у меня напоминания?") if !strings.Contains(reply, clarifyDropped) { t.Fatalf("the question rode four asides and was let go in silence: %q", reply) } diff --git a/internal/lexicon/lexicon.go b/internal/lexicon/lexicon.go index 40af6c2..731687b 100644 --- a/internal/lexicon/lexicon.go +++ b/internal/lexicon/lexicon.go @@ -119,6 +119,11 @@ func PartsOfDay() []string { return words("parts_of_day") } // ReminderVerbs returns the imperatives that open a reminder. func ReminderVerbs() []string { return words("reminder_verbs") } +// Pleasantries returns the whole utterances that greet, thank or say goodbye. +// Whole utterances and not tokens: see the set's own note for why the tokens +// are unsafe alone. +func Pleasantries() []string { return words("pleasantries") } + // TaskDoneWords returns the words that finish a task, and TaskDropWords the // words that abandon one. Two sets rather than one with a value, because the // store records which of the two happened and the caller has to say so. diff --git a/internal/lexicon/lexicon_ru_v1.json b/internal/lexicon/lexicon_ru_v1.json index f74e0b3..aac8e20 100644 --- a/internal/lexicon/lexicon_ru_v1.json +++ b/internal/lexicon/lexicon_ru_v1.json @@ -176,6 +176,19 @@ "morning", "afternoon", "evening", "night" ] }, + "pleasantries": { + "note": "Whole utterances that greet, thank or say goodbye. They ask for nothing and answer nothing, so a parked question must neither consume them as a failed answer nor be dropped by them (V-663). Matched as WHOLE utterances and never as tokens, because the tokens are not safe alone: \"вечер\" answers \"это утра или вечера?\" and \"нет\" answers a confirm. Anything that could fill a slot stays out. The control words (\"стоп\", \"отмена\") stay out too, because isCancel already owns them and they mean something stronger.", + "words": [ + "привет", "приветик", "здравствуй", "здравствуйте", + "доброе утро", "добрый день", "добрый вечер", + "пока", "прощай", "до свидания", "спокойной ночи", + "спасибо", "спасибо тебе", "большое спасибо", "благодарю", + "извини", "извините", "прости", "простите", + "hi", "hello", "hey", "bye", "goodbye", + "good morning", "good evening", "good night", + "thanks", "thank you", "thanks a lot", "sorry" + ] + }, "reminder_verbs": { "note": "The imperatives that mean \"remind me\", in the forms he speaks. The same kind of set as capture_verbs and decided the same way: it is her vocabulary, not a discovery about Russian (Vikunja #530). The alarm verbs joined them in V-627. \"разбуди меня в 6:30\" is a reminder that fires at the hour he gets up, and the set knew no form of it, so an alarm reached IntentReminder only by resembling one to the embedder.", "words": [