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
+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)
}
}
}