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:
@@ -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), ".,!?;:") {
|
||||
|
||||
Reference in New Issue
Block a user