Merge the clock invention fix (#231)
A parked clarify ate a foreign utterance and the clock answered for him.
Root cause was a step earlier than filed. ownContent("что у меня сегодня?")
returns empty, every token being frame, so needsRoute said no and no route was
computed at all. classifyTurnRole fell through to roleAnswer, the extractor read
сегодня, and the date parser answered a bare day word with that day at the
current minute.
needsRoute now routes a question shape even when every token is frame, and the
route decides when the utterance fills nothing she asked about. A frame match is
a hint, not a decision. roleAside is new: a note or fact stated mid-flow is
stored and the question comes back on the same reply.
router.NamesAnHour is the single gate on the reminder time slot, so a sentence
naming no hour never fills it. IsClockEcho is deleted; it could not catch на
завтра on the stub, which returns midnight rather than the clock. на joins в and
во as a frame around a spoken hour.
The owner's rule, ruled on 2026-08-06: a reminder commits only when what, what
time and what day are all answered, and every time question opens by stating the
clock. He confirmed both derived cases himself, so завтра в 15:00 and через час
commit with no question.
A global assertion in checkEnd now fails any trace whose reminder fires at the
current clock.
(V-577) (V-579)
This commit is contained in:
@@ -31,7 +31,12 @@ type PendingQuestion struct {
|
||||
Slots Slots // what it already filled
|
||||
Missing []Slot // what is still empty, in the order to ask about
|
||||
Utterance string // the user's original raw words
|
||||
Asked time.Time
|
||||
// WhenText is every answer he has given about the time, joined in the order
|
||||
// he gave them. Kept apart from Utterance because the utterance is the
|
||||
// reminder's payload, and because a time answer has to be read against the
|
||||
// request rather than alone: "завтра" names a day for an hour said earlier.
|
||||
WhenText string
|
||||
Asked time.Time
|
||||
TTL time.Duration
|
||||
Attempts int // questions already asked
|
||||
// MaxAttempts caps Attempts. 0 ⇒ DefaultMaxAttempts.
|
||||
|
||||
@@ -54,7 +54,9 @@ try:
|
||||
# часов" is read as seven hours from now. Only a qualifier (already an
|
||||
# am/pm above) or a colon makes it read the hour, so give it the colon.
|
||||
# English "at 7" fails identically, so both prepositions are rewritten.
|
||||
text = re.sub(r'(?<![\w:])(в|во|at)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов)?)?(?![\d:.\w])',
|
||||
# "на 9" is the same hour said with the other preposition, and it was not
|
||||
# read at all until V-579: "в 9" set the reminder and "на 9" did not.
|
||||
text = re.sub(r'(?<![\w:])(в|во|на|at)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов)?)?(?![\d:.\w])',
|
||||
lambda m: '%s %02d:00' % (m.group(1), int(m.group(2))), text, flags=re.IGNORECASE)
|
||||
settings = {'PREFER_DATES_FROM': 'future', 'RELATIVE_BASE': now}
|
||||
# Two-step: search_dates finds the date substring in text,
|
||||
|
||||
@@ -55,7 +55,11 @@ func (e Extractor) Extract(ctx context.Context, intent Intent, utterance string,
|
||||
switch intent {
|
||||
case IntentReminder:
|
||||
if e.Time != nil {
|
||||
if t, ok, err := e.Time.Parse(ctx, utterance, now); err == nil && ok {
|
||||
// NamesAnHour is the gate, not the parser's ok (V-577, V-579). A
|
||||
// sentence that names a day and no hour parses to that day at the
|
||||
// current minute, and filling the slot with it invents the answer
|
||||
// she asked for. Left empty, the daemon asks.
|
||||
if t, ok, err := e.Time.Parse(ctx, utterance, now); err == nil && ok && NamesAnHour(utterance) {
|
||||
s.Time = t
|
||||
s.HasTime = true
|
||||
}
|
||||
@@ -171,6 +175,11 @@ func afterWord(s, w string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// hourPrepositions — the words a spoken hour sits behind. Three, and no more:
|
||||
// the lexicon's frame set is much wider, and a word goes in here only when the
|
||||
// number after it is an hour of the day rather than a count of anything.
|
||||
var hourPrepositions = map[string]bool{"в": true, "во": true, "на": true}
|
||||
|
||||
// StubDateTimeParser — a tiny relative/absolute parser standing in for
|
||||
// `dateparser` until the i18n module lands. Handles "in Nh"/"in Nm"/"in Ns" and
|
||||
// "at HH:MM" / "HH:MM". The production path replaces this wholesale; the
|
||||
@@ -210,18 +219,27 @@ func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) (
|
||||
// after the hour moves it into the afternoon: "в 7 вечера" is 19:00, and
|
||||
// with SpellOutDigits in front of this that is what "в семь вечера" reads
|
||||
// as too (Vikunja #469).
|
||||
//
|
||||
// "на" and "во" frame a spoken hour the same way, and until V-579 only "в"
|
||||
// did: "в 9" set the reminder and "на 9" was not read at all.
|
||||
for i := 0; i+1 < len(toks); i++ {
|
||||
if toks[i] != "в" {
|
||||
if !hourPrepositions[toks[i]] {
|
||||
continue
|
||||
}
|
||||
t, ok := parseClock(toks[i+1], now)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if i+2 < len(toks) {
|
||||
t = applyRuQualifier(t, toks[i+2], now)
|
||||
// The qualifier is looked for anywhere in the sentence, not only right
|
||||
// after the hour. It arrives on its own turn when she asks which half of
|
||||
// the day he meant, and "на 9" plus "вечера" is one time (V-579).
|
||||
if qual := ruQualifierIn(toks); qual != "" {
|
||||
t = applyRuQualifier(t, qual, now)
|
||||
}
|
||||
return t, true, nil
|
||||
// A day word anywhere in the sentence moves the hour onto that day. This
|
||||
// scan runs before the calendar one below, so without this "напомни
|
||||
// завтра в 15:00" landed today and V-579 asks about exactly that gap.
|
||||
return applyRuDayShift(t, toks, now), true, nil
|
||||
}
|
||||
|
||||
// "через <N> <unit>" / "через <unit>" (bare = 1) / "через полчаса".
|
||||
@@ -307,6 +325,10 @@ func sortDescByLen(ss []string) {
|
||||
// parseClock — "7", "7:30" → today at that time; if already past today, roll
|
||||
// to tomorrow (a "wake me 7" at 8pm fires tomorrow 7). Used by the stub scan.
|
||||
func parseClock(clock string, now time.Time) (time.Time, bool) {
|
||||
// Speech arrives with its punctuation attached: "на 9." ends a sentence and
|
||||
// still names nine o'clock (V-579). The colon is kept, since it is the one
|
||||
// mark that is part of a clock.
|
||||
clock = strings.Trim(clock, ".,!?;")
|
||||
parts := strings.SplitN(clock, ":", 2)
|
||||
h, err := strconv.Atoi(parts[0])
|
||||
if err != nil || h < 0 || h > 23 {
|
||||
@@ -480,6 +502,39 @@ func midnight(now time.Time, days int) time.Time {
|
||||
//
|
||||
// The date is recomputed rather than shifted, so an hour that parseClock
|
||||
// already pushed to tomorrow does not land two days out.
|
||||
// applyRuDayShift moves an hour onto the day the sentence names, if it names
|
||||
// one. The hour is kept exactly as read: the day word says which day and says
|
||||
// nothing about when in it.
|
||||
// ruQualifierIn returns the first part-of-day word in the sentence, or "".
|
||||
func ruQualifierIn(toks []string) string {
|
||||
for _, tok := range toks {
|
||||
switch cleanWord(tok) {
|
||||
case "утра", "вечера", "дня", "ночи":
|
||||
return cleanWord(tok)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func applyRuDayShift(t time.Time, toks []string, now time.Time) time.Time {
|
||||
for _, tok := range toks {
|
||||
days := 0
|
||||
switch cleanWord(tok) {
|
||||
case "сегодня":
|
||||
days = 0
|
||||
case "завтра":
|
||||
days = 1
|
||||
case "послезавтра":
|
||||
days = 2
|
||||
default:
|
||||
continue
|
||||
}
|
||||
base := now.AddDate(0, 0, days)
|
||||
return time.Date(base.Year(), base.Month(), base.Day(), t.Hour(), t.Minute(), 0, 0, now.Location())
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func applyRuQualifier(t time.Time, qualifier string, now time.Time) time.Time {
|
||||
h := t.Hour()
|
||||
switch strings.Trim(strings.ToLower(qualifier), ".,!?;:") {
|
||||
|
||||
@@ -81,6 +81,148 @@ func NamesADay(text string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// NamesAnHour reports whether the sentence names a time of day or an interval
|
||||
// away from now: a written clock, a numeral, a half or quarter past, or one of
|
||||
// the words an interval is built from. A day word alone is not one — "завтра"
|
||||
// says which day and says nothing about when in it.
|
||||
//
|
||||
// It is the gate on the reminder's time slot (V-577, V-579). A time slot that
|
||||
// names no hour is never filled, it is asked about. Both parsers answer a bare
|
||||
// day word with that day at the current minute, so "что у меня сегодня?" set a
|
||||
// reminder at 01:28 and "на завтра" set one at 01:38 — the minute he happened
|
||||
// to be speaking, in a request that never named one. The stub answers with
|
||||
// midnight instead, which is a different invented hour and no better.
|
||||
//
|
||||
// Same discipline as MentionsTime above: every signal is a closed lexicon class
|
||||
// or a digit, so this reads data and decides nothing about meaning.
|
||||
func NamesAnHour(text string) bool {
|
||||
toks := strings.Fields(strings.ToLower(text))
|
||||
for i, raw := range toks {
|
||||
tok := cleanWord(raw)
|
||||
if isDigitClock(tok) || isAllDigits(tok) {
|
||||
return true
|
||||
}
|
||||
if _, ok := numeralDigit(tok); ok {
|
||||
return true
|
||||
}
|
||||
if hourMarkers[tok] {
|
||||
return true
|
||||
}
|
||||
if _, _, ok := halfPastAt(toks, i); ok {
|
||||
return true
|
||||
}
|
||||
if _, _, _, ok := quarterToAt(toks, i); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NamesAnInterval reports whether the sentence measures the time from now
|
||||
// instead of naming it: "через час", "через 10 минут", "in 30 minutes".
|
||||
//
|
||||
// An interval resolves to one instant, so it answers the hour and the day
|
||||
// together and nothing about it is ambiguous. Callers that ask which day or
|
||||
// which nine o'clock have to skip it (V-579).
|
||||
func NamesAnInterval(text string) bool {
|
||||
for _, raw := range strings.Fields(strings.ToLower(text)) {
|
||||
switch cleanWord(raw) {
|
||||
case "через", "спустя", "in":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HourIsAmbiguous reports whether the hour named could be either half of the
|
||||
// day: "в 3" is three in the afternoon or three at night, and only he knows
|
||||
// which (V-579, owner's rule of 2026-08-06).
|
||||
//
|
||||
// Three things settle it and any one is enough. A qualifier - "вечера", "pm",
|
||||
// "полдень" - says which half. A written clock says it by being written. An
|
||||
// hour above twelve says it by arithmetic. An interval names no hour at all.
|
||||
func HourIsAmbiguous(text string) bool {
|
||||
if NamesAnInterval(text) {
|
||||
return false
|
||||
}
|
||||
toks := strings.Fields(strings.ToLower(text))
|
||||
for _, raw := range toks {
|
||||
tok := cleanWord(raw)
|
||||
if hourQualifiers[tok] || isDigitClock(tok) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, raw := range toks {
|
||||
tok := cleanWord(raw)
|
||||
d, ok := numeralDigit(tok)
|
||||
if !ok && isAllDigits(tok) {
|
||||
d, ok = tok, true
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(d)
|
||||
if err == nil && n >= 1 && n <= 12 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hourQualifiers — the words that pin an hour to one half of the day. Closed,
|
||||
// and every member is a lexicon class or the two English markers.
|
||||
var hourQualifiers = buildHourQualifiers()
|
||||
|
||||
func buildHourQualifiers() map[string]bool {
|
||||
m := map[string]bool{
|
||||
"утра": true, "вечера": true, "дня": true, "ночи": true,
|
||||
"полдень": true, "полночь": true, "полудня": true,
|
||||
"am": true, "pm": true, "noon": true, "midnight": true,
|
||||
}
|
||||
for _, w := range lexicon.PartsOfDay() {
|
||||
m[w] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func isAllDigits(tok string) bool {
|
||||
if tok == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range tok {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// hourMarkers — timeMarkers minus the day words and the weekdays, which name a
|
||||
// day and not an hour, and minus "сейчас", which IS the clock and so can never
|
||||
// be the evidence that the clock was meant.
|
||||
var hourMarkers = buildHourMarkers()
|
||||
|
||||
func buildHourMarkers() map[string]bool {
|
||||
m := map[string]bool{
|
||||
"утра": true, "вечера": true, "дня": true, "ночи": true,
|
||||
"часа": true, "часов": true, "час": true, "часу": true,
|
||||
"минут": true, "минуты": true, "минуту": true,
|
||||
"через": true, "спустя": true, "полчаса": true,
|
||||
"полдень": true, "полночь": true,
|
||||
"am": true, "pm": true, "noon": true, "midnight": true, "in": true,
|
||||
}
|
||||
for _, w := range lexicon.PartsOfDay() {
|
||||
m[w] = true
|
||||
}
|
||||
for _, w := range lexicon.HalfHourWords() {
|
||||
m[w] = true
|
||||
}
|
||||
for w := range minutesTo {
|
||||
m[w] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// isMonth reports whether the token is a month name. The lexicon holds the
|
||||
// genitive, which is the form a spoken date uses: "10 июля".
|
||||
func isMonth(tok string) bool {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package router
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMentionsTime(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
@@ -21,6 +25,87 @@ func TestMentionsTime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNamesAnHour — the gate on the reminder's time slot (V-577, V-579). A day
|
||||
// word is not an hour, and a sentence that names no hour is asked about rather
|
||||
// than completed from the clock.
|
||||
func TestNamesAnHour(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"в 11:00", "на 9", "в 9", "в девять", "в 9 утра", "завтра в 9",
|
||||
"через час", "через двадцать минут", "в половине восьмого",
|
||||
"без четверти восемь", "remind me at noon", "вечером",
|
||||
} {
|
||||
if !NamesAnHour(s) {
|
||||
t.Errorf("NamesAnHour(%q) = false; this names an hour or an interval", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{
|
||||
"на завтра", "что у меня сегодня?", "напомни завтра позвонить маме",
|
||||
"в пятницу", "позвонить маме", "",
|
||||
} {
|
||||
if NamesAnHour(s) {
|
||||
t.Errorf("NamesAnHour(%q) = true; no hour was spoken, so she has to ask", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHourIsAmbiguous — the owner's rule of 2026-08-06. A bare hour is either
|
||||
// half of the day and gets asked about; a qualifier, a written clock, an hour
|
||||
// above twelve or an interval settles it and goes straight through.
|
||||
func TestHourIsAmbiguous(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"напомни завтра в 3 заказать цветы", "в 9", "на 9", "в девять", "в 11 позвонить маме",
|
||||
} {
|
||||
if !HourIsAmbiguous(s) {
|
||||
t.Errorf("HourIsAmbiguous(%q) = false; the hour could be either half of the day", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{
|
||||
"напомни в 9 вечера разгрузить стиралку", "завтра в 15:00", "в 21", "в 11:00",
|
||||
"через час", "через 10 минут", "remind me at noon", "напомни позвонить маме",
|
||||
} {
|
||||
if HourIsAmbiguous(s) {
|
||||
t.Errorf("HourIsAmbiguous(%q) = true; this time reads only one way", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNamesAnInterval — an interval resolves to one instant, so it answers the
|
||||
// hour and the day at once and is never asked about.
|
||||
func TestNamesAnInterval(t *testing.T) {
|
||||
for _, s := range []string{"через час", "через 10 минут", "через полчаса", "in 30 minutes"} {
|
||||
if !NamesAnInterval(s) {
|
||||
t.Errorf("NamesAnInterval(%q) = false", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"завтра в 15:00", "в 9 вечера", ""} {
|
||||
if NamesAnInterval(s) {
|
||||
t.Errorf("NamesAnInterval(%q) = true", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReminderSlotRefusesAnHourNobodySaid — the same rule where it bites. The
|
||||
// parser answers a bare day word with that day at the current minute, and the
|
||||
// slot must stay empty so the daemon asks.
|
||||
func TestReminderSlotRefusesAnHourNobodySaid(t *testing.T) {
|
||||
now := time.Date(2026, 8, 6, 1, 38, 0, 0, time.UTC)
|
||||
ex := Extractor{Time: clockEchoParser{}}
|
||||
if got := ex.Extract(context.Background(), IntentReminder, "на завтра", now); got.HasTime {
|
||||
t.Errorf("«на завтра» filled the time slot with %s, which is the clock", got.Time.Format("15:04"))
|
||||
}
|
||||
if got := ex.Extract(context.Background(), IntentReminder, "на 9", now); !got.HasTime {
|
||||
t.Error("«на 9» names an hour and must still fill the slot")
|
||||
}
|
||||
}
|
||||
|
||||
// clockEchoParser stands in for what both real parsers do with a bare day word:
|
||||
// it answers with the current time of day.
|
||||
type clockEchoParser struct{}
|
||||
|
||||
func (clockEchoParser) Parse(_ context.Context, _ string, now time.Time) (time.Time, bool, error) {
|
||||
return now.AddDate(0, 0, 1), true, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user