1d10c9535c
"напомни завтра к трём часам дня позвонить врачу" now sets 15:00. It set 03:53, which was the clock at the moment of the turn. She confirmed that as the hour he had just said. #252 taught hourPrepositions and the dateparser rewrite the preposition "к". So HasTime and NamesAnHour started answering true for the sentence. The value did not follow. The rewrite kept his preposition and handed dateparser "завтра к 03:00 pm". dateparser joins a day word to a clock through "в" and through no other Russian preposition. It read the day, dropped the clock and filled the time from its relative base. The completeness rule then saw what, time and day all answered, and committed at the current minute. The preposition is normalised along with the hour now. "на" was losing the clock the same way and was never measured. So "напомни завтра на 9" was landing on the current minute too. The second half is the durable one. ResolvedTheHour is the gate the reminder slot reads, and it refuses a parse whose minute nobody spoke. A spoken hour lands on the hour. The three shapes that name a minute of their own are a written clock, a half hour and a quarter to. Anything else came off the clock the parser was handed. An interval is exempt, because it lands where the arithmetic says. Comparing the whole instant to now is the obvious test and it is wrong. ru-rem-006 resolves to 12:00 and the fixture reference clock is 12:00. That is an hour he did say, reading as an hour nobody did. The five sentences measured on the box are pinned as tests. They run against the stub and against the production parser, and the two that already passed are in there too. Fixture unchanged. classifier+hash is 27/91 and classifier+onnx is 64/91, before and after. reach is 18/30 and 27/30, before and after. No case moved and no clarify count changed. Suite green under -race. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
358 lines
11 KiB
Go
358 lines
11 KiB
Go
package router
|
||
|
||
import (
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// ResolvedTheHour reports whether a parse read the hour the sentence names,
|
||
// rather than inheriting the clock it was handed as its relative base.
|
||
//
|
||
// It is the second half of the gate NamesAnHour opens (V-610). NamesAnHour asks
|
||
// whether an hour was spoken and cannot ask whether it was read, so a
|
||
// preposition the parser half knew wrote a reminder at 03:53 for "напомни
|
||
// завтра к трём часам дня" and confirmed it as if it were the hour he said. A
|
||
// wrong instant she states as fact is worse than a question, because he stops
|
||
// thinking about it.
|
||
//
|
||
// The tell is the minute. A spoken hour lands on the hour, and the only three
|
||
// shapes that name a minute of their own are a written clock, a half hour and a
|
||
// quarter to. A parse that came back with any other minute took it from the
|
||
// clock it was handed, whatever hour it put in front of it. An interval is
|
||
// exempt, because it is measured from now and lands wherever the arithmetic
|
||
// says.
|
||
//
|
||
// Comparing the whole instant to now would be the obvious test and it is the
|
||
// wrong one: "напомни послезавтра в 12" resolves to 12:00 and the fixture's
|
||
// reference clock is 12:00, so an hour he did say would read as an hour nobody
|
||
// did.
|
||
func ResolvedTheHour(text string, t time.Time) bool {
|
||
if !NamesAnHour(text) {
|
||
return false
|
||
}
|
||
if NamesAnInterval(text) || t.Minute() == 0 {
|
||
return true
|
||
}
|
||
return namesTheMinute(text)
|
||
}
|
||
|
||
// namesTheMinute reports whether the sentence says which minute of the hour it
|
||
// means, in any of the three ways it can.
|
||
func namesTheMinute(text string) bool {
|
||
toks := strings.Fields(strings.ToLower(text))
|
||
for i, raw := range toks {
|
||
if isDigitClock(cleanWord(raw)) {
|
||
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,
|
||
"am": true, "pm": true, "noon": true, "midnight": true, "in": true,
|
||
}
|
||
for _, w := range lexicon.HourUnits() {
|
||
m[w] = true
|
||
}
|
||
for _, w := range lexicon.MinuteUnits() {
|
||
m[w] = 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 {
|
||
for m := 1; m <= 12; m++ {
|
||
if tok == lexicon.MonthGenitive(m) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// WeekdayIndex reports which day of the week a token names, in any case and in
|
||
// either language, or false when it names none.
|
||
//
|
||
// One matcher for the whole daemon (V-581). Four files used to keep a weekday
|
||
// list of their own and each one was short in a different direction: the habit
|
||
// map had "воскресеньях" but no "средах", the plan refusal had "среду" but not
|
||
// "среде", and cmd/mavend matched the STEM "сред" with strings.Contains, so
|
||
// "среди" and "средство" read as Wednesday. The lexicon lists the nominative,
|
||
// every Russian case lemmatises to it, and only English needs its forms written
|
||
// out — the vendored dictionary is Russian and leaves "mondays" alone.
|
||
func WeekdayIndex(tok string) (time.Weekday, bool) {
|
||
t := strings.ToLower(strings.TrimSpace(tok))
|
||
if n, ok := lexicon.WeekdayEnglish(t); ok {
|
||
return time.Weekday(n), true
|
||
}
|
||
for i, name := range lexicon.Weekdays() {
|
||
if morph.SameWord(t, name) {
|
||
return time.Weekday(i), true
|
||
}
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
// isWeekday reports whether the token is a day of the week, when the caller
|
||
// does not need to know which one.
|
||
func isWeekday(tok string) bool {
|
||
_, ok := WeekdayIndex(tok)
|
||
return ok
|
||
}
|
||
|
||
// 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,
|
||
"am": true, "pm": true, "noon": true, "midnight": true,
|
||
}
|
||
for _, w := range lexicon.HourUnits() {
|
||
m[w] = true
|
||
}
|
||
for _, w := range lexicon.MinuteUnits() {
|
||
m[w] = 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
|
||
}
|