580959f856
"напомни к двум часам позвонить маме" now reads two o'clock. It read no time at all, so the reminder reached the daemon with an empty slot and she asked the open "Когда?" about an hour he had just said. The word that lost it was "часам", the dative plural of "час". Four sets in internal/router listed the hour noun and every one of them stopped at "часу". They are now one lexicon key, hour_units, read by all four through lexicon.HourUnits and lexicon.IsHourUnit. The minute noun had the same gap one word over and gets the same treatment in minute_units: "минутам" was missing everywhere "минут" and "минуты" were present. The slot_value_frame set no longer lists either noun and appends both, so there is one copy of each closed class rather than a copy per caller. Two more sites had to move for the sentence to parse. hourPrepositions knew "в", "во" and "на" and not "к", and the python dateparser rewrite knew the same three. Both now read the fifth preposition and the oblique forms of the hour that follow it. Fixture unchanged: classifier+hash 27/91 before and after, reach 18/30 before and after, no case moved in either direction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
570 lines
19 KiB
Go
570 lines
19 KiB
Go
package router
|
|
|
|
import (
|
|
"context"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/lexicon"
|
|
)
|
|
|
|
// DateTimeParser — resolves relative→absolute AT CAPTURE ("in 4h" → now+4h),
|
|
// per spec. The production impl is `dateparser` (ru+en relative+absolute) in a
|
|
// later module; the interface keeps slot extraction testable without it.
|
|
// Returns (time, true, nil) on a successful parse; (zero, false, nil) when the
|
|
// text carries no recognizable datetime — a missing slot, not an error.
|
|
type DateTimeParser interface {
|
|
Parse(ctx context.Context, text string, now time.Time) (time.Time, bool, error)
|
|
}
|
|
|
|
// ActMatcher — fuzzy-matches an utterance's verb against the fn allowlist.
|
|
// Not on the list → refuse, don't improvise (per spec). The production matcher
|
|
// is fuzzy; the scaffold ships exact + exact-with-args. Destructive acts still
|
|
// gate behind confirm at the daemon layer — the matcher only identifies the fn.
|
|
type ActMatcher interface {
|
|
Match(utterance string) (fn string, args []string, ok bool)
|
|
Allowlist() []string
|
|
}
|
|
|
|
// FactParser — pulls a (key,value) pair out of a fact utterance. "drank water"
|
|
// → key=water; "slept 6h" → key=sleep, value=6h. The loop evaluates predicates
|
|
// against the key; the value is the structured payload the daemon json-encodes
|
|
// before WriteFact. Tiny at mvp; the table of recognizers grows as code (same
|
|
// instinct as rules-as-code).
|
|
type FactParser interface {
|
|
Parse(utterance string) (key, value string, ok bool)
|
|
}
|
|
|
|
// Extractor — stage 2: per-intent slot extraction. Classification gives *what
|
|
// kind*, not *the args*. Each intent has its own parser; the router dispatches.
|
|
// The SLM's last-resort lane (free-form notes the parsers choke on) is NOT
|
|
// here — it lives in the phrasing module. The extractor is deterministic.
|
|
type Extractor struct {
|
|
Time DateTimeParser
|
|
Acts ActMatcher
|
|
Facts FactParser
|
|
}
|
|
|
|
// Extract — dispatches on intent, fills the relevant Slots fields. Best-effort:
|
|
// a slot that doesn't parse leaves its Has* flag false; the daemon/SLM last-
|
|
// resort lane picks it up. Never returns an error for "couldn't parse" —
|
|
// missing slot ≠ failure.
|
|
func (e Extractor) Extract(ctx context.Context, intent Intent, utterance string, now time.Time) Slots {
|
|
s := Slots{Text: utterance}
|
|
switch intent {
|
|
case IntentReminder:
|
|
if e.Time != nil {
|
|
// 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
|
|
}
|
|
}
|
|
case IntentAct:
|
|
if e.Acts != nil {
|
|
if fn, args, ok := e.Acts.Match(utterance); ok {
|
|
s.Fn = fn
|
|
s.Args = args
|
|
s.HasFn = true
|
|
}
|
|
}
|
|
case IntentFact:
|
|
if e.Facts != nil {
|
|
if k, v, ok := e.Facts.Parse(utterance); ok {
|
|
s.Key = k
|
|
s.Value = v
|
|
s.HasKey = true
|
|
}
|
|
}
|
|
case IntentChat:
|
|
// Chat has no structured slots — the full utterance is the payload.
|
|
// Slots.Text is already set to utterance at the top of Extract.
|
|
}
|
|
return s
|
|
}
|
|
|
|
// --- default implementations (scaffold floors; production swaps wholesale) ---
|
|
|
|
// DefaultActMatcher — exact verb prefix + remainder-as-args. The production
|
|
// matcher is fuzzy; this is the scaffold floor. "restart nginx" → fn=restart,
|
|
// args=[nginx]. Not on the list → ok=false → the router refuses the act.
|
|
type DefaultActMatcher struct {
|
|
Fns []string
|
|
}
|
|
|
|
func (m DefaultActMatcher) Allowlist() []string { return m.Fns }
|
|
|
|
func (m DefaultActMatcher) Match(utterance string) (string, []string, bool) {
|
|
u := strings.TrimSpace(utterance)
|
|
// longest-verb-first so "restart" can't be shadowed by a shorter prefix.
|
|
sorted := append([]string(nil), m.Fns...)
|
|
sortDescByLen(sorted)
|
|
for _, fn := range sorted {
|
|
if u == fn {
|
|
return fn, nil, true
|
|
}
|
|
if strings.HasPrefix(u, fn+" ") {
|
|
rest := strings.TrimSpace(strings.TrimPrefix(u, fn+" "))
|
|
return fn, splitArgs(rest), true
|
|
}
|
|
}
|
|
return "", nil, false
|
|
}
|
|
|
|
// DefaultFactParser — a handful of recognizers as code. Grows by append, not
|
|
// by config. Keys match the loop's rule keys (water/meal/sleep/break) so a
|
|
// captured fact actually feeds the predicates that read it.
|
|
type DefaultFactParser struct{}
|
|
|
|
func (DefaultFactParser) Parse(utterance string) (string, string, bool) {
|
|
s := strings.ToLower(strings.TrimSpace(utterance))
|
|
// Maven is ru-first (voice, tts). Each case carries the English tokens AND
|
|
// Russian stems — matched by prefix (hasStem) because Russian inflects
|
|
// (воды/воду/вода share "вод"), so exact-token matching would miss most
|
|
// real utterances and silently drop the capture.
|
|
switch {
|
|
case (containsWord(s, "water") && containsWord(s, "drank")) ||
|
|
(hasRoot(s, "вод") && (hasRoot(s, "пил") || hasRoot(s, "пью") || hasRoot(s, "пей"))):
|
|
return "water", `"drank"`, true
|
|
case containsWord(s, "meal") || (containsWord(s, "ate") && !containsWord(s, "backup")) || containsWord(s, "lunch") || containsWord(s, "dinner") ||
|
|
hasRoot(s, "поел") || hasRoot(s, "поесть") || hasRoot(s, "куша") || hasRoot(s, "обед") || hasRoot(s, "ужин") || hasRoot(s, "завтрак") || hasRoot(s, "еда"):
|
|
return "meal", `"ate"`, true
|
|
case containsWord(s, "shower") || hasRoot(s, "душ"):
|
|
return "shower", `"took"`, true
|
|
case containsWord(s, "break") || hasRoot(s, "перерыв") || hasRoot(s, "отдох"):
|
|
return "break", `"took"`, true
|
|
case containsWord(s, "slept") || containsWord(s, "sleep") ||
|
|
hasRoot(s, "спал") || hasRoot(s, "выспал"):
|
|
if v, ok := parseDurationValue(afterWord(s, "slept")); ok {
|
|
return "sleep", strconv.Quote(v), true
|
|
}
|
|
return "sleep", `"slept"`, true
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
// hasRoot — substring match on the whole utterance. Russian inflects with BOTH
|
|
// prefixes and suffixes (вы-пил, по-пил, пил-и), so a prefix test misses the
|
|
// verb; the root as a substring catches all forms. A rare over-match (пил in
|
|
// пилот) is fine at this floor. ponytail: substring roots over a morphology lib
|
|
// until misfires actually bite.
|
|
func hasRoot(s, root string) bool { return strings.Contains(s, root) }
|
|
|
|
// containsWord — whole-token membership (avoids "breakfast" matching "break").
|
|
func containsWord(s, w string) bool {
|
|
for _, tok := range strings.Fields(s) {
|
|
if tok == w {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// afterWord — the remainder of s after the first occurrence of word w (tokens).
|
|
func afterWord(s, w string) string {
|
|
toks := strings.Fields(s)
|
|
for i, t := range toks {
|
|
if t == w {
|
|
return strings.Join(toks[i+1:], " ")
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// hourPrepositions — the words a spoken hour sits behind. Five, 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.
|
|
//
|
|
// "к" and "ко" joined the three on V-609. "напомни к двум часам" named an hour
|
|
// and parsed to nothing, so the reminder reached the daemon with no time and she
|
|
// asked the open question about an hour he had just said.
|
|
var hourPrepositions = map[string]bool{"в": true, "во": true, "на": 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
|
|
// interface is the seam, not this implementation.
|
|
type StubDateTimeParser struct{}
|
|
|
|
func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) (time.Time, bool, error) {
|
|
s := strings.ToLower(strings.TrimSpace(SpellOutDigits(text)))
|
|
toks := strings.Fields(s)
|
|
// scan for "in <num> <unit>" anywhere — dateparser extracts the datetime
|
|
// expression from surrounding text; the stub does the same naively.
|
|
for i := 0; i+2 < len(toks); i++ {
|
|
if toks[i] != "in" {
|
|
continue
|
|
}
|
|
n, unit, ok := splitNumUnit(toks[i+1] + " " + toks[i+2])
|
|
if !ok {
|
|
continue
|
|
}
|
|
if d, ok := unitToDuration(n, unit); ok {
|
|
return now.Add(d), true, nil
|
|
}
|
|
}
|
|
// scan for "at <clock>" anywhere.
|
|
for i := 0; i+1 < len(toks); i++ {
|
|
if toks[i] != "at" {
|
|
continue
|
|
}
|
|
if t, ok := parseClock(toks[i+1], now); ok {
|
|
return t, true, nil
|
|
}
|
|
}
|
|
|
|
// --- Russian time expressions (stub floor; dateparser replaces) ---
|
|
|
|
// "в <clock>" anywhere — mirror of the English "at" scan. A qualifier
|
|
// 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 !hourPrepositions[toks[i]] {
|
|
continue
|
|
}
|
|
t, ok := parseClock(toks[i+1], now)
|
|
if !ok {
|
|
continue
|
|
}
|
|
// 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)
|
|
}
|
|
// 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) / "через полчаса".
|
|
for i := 0; i+1 < len(toks); i++ {
|
|
if toks[i] != "через" {
|
|
continue
|
|
}
|
|
// "через N unit" — three-token scan.
|
|
if i+2 < len(toks) {
|
|
n, unit, ok := splitNumUnit(toks[i+1] + " " + toks[i+2])
|
|
if ok {
|
|
if d, ok := unitToDuration(n, unit); ok {
|
|
return now.Add(d), true, nil
|
|
}
|
|
}
|
|
}
|
|
// "через полчаса"
|
|
if toks[i+1] == "полчаса" {
|
|
return now.Add(30 * time.Minute), true, nil
|
|
}
|
|
// "через unit" (bare unit without number = 1, e.g. "через час")
|
|
if d, ok := unitToDuration(1, toks[i+1]); ok {
|
|
return now.Add(d), true, nil
|
|
}
|
|
}
|
|
|
|
// Calendar day: "сегодня", "завтра", "послезавтра" [в] <clock>
|
|
for i := 0; i < len(toks); i++ {
|
|
var dayShift time.Duration
|
|
switch toks[i] {
|
|
case "сегодня":
|
|
dayShift = 0
|
|
case "завтра":
|
|
dayShift = 24 * time.Hour
|
|
case "послезавтра":
|
|
dayShift = 48 * time.Hour
|
|
default:
|
|
continue
|
|
}
|
|
base := now.Truncate(24 * time.Hour).Add(dayShift)
|
|
// Look for clock after the day word (optional "в").
|
|
nextIdx := i + 1
|
|
if nextIdx < len(toks) && toks[nextIdx] == "в" {
|
|
nextIdx++
|
|
}
|
|
if nextIdx < len(toks) {
|
|
if t, ok := parseClock(toks[nextIdx], now); ok {
|
|
t = time.Date(base.Year(), base.Month(), base.Day(), t.Hour(), t.Minute(), 0, 0, now.Location())
|
|
return t, true, nil
|
|
}
|
|
}
|
|
// No clock — return midnight of that day.
|
|
return base, true, nil
|
|
}
|
|
|
|
// bare clock at start ("7:30").
|
|
if len(toks) > 0 {
|
|
if t, ok := parseClock(toks[0], now); ok {
|
|
return t, true, nil
|
|
}
|
|
}
|
|
return time.Time{}, false, nil
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func splitArgs(rest string) []string {
|
|
parts := strings.Fields(rest)
|
|
if len(parts) == 0 {
|
|
return nil
|
|
}
|
|
return parts
|
|
}
|
|
|
|
func sortDescByLen(ss []string) {
|
|
for i := 1; i < len(ss); i++ {
|
|
for j := i; j > 0 && len(ss[j]) > len(ss[j-1]); j-- {
|
|
ss[j], ss[j-1] = ss[j-1], ss[j]
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
return time.Time{}, false
|
|
}
|
|
m := 0
|
|
if len(parts) == 2 {
|
|
m, err = strconv.Atoi(parts[1])
|
|
if err != nil || m < 0 || m > 59 {
|
|
return time.Time{}, false
|
|
}
|
|
}
|
|
t := time.Date(now.Year(), now.Month(), now.Day(), h, m, 0, 0, now.Location())
|
|
if !t.After(now) {
|
|
t = t.Add(24 * time.Hour)
|
|
}
|
|
return t, true
|
|
}
|
|
|
|
// splitNumUnit — "4h" → (4, "h"); "thirty minutes" → (30, "minutes"). Also
|
|
// handles a small set of English word numbers ("four", "thirty") so the stub
|
|
// parses natural reminder seeds; `dateparser` brings the full ru/en coverage.
|
|
func splitNumUnit(s string) (int, string, bool) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return 0, "", false
|
|
}
|
|
if n, rest, ok := leadingDigits(s); ok {
|
|
return n, strings.TrimSpace(rest), true
|
|
}
|
|
if n, rest, ok := leadingWordNumber(s); ok {
|
|
return n, strings.TrimSpace(rest), true
|
|
}
|
|
return 0, "", false
|
|
}
|
|
|
|
func leadingDigits(s string) (int, string, bool) {
|
|
i := 0
|
|
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
|
i++
|
|
}
|
|
if i == 0 {
|
|
return 0, "", false
|
|
}
|
|
n, err := strconv.Atoi(s[:i])
|
|
if err != nil {
|
|
return 0, "", false
|
|
}
|
|
return n, s[i:], true
|
|
}
|
|
|
|
// leadingWordNumber reads a spoken number off the front of a phrase — "два
|
|
// часа", "twenty minutes". The number words are a closed class and live in
|
|
// internal/lexicon, complete: the inline table here stopped at "десять" in
|
|
// Russian, so "пятнадцать минут" was not a duration (Vikunja #525).
|
|
|
|
func leadingWordNumber(s string) (int, string, bool) {
|
|
toks := strings.Fields(s)
|
|
if len(toks) == 0 {
|
|
return 0, "", false
|
|
}
|
|
n, ok := lexicon.Cardinal(toks[0])
|
|
if !ok {
|
|
return 0, "", false
|
|
}
|
|
return n, strings.Join(toks[1:], " "), true
|
|
}
|
|
|
|
func unitToDuration(n int, unit string) (time.Duration, bool) {
|
|
// The hour and the minute nouns are closed classes with one home in the
|
|
// lexicon, and the list here used to be short of the oblique forms (V-609).
|
|
if lexicon.IsHourUnit(unit) {
|
|
return time.Duration(n) * time.Hour, true
|
|
}
|
|
if lexicon.IsMinuteUnit(unit) {
|
|
return time.Duration(n) * time.Minute, true
|
|
}
|
|
switch unit {
|
|
case "h", "hr", "hrs":
|
|
return time.Duration(n) * time.Hour, true
|
|
case "m", "min", "mins":
|
|
return time.Duration(n) * time.Minute, true
|
|
case "s", "sec", "secs", "second", "seconds":
|
|
return time.Duration(n) * time.Second, true
|
|
// English day/week (pre-existing gap)
|
|
case "day", "days":
|
|
return time.Duration(n) * 24 * time.Hour, true
|
|
// Russian units (inflected forms)
|
|
case "день", "дня", "дней":
|
|
return time.Duration(n) * 24 * time.Hour, true
|
|
case "неделя", "недели", "недель":
|
|
return time.Duration(n) * 7 * 24 * time.Hour, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// parseDurationValue — used by the fact parser for "slept 6h" → value "6h".
|
|
func parseDurationValue(s string) (string, bool) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return "", false
|
|
}
|
|
n, unit, ok := splitNumUnit(s)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
if _, ok := unitToDuration(n, unit); !ok {
|
|
return "", false
|
|
}
|
|
return strconv.Itoa(n) + unit, true
|
|
}
|
|
|
|
// AnaphoraResolver resolves pronouns like "это", "он", "она" to the prior
|
|
// turn's key entity. Returns the matched pronoun's class as ref, or
|
|
// ("", false) when no pronoun is detected — the caller cross-references ref
|
|
// against the prior turn's own slots, this type holds no state of its own.
|
|
type AnaphoraResolver struct{}
|
|
|
|
// Resolve checks if text contains an anaphoric reference to a prior turn's
|
|
// entity. For MVP this handles the common Russian pronouns:
|
|
// - "это" / "этого" / "этому" / "этим" / "этом" / "эти" / "эта" → "this"
|
|
// (most common)
|
|
// - "он" / "его" / "ему" / "ним" → "he/it", masc
|
|
// - "она" / "её" / "ей" / "ней" → "she/it", fem
|
|
// - "оно" → "it", neuter
|
|
// - "тот" / "та" / "то" / "те" → "that"
|
|
// - "мой" and its declined forms → "mine"
|
|
//
|
|
// Returns the matching pronoun class for cross-referencing with prior slots.
|
|
func (AnaphoraResolver) Resolve(text string) (ref string, ok bool) {
|
|
s := strings.ToLower(strings.TrimSpace(text))
|
|
toks := strings.Fields(s)
|
|
for _, tok := range toks {
|
|
switch tok {
|
|
case "это", "этого", "этому", "этим", "этом", "эти", "эта":
|
|
return "this", true
|
|
case "он", "его", "ему", "ним":
|
|
return "he", true
|
|
case "она", "её", "ей", "ней":
|
|
return "she", true
|
|
case "оно":
|
|
return "it", true
|
|
case "тот", "та", "то", "те":
|
|
return "that", true
|
|
case "мой", "моего", "моему", "моим", "моём", "моя", "моей", "моё":
|
|
return "mine", true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// ParseCalendarDate detects RU/EN calendar day words in text and returns
|
|
// midnight of that day in now's own time zone. Returns zero time + false if no
|
|
// match.
|
|
//
|
|
// The day words are a closed class and live in internal/lexicon, so this is a
|
|
// lookup rather than an ordered switch (Vikunja #525). The switch it replaced
|
|
// had to test "послезавтра" before "завтра" by hand, because one contains the
|
|
// other — and it matched on substrings, so "завтраком" was tomorrow. The lexicon
|
|
// matches on word boundaries and gained "позавчера", which was never here.
|
|
func ParseCalendarDate(text string, now time.Time) (time.Time, bool) {
|
|
days, ok := lexicon.DayOffsetIn(text)
|
|
if !ok {
|
|
return time.Time{}, false
|
|
}
|
|
return midnight(now, days), true
|
|
}
|
|
|
|
// midnight returns the start of the day that is `days` away from now, in
|
|
// now's time zone (now.Truncate(24h) would cut on a UTC boundary instead).
|
|
func midnight(now time.Time, days int) time.Time {
|
|
y, m, d := now.AddDate(0, 0, days).Date()
|
|
return time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
|
}
|
|
|
|
// applyRuQualifier moves an hour into the afternoon when he said "вечера" or
|
|
// "дня" after it. Noon-crossing only: 7 becomes 19, and 19 stays 19. Morning
|
|
// qualifiers need no arithmetic, they only confirm the hour as spoken.
|
|
//
|
|
// 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), ".,!?;:") {
|
|
case "вечера", "дня":
|
|
if h < 12 {
|
|
h += 12
|
|
}
|
|
case "утра", "ночи":
|
|
if h == 12 {
|
|
h = 0
|
|
}
|
|
default:
|
|
return t
|
|
}
|
|
out := time.Date(now.Year(), now.Month(), now.Day(), h, t.Minute(), 0, 0, now.Location())
|
|
if !out.After(now) {
|
|
out = out.Add(24 * time.Hour)
|
|
}
|
|
return out
|
|
}
|