a time slot naming no hour is asked about, never filled (V-579)
Both parsers answer a bare day word with that day at the current minute, so "на завтра" set a reminder at 01:38, the minute he happened to be speaking. The gate is textual now: NamesAnHour reads the sentence, and the slot stays empty when nobody said an hour. Beside it, NamesAnInterval and HourIsAmbiguous, which the owner's commit rule reads. "на" joins "в" as a frame around a spoken hour in both parsers, a clock keeps its meaning with a full stop after it, and the stub applies a day word and a part-of-day qualifier from anywhere in the sentence rather than only from the token after the hour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -54,7 +54,9 @@ try:
|
|||||||
# часов" is read as seven hours from now. Only a qualifier (already an
|
# часов" 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.
|
# 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.
|
# 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)
|
lambda m: '%s %02d:00' % (m.group(1), int(m.group(2))), text, flags=re.IGNORECASE)
|
||||||
settings = {'PREFER_DATES_FROM': 'future', 'RELATIVE_BASE': now}
|
settings = {'PREFER_DATES_FROM': 'future', 'RELATIVE_BASE': now}
|
||||||
# Two-step: search_dates finds the date substring in text,
|
# 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 {
|
switch intent {
|
||||||
case IntentReminder:
|
case IntentReminder:
|
||||||
if e.Time != nil {
|
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.Time = t
|
||||||
s.HasTime = true
|
s.HasTime = true
|
||||||
}
|
}
|
||||||
@@ -171,6 +175,11 @@ func afterWord(s, w string) string {
|
|||||||
return ""
|
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
|
// StubDateTimeParser — a tiny relative/absolute parser standing in for
|
||||||
// `dateparser` until the i18n module lands. Handles "in Nh"/"in Nm"/"in Ns" and
|
// `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
|
// "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
|
// after the hour moves it into the afternoon: "в 7 вечера" is 19:00, and
|
||||||
// with SpellOutDigits in front of this that is what "в семь вечера" reads
|
// with SpellOutDigits in front of this that is what "в семь вечера" reads
|
||||||
// as too (Vikunja #469).
|
// 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++ {
|
for i := 0; i+1 < len(toks); i++ {
|
||||||
if toks[i] != "в" {
|
if !hourPrepositions[toks[i]] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
t, ok := parseClock(toks[i+1], now)
|
t, ok := parseClock(toks[i+1], now)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if i+2 < len(toks) {
|
// The qualifier is looked for anywhere in the sentence, not only right
|
||||||
t = applyRuQualifier(t, toks[i+2], now)
|
// 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) / "через полчаса".
|
// "через <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
|
// 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.
|
// 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) {
|
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)
|
parts := strings.SplitN(clock, ":", 2)
|
||||||
h, err := strconv.Atoi(parts[0])
|
h, err := strconv.Atoi(parts[0])
|
||||||
if err != nil || h < 0 || h > 23 {
|
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
|
// The date is recomputed rather than shifted, so an hour that parseClock
|
||||||
// already pushed to tomorrow does not land two days out.
|
// 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 {
|
func applyRuQualifier(t time.Time, qualifier string, now time.Time) time.Time {
|
||||||
h := t.Hour()
|
h := t.Hour()
|
||||||
switch strings.Trim(strings.ToLower(qualifier), ".,!?;:") {
|
switch strings.Trim(strings.ToLower(qualifier), ".,!?;:") {
|
||||||
|
|||||||
@@ -81,6 +81,148 @@ func NamesADay(text string) bool {
|
|||||||
return false
|
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
|
// isMonth reports whether the token is a month name. The lexicon holds the
|
||||||
// genitive, which is the form a spoken date uses: "10 июля".
|
// genitive, which is the form a spoken date uses: "10 июля".
|
||||||
func isMonth(tok string) bool {
|
func isMonth(tok string) bool {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
func TestMentionsTime(t *testing.T) {
|
func TestMentionsTime(t *testing.T) {
|
||||||
for _, s := range []string{
|
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
|
// A sentence with no time in it must not read as one, or a real follow-up stops
|
||||||
// inheriting the hour it meant.
|
// inheriting the hour it meant.
|
||||||
func TestMentionsTimeIgnoresSentencesWithoutOne(t *testing.T) {
|
func TestMentionsTimeIgnoresSentencesWithoutOne(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user