From d850f1f5fdbd2e893fd8864451f4d8c33be879fb Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 19:36:29 +0400 Subject: [PATCH] =?UTF-8?q?a=20bare=20"=D0=BD=D0=B0=D0=BF=D0=BE=D0=BC?= =?UTF-8?q?=D0=BD=D0=B8"=20asks=20instead=20of=20failing=20to=20parse=20(V?= =?UTF-8?q?-548)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subjectless-reminder gate has been dead since V-383. It tested `d.Slots.Text == ""`, and that slot is never empty: fillSlots hands it the utterance when the model names nothing narrower. Measured on the box on 05-08-2026 — "напомни" alone routed to IntentReminder with Text:напомни, reached actionReminder, and answered "не получилось разобрать время напоминания." A parse error for a request he never finished asking about. "ну напомни же" did the same. The test is now what the slot CONTAINS. reminderHasSubject discounts the reminder verb by lemma and the filler particles, and asks whether anything is left. A day or an hour counts as a subject, which is why this does not reuse cmd/mavend/reminderbody.go — that one strips the time words too. filler_particles is the lexicon's 16th set. Not a stopword list: every word in it is one that cannot BE a reminder's subject. Measured against the 87-case fixture with and without the change: 65/87 both ways, identical clarify counts, because no case exercised the shape. So amb-007 "напомни" and amb-008 "ну напомни же" were added, both want_clarify. At 89 cases the cascade scores 67/89 (75.3% full, 79.8% intent-only), 3 false clarifies / 1 missed, p50 1.199s — the two new cases clarify, and nothing else moved. The classifier path still guesses both (62/89, 8 missed clarify); the gate is on the LLM arm only. The box needs a rebuild for this to take effect. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy --- internal/lexicon/lexicon.go | 14 ++++++ internal/lexicon/lexicon_ru_v1.json | 8 ++++ internal/router/eval/ru_routing_v1.json | 4 +- internal/router/remindersubject.go | 62 +++++++++++++++++++++++++ internal/router/remindersubject_test.go | 40 ++++++++++++++++ internal/router/router.go | 7 ++- 6 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 internal/router/remindersubject.go create mode 100644 internal/router/remindersubject_test.go diff --git a/internal/lexicon/lexicon.go b/internal/lexicon/lexicon.go index b7824d9..ab06327 100644 --- a/internal/lexicon/lexicon.go +++ b/internal/lexicon/lexicon.go @@ -64,6 +64,7 @@ func mustLoad() lexiconFile { "interrogatives", "capture_verbs", "narrative_requests", "cardinals", "ordinals", "day_offsets", "weekdays", "months_genitive", "hours_spoken", "not_place_after_v", "parts_of_day", "reminder_verbs", "half_hour", + "filler_particles", } { s, ok := f.Sets[name] if !ok || (len(s.Words) == 0 && len(s.Values) == 0) { @@ -112,6 +113,19 @@ func PartsOfDay() []string { return words("parts_of_day") } // ReminderVerbs returns the imperatives that open a reminder. func ReminderVerbs() []string { return words("reminder_verbs") } +// IsFillerParticle reports whether a word can never be the subject of a +// request: a particle, a politeness word, or the first-person object. See the +// set's own note for why this is not a stopword list. +func IsFillerParticle(word string) bool { + w := norm(word) + for _, p := range ru.Sets["filler_particles"].Words { + if w == p { + return true + } + } + return false +} + // HalfHourWords returns those forms, for a caller folding every time word into // one set rather than asking about one word. func HalfHourWords() []string { return words("half_hour") } diff --git a/internal/lexicon/lexicon_ru_v1.json b/internal/lexicon/lexicon_ru_v1.json index 134a083..b3ee915 100644 --- a/internal/lexicon/lexicon_ru_v1.json +++ b/internal/lexicon/lexicon_ru_v1.json @@ -173,6 +173,14 @@ "половина", "половине", "половину", "половины", "пол", "half" ] + }, + "filler_particles": { + "note": "Words that carry no subject of their own: particles, the politeness words, and the first-person object he addresses her with. A caller asking \"did he say WHAT to remind him about\" has to discount these, or \"ну напомни же\" and \"напомни мне пожалуйста\" both read as a reminder whose subject is the particle. Closed in the sense that matters: these are function words, and the language is not adding any. Not a stopword list — a stopword list is a scoring convenience and may be as long as it likes, while every word here has to be one that cannot BE a reminder's subject.", + "words": [ + "ну", "же", "уж", "там", "вот", "пожалуйста", "плиз", "ка", + "давай", "давай-ка", "а", "и", "бы", "мне", "меня", "мной", + "please", "just", "hey", "me" + ] } } } diff --git a/internal/router/eval/ru_routing_v1.json b/internal/router/eval/ru_routing_v1.json index c66ed89..5fbef0b 100644 --- a/internal/router/eval/ru_routing_v1.json +++ b/internal/router/eval/ru_routing_v1.json @@ -102,6 +102,8 @@ { "id": "amb-003", "utterance": "ну это", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "filler"] }, { "id": "amb-004", "utterance": "сделай это", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "anaphora"], "note": "unresolved anaphora with an imperative — must not guess an fn" }, { "id": "amb-005", "utterance": "потом", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "filler"] }, - { "id": "amb-006", "utterance": "the thing from earlier", "lang": "en", "want_clarify": true, "tags": ["ambiguous", "anaphora"] } + { "id": "amb-006", "utterance": "the thing from earlier", "lang": "en", "want_clarify": true, "tags": ["ambiguous", "anaphora"] }, + { "id": "amb-007", "utterance": "напомни", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "reminder"], "note": "the reminder verb and nothing else — she knows the shape of the request and not one thing about it. Answered 'не получилось разобрать время напоминания' on the box until V-548: the subjectless-reminder gate tested Slots.Text == \"\", and fillSlots had put the verb in that slot" }, + { "id": "amb-008", "utterance": "ну напомни же", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "reminder", "filler"], "note": "the same request wrapped in particles, which is why filler_particles is a lexicon set — without it the particles read as the subject" } ] } diff --git a/internal/router/remindersubject.go b/internal/router/remindersubject.go new file mode 100644 index 0000000..701cd65 --- /dev/null +++ b/internal/router/remindersubject.go @@ -0,0 +1,62 @@ +package router + +import ( + "strings" + + "github.com/kami/maven/internal/lexicon" + "github.com/kami/maven/internal/morph" +) + +// A reminder needs something to say at the hour, and the gate that checks for +// one was reading a slot that is never empty. +// +// gateLLMDecision has asked about a subjectless reminder since V-383, on the +// test `d.Slots.Text == ""`. Measured on the box on 05-08-2026: "напомни" alone +// routes to IntentReminder with `Text:напомни`, because fillSlots hands the text +// slot the utterance when the model names nothing narrower. So the slot was +// never empty, the gate never fired, and the turn reached actionReminder and +// answered "не получилось разобрать время напоминания." — a parse error for a +// request she never finished asking about. "ну напомни же" did the same. +// +// The fix is to ask what the text slot CONTAINS rather than whether it is set. +// Two closed classes answer that and no third mechanism is needed: the reminder +// verbs are her own vocabulary (lexicon.ReminderVerbs), and the particles and +// politeness words cannot be the subject of anything (lexicon.IsFillerParticle). +// A verb is matched by lemma through morph.SameWord, so "напоминай" and +// "напомнить" need no entry of their own. +// +// Deliberately NOT reusing cmd/mavend/reminderbody.go, which strips the same +// marker: that function also strips the time words, so "напомни завтра" would +// read as subjectless there. Asking is right when he named no subject, and wrong +// when he named a day — the reminder for tomorrow is the one whose subject she +// should ask about, not one she should treat as noise. + +// reminderHasSubject reports whether a reminder's text names anything to say at +// the hour. False for "напомни", "напомни мне", "ну напомни же"; true for +// "напомни позвонить маме" and for "напомни завтра", where the day is a subject +// she can ask nothing better about. +func reminderHasSubject(text string) bool { + for _, f := range strings.Fields(strings.ToLower(text)) { + w := strings.Trim(f, " ,.;:!?—-«»\"'()") + if w == "" || lexicon.IsFillerParticle(w) { + continue + } + if isReminderVerb(w) { + continue + } + return true + } + return false +} + +// isReminderVerb matches one of her reminder imperatives by lemma. Lemma and not +// prefix: "напоминание" is a noun he can perfectly well ask to be reminded +// about, and a stem test would eat it. +func isReminderVerb(word string) bool { + for _, v := range lexicon.ReminderVerbs() { + if word == v || morph.SameWord(word, v) { + return true + } + } + return false +} diff --git a/internal/router/remindersubject_test.go b/internal/router/remindersubject_test.go new file mode 100644 index 0000000..80b0565 --- /dev/null +++ b/internal/router/remindersubject_test.go @@ -0,0 +1,40 @@ +package router + +import "testing" + +func TestReminderHasSubject(t *testing.T) { + cases := []struct { + text string + want bool + }{ + // The three the box produced, and the reason this file exists. + {"напомни", false}, + {"ну напомни же", false}, + {"напомни мне", false}, + {"напомни мне пожалуйста", false}, + // Lemma, not literal: none of these forms is the one in the utterance + // the lexicon lists first. + {"напоминай", false}, + {"напомнить", false}, + {"remind me", false}, + {"remind me please", false}, + // A real subject, however short. + {"напомни позвонить маме", true}, + {"напомни про таблетки", true}, + {"напомни выпить воды", true}, + {"remind me to call mom", true}, + // A day is a subject she can ask nothing better about, so she does not + // ask. This is where reminderBody's stripping would disagree, on purpose. + {"напомни завтра", true}, + {"напомни в семь", true}, + // A noun that starts like the verb. A stem test would eat it. + {"напомни про напоминание", true}, + // Empty is subjectless without asking the lexicon anything. + {"", false}, + } + for _, c := range cases { + if got := reminderHasSubject(c.text); got != c.want { + t.Errorf("reminderHasSubject(%q) = %v, want %v", c.text, got, c.want) + } + } +} diff --git a/internal/router/router.go b/internal/router/router.go index c30738e..68457e3 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -189,7 +189,12 @@ func (r *Router) gateLLMDecision(d *Decision) { // A reminder with no subject: she knows when but not what to say then. // Setting it anyway fires an empty reminder at the hour, which reads as a // bug to him and cannot be repaired after the fact. Ask (Vikunja #383). - if d.Intent == IntentReminder && d.Slots.Text == "" && d.Confidence > llmThinConfidence { + // + // The test is what the text slot CONTAINS, not whether it is set. It was the + // latter until 05-08-2026, and the slot is never empty: fillSlots hands it + // the utterance, so "напомни" arrived here with Text:напомни and the gate + // never fired (V-457). See remindersubject.go. + if d.Intent == IntentReminder && !reminderHasSubject(d.Slots.Text) && d.Confidence > llmThinConfidence { d.Confidence = llmThinConfidence } if d.Confidence < r.threshold {