diff --git a/cmd/mavend/followup.go b/cmd/mavend/followup.go index 7da65fe..2a18a5c 100644 --- a/cmd/mavend/followup.go +++ b/cmd/mavend/followup.go @@ -97,7 +97,8 @@ var anaphoraResolver router.AnaphoraResolver // followUpMerge fills the current turn's missing slots from a prior // non-expired session — the multi-turn seam. It handles three cases: // -// 1. Same-intent: inherit missing slots via InheritSlots (existing behavior). +// 1. Same-intent: inherit missing slots via InheritSlots (existing behavior), +// except a reminder time the current sentence named and the parser missed. // 2. Cross-intent anaphora: if the current utterance contains a pronoun // ("это" / "он" / "она" etc.) AND the prior session has a key, inherit // the key for fact-lookup queries and reminder creation. @@ -113,8 +114,22 @@ func followUpMerge(prev *dialogue.Session, dec router.Decision, now time.Time) r // Case 1: same-intent inheritance (existing). if prev.Intent == dialogue.Intent(dec.Intent) { + // A reminder that named an hour nobody could read must not borrow the + // last one's. Two reminders in a row and the second landed at the + // first's time, confirmed as if it had been read from the sentence: + // "напомни без четверти восемь выходить" fired at 07:30 (V-543). The + // hour is also what fills before the action's own fallback parse can + // run, so inheriting it hid a time that did parse. + // + // Inheriting is still right when the sentence names no time at all, + // which is the follow-up this seam exists for. + blockTime := dec.Intent == router.IntentReminder && + !dec.Slots.HasTime && router.MentionsTime(dec.Utterance) merged := dialogue.InheritSlots(prev.Slots, toDialogueSlots(dec.Slots)) dec.Slots = applyDialogueSlots(dec.Slots, merged) + if blockTime { + dec.Slots.Time, dec.Slots.HasTime = time.Time{}, false + } return dec } diff --git a/cmd/mavend/followup_test.go b/cmd/mavend/followup_test.go index cd2ebcf..6facfb1 100644 --- a/cmd/mavend/followup_test.go +++ b/cmd/mavend/followup_test.go @@ -35,6 +35,42 @@ func TestFollowUpMerge(t *testing.T) { } }) + // V-543, measured on the box: four reminders in a row all landed at the + // first one's hour, each confirmed as if it had been read from the sentence. + // A sentence that names a time and fails to parse must ask, not borrow. + t.Run("a named time that did not parse is not inherited", func(t *testing.T) { + for _, utt := range []string{ + "напомни без четверти восемь выходить", + "напомни в половине первого пообедать", + "напомни завтра принять лекарство", + "remind me at noon to stretch", + } { + cur := router.Decision{ + Intent: router.IntentReminder, + Utterance: utt, + Slots: router.Slots{Text: utt}, + } + got := followUpMerge(prev, cur, base.Add(30*time.Second)) + if got.Slots.HasTime { + t.Errorf("%q borrowed the previous hour %v", utt, got.Slots.Time) + } + } + }) + + // The follow-up this seam exists for still works: the sentence names no + // time, so the previous one is the only one it could mean. + t.Run("a follow-up naming no time still inherits", func(t *testing.T) { + cur := router.Decision{ + Intent: router.IntentReminder, + Utterance: "и ещё полить цветы", + Slots: router.Slots{Text: "полить цветы"}, + } + got := followUpMerge(prev, cur, base.Add(30*time.Second)) + if !got.Slots.HasTime || !got.Slots.Time.Equal(fireAt) { + t.Errorf("time not inherited: HasTime=%v Time=%v", got.Slots.HasTime, got.Slots.Time) + } + }) + t.Run("current slot wins over prior (gaps only)", func(t *testing.T) { own := base.Add(48 * time.Hour) cur := router.Decision{ diff --git a/internal/router/timementions.go b/internal/router/timementions.go new file mode 100644 index 0000000..c267433 --- /dev/null +++ b/internal/router/timementions.go @@ -0,0 +1,109 @@ +package router + +import ( + "strconv" + "strings" + + "github.com/kami/maven/internal/lexicon" + "github.com/kami/maven/internal/morph" +) + +// MentionsTime reports whether the sentence names a time at all, whether or not +// a parser could read it. +// +// The caller is the multi-turn seam. A reminder whose time slot is empty used to +// inherit the previous reminder's hour, so "напомни без четверти восемь +// выходить" landed at 07:30 because the turn before it had (V-543). Inheriting +// is right when the sentence names no time and wrong when it names one the +// parser missed, and this is the test that tells those apart. Missing the time +// he said means asking; inheriting means a wrong alarm he stops thinking about. +// +// Every signal here is a closed lexicon class or a digit, so this reads data and +// decides nothing about meaning. +func MentionsTime(text string) bool { + toks := strings.Fields(strings.ToLower(text)) + for i, raw := range toks { + tok := cleanWord(raw) + if timeMarkers[tok] { + return true + } + if isDigitClock(tok) { + return true + } + if _, ok := numeralDigit(tok); ok && hasTimeNeighbour(toks, i) { + return true + } + if _, _, ok := halfPastAt(toks, i); ok { + return true + } + if _, _, _, ok := quarterToAt(toks, i); ok { + return true + } + if isWeekday(tok) { + return true + } + } + return false +} + +// isDigitClock reports whether the token is a written clock, "19:30". The +// minutes must be written as two digits, because a clock is and a score is not: +// "счёт 3:2" names no time. +func isDigitClock(tok string) bool { + h, m, found := strings.Cut(tok, ":") + if !found || len(m) != 2 { + return false + } + hn, err := strconv.Atoi(h) + if err != nil || hn < 0 || hn > 23 { + return false + } + mn, err := strconv.Atoi(m) + return err == nil && mn >= 0 && mn <= 59 +} + +// isWeekday reports whether the token is a day of the week in any case. The +// lexicon lists the nominative, and "в пятницу" is what a reminder says, so the +// match is by lemma — grammar is morph's job, not a second word list. +func isWeekday(tok string) bool { + for i := 0; i < 7; i++ { + if morph.SameWord(tok, lexicon.Weekday(i)) { + return true + } + } + return false +} + +// timeMarkers — the words that name a time on their own: the qualifiers that +// turn an hour into a part of the day, the relative day words, the weekdays and +// the two relative openers. Built from the lexicon at init, so a word added +// there is a word this reads. +var timeMarkers = buildTimeMarkers() + +func buildTimeMarkers() map[string]bool { + m := map[string]bool{ + "утра": true, "вечера": true, "дня": true, "ночи": true, + "часа": true, "часов": true, "час": true, "часу": true, + "минут": true, "минуты": true, "минуту": true, + "через": true, "полчаса": true, "сейчас": true, + "am": true, "pm": true, "noon": true, "midnight": true, + } + for _, w := range lexicon.PartsOfDay() { + m[w] = true + } + for _, w := range lexicon.DayOffsetWords() { + m[w] = true + } + for i := 0; i < 7; i++ { + if w := lexicon.Weekday(i); w != "" { + m[w] = true + } + } + for w := range halfWords { + m[w] = true + } + for w := range minutesTo { + m[w] = true + } + return m +} diff --git a/internal/router/timementions_test.go b/internal/router/timementions_test.go new file mode 100644 index 0000000..c6d4988 --- /dev/null +++ b/internal/router/timementions_test.go @@ -0,0 +1,40 @@ +package router + +import "testing" + +func TestMentionsTime(t *testing.T) { + for _, s := range []string{ + "напомни в семь вечера позвонить маме", + "напомни в 19:30 позвонить маме", + "напомни без четверти восемь выходить", + "напомни в половине первого пообедать", + "разбуди меня полвосьмого", + "напомни завтра принять лекарство", + "напомни в пятницу забрать заказ", + "напомни через двадцать минут", + "напомни утром выпить таблетку", + "remind me at noon to stretch", + } { + if !MentionsTime(s) { + t.Errorf("MentionsTime(%q) = false; this sentence names a time", s) + } + } +} + +// A sentence with no time in it must not read as one, or a real follow-up stops +// inheriting the hour it meant. +func TestMentionsTimeIgnoresSentencesWithoutOne(t *testing.T) { + for _, s := range []string{ + "напомни позвонить маме", + "и ещё полить цветы", + "напомни про счёт за свет", + "купить три яблока", + "перезапусти докер", + "счёт 3:2 в нашу пользу", + "", + } { + if MentionsTime(s) { + t.Errorf("MentionsTime(%q) = true; there is no time in it", s) + } + } +}