Merge the subjectless reminder gate (#183)
This commit is contained in:
@@ -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") }
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user