Merge the subjectless reminder gate (#183)

This commit is contained in:
2026-08-05 19:36:29 +04:00
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 {