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:
+16
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user