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 {