a bare "напомни" asks instead of failing to parse (V-548)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
This commit is contained in:
2026-08-05 19:36:29 +04:00
parent 4ed951040f
commit d850f1f5fd
6 changed files with 133 additions and 2 deletions
+14
View File
@@ -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") }
+8
View File
@@ -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"
]
}
}
}
+3 -1
View File
@@ -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" }
]
}
+62
View File
@@ -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
}
+40
View File
@@ -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)
}
}
}
+6 -1
View File
@@ -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 {