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",
|
"interrogatives", "capture_verbs", "narrative_requests", "cardinals", "ordinals",
|
||||||
"day_offsets", "weekdays", "months_genitive", "hours_spoken",
|
"day_offsets", "weekdays", "months_genitive", "hours_spoken",
|
||||||
"not_place_after_v", "parts_of_day", "reminder_verbs", "half_hour",
|
"not_place_after_v", "parts_of_day", "reminder_verbs", "half_hour",
|
||||||
|
"filler_particles",
|
||||||
} {
|
} {
|
||||||
s, ok := f.Sets[name]
|
s, ok := f.Sets[name]
|
||||||
if !ok || (len(s.Words) == 0 && len(s.Values) == 0) {
|
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.
|
// ReminderVerbs returns the imperatives that open a reminder.
|
||||||
func ReminderVerbs() []string { return words("reminder_verbs") }
|
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
|
// HalfHourWords returns those forms, for a caller folding every time word into
|
||||||
// one set rather than asking about one word.
|
// one set rather than asking about one word.
|
||||||
func HalfHourWords() []string { return words("half_hour") }
|
func HalfHourWords() []string { return words("half_hour") }
|
||||||
|
|||||||
@@ -173,6 +173,14 @@
|
|||||||
"половина", "половине", "половину", "половины", "пол",
|
"половина", "половине", "половину", "половины", "пол",
|
||||||
"half"
|
"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-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-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-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.
|
// 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
|
// 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).
|
// 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
|
d.Confidence = llmThinConfidence
|
||||||
}
|
}
|
||||||
if d.Confidence < r.threshold {
|
if d.Confidence < r.threshold {
|
||||||
|
|||||||
Reference in New Issue
Block a user