Files
Maven/internal/router/timementions.go
T
claude 52a4772962 ordinal selection asks the lexicon, and declines a half hour (V-522)
Group 1 of the sweep listed cmd/mavend/ordinal.go, and it was still
picking a position by stem prefix: {"перв", 1}, {"втор", 2}. The lexicon
already carries every form with its position and "последний" as -1, up to
twelve rather than five, so parseOrdinal reads that instead. "вторым" and
"седьмую" were missed before and now land.

A wider set opens one hole the stems did not have. Russian names a half
hour with the genitive ordinal of the hour it is entering, so "в половине
восьмого" would read as the eighth thing she read out. The forms of
"половина" move into the lexicon as half_hour, where the clock rewrite in
internal/router/halfpast.go and this refusal read one copy, and
parseOrdinal skips an ordinal standing behind one.

Six new parseOrdinal cases. cmd/mavend, internal/router, internal/lexicon
and internal/calendar all pass.
2026-08-05 18:16:54 +04:00

140 lines
4.1 KiB
Go

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
}
// NamesADay reports whether the sentence names a calendar day: a weekday, a
// relative day word, or a month beside a date. A bare clock names none of them,
// which is what lets a parser roll it forward to the next occurrence.
//
// "напомни сегодня в 12:30" names the day, so it stays on it even when 12:30 has
// passed. Rolling that one forward would move a reminder he placed deliberately.
func NamesADay(text string) bool {
for _, raw := range strings.Fields(strings.ToLower(text)) {
tok := cleanWord(raw)
if _, ok := lexicon.DayOffset(tok); ok {
return true
}
if isWeekday(tok) || isMonth(tok) {
return true
}
}
return false
}
// 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 {
for m := 1; m <= 12; m++ {
if tok == lexicon.MonthGenitive(m) {
return true
}
}
return false
}
// 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 lexicon.HalfHourWords() {
m[w] = true
}
for w := range minutesTo {
m[w] = true
}
return m
}