a named time that did not parse never borrows the last one (V-543)

Four reminders in a row on the box all landed at the first one's hour, each
confirmed as if it had been read from the sentence: "напомни без четверти
восемь выходить" fired at 07:30. followUpMerge inherits a missing slot from
the previous same-intent turn, and a reminder time is one of those slots. It
also filled the slot before actionReminder's own fallback parse could run, so
inheriting hid a time that did parse.

router.MentionsTime tells the two cases apart. A sentence that names no time
still inherits, which is the follow-up the seam exists for. A sentence that
names one the parser missed keeps an empty slot, so she asks. Missing the hour
he said costs a question; borrowing one costs an alarm he stops thinking about.

Signals are lexicon classes and digits only: the day qualifiers, parts of day,
day offsets, weekdays by lemma through morph, the half-past and quarter-to
markers, and a written clock whose minutes are two digits so a score does not
pass for one.

Fact keys and act fns inherit through the same call and are left alone: a
borrowed key answers about the wrong thing out loud, which he hears, while a
borrowed hour is silent until it fires.
This commit is contained in:
2026-08-05 15:10:35 +04:00
parent be758d9a59
commit c07722266a
4 changed files with 201 additions and 1 deletions
+16 -1
View File
@@ -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
}
+36
View File
@@ -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{
+109
View File
@@ -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
}
+40
View File
@@ -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)
}
}
}