01c78ef369
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>
282 lines
8.6 KiB
Go
282 lines
8.6 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
|
||
}
|
||
|
||
// 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 {
|
||
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
|
||
}
|