22edc3cdfb
DefaultFactParser matched Russian by hand-written stem substring: "вод", "пил", "душ", "еда" and eleven more, with a helper whose own comment said it would use a morphology lib "until misfires actually bite". That is the fourth mechanism CLAUDE.md says does not exist, and it ran on every fact turn through both wirings in cmd/mavend/voicewire.go. Five closed classes move to internal/lexicon — water nouns and drink verbs, meal words, shower, break, sleep — and internal/morph does the inflection. Three dictionary quirks are carried as data rather than worked around in code, each with its reason in the set's note: "вода" and "водой" lemmatise to two different lemmas, "пил" lemmatises to the saw, and "спал" to "спасть". Shower is matched exactly rather than by lemma, because the dictionary makes "душ" and "душа" one word and only one of them is washing. The accusative of an inanimate noun is its nominative, so exact matching costs nothing he says. NOT behaviour-preserving, deliberately. Rejected now: "пилот", "водитель", "заводить", "душа", "душно", "беда", "победа". "есть" and "ел" are left out of the meal set on purpose — "есть новости по бэкапу" is a question. The vestigial "ate"/"backup" guard goes with the substring era that needed it. Measured on the RU routing fixture, classifier+ONNX arm (91 cases): 64/91 (70.3%) before and after, same failing cases. The LLM arm was not measured — no llama-server reachable from here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
584 lines
19 KiB
Go
584 lines
19 KiB
Go
package router
|
|
|
|
import (
|
|
"context"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/lexicon"
|
|
"github.com/kami/maven/internal/morph"
|
|
)
|
|
|
|
// 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 {
|
|
// ResolvedTheHour is the gate, not the parser's ok (V-577, V-579,
|
|
// V-610). A sentence that names a day and no hour parses to that day
|
|
// at the current minute, and so does one whose hour the parser could
|
|
// not read. Filling the slot with either invents the answer she asked
|
|
// for. Left empty, the daemon asks.
|
|
if t, ok, err := e.Time.Parse(ctx, utterance, now); err == nil && ok && ResolvedTheHour(utterance, t) {
|
|
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))
|
|
toks := strings.Fields(s)
|
|
// Maven is ru-first (voice, tts), and Russian inflects, so the words each
|
|
// case reads are closed classes in internal/lexicon and the inflection is
|
|
// morph's job (V-586). What stood here was a fourth mechanism: hand-written
|
|
// stems matched as substrings, so "пилот" was drinking, "водитель" was
|
|
// water, "душно" was a shower and "победа" was a meal.
|
|
switch {
|
|
case anyLemma(toks, lexicon.WaterNouns()) && anyLemma(toks, lexicon.DrinkVerbs()):
|
|
return "water", `"drank"`, true
|
|
case anyLemma(toks, lexicon.MealWords()):
|
|
return "meal", `"ate"`, true
|
|
case anyExact(toks, lexicon.ShowerWords()):
|
|
return "shower", `"took"`, true
|
|
case anyLemma(toks, lexicon.BreakWords()):
|
|
return "break", `"took"`, true
|
|
case anyLemma(toks, lexicon.SleepWords()):
|
|
if v, ok := parseDurationValue(afterWord(s, "slept")); ok {
|
|
return "sleep", strconv.Quote(v), true
|
|
}
|
|
return "sleep", `"slept"`, true
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
// anyLemma reports whether any token is one of the set's words, in any case or
|
|
// tense. Exact equality first because morph falls back to it without the
|
|
// dictionary, and because a member the dictionary lemmatises oddly is carried
|
|
// in the set as its surface form.
|
|
func anyLemma(toks []string, set []string) bool {
|
|
for _, tok := range toks {
|
|
t := cleanWord(tok)
|
|
for _, w := range set {
|
|
if t == w || morph.SameWord(t, w) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// anyExact is anyLemma without the grammar, for a set whose members share a
|
|
// lemma with a word that means something else. Only ShowerWords needs it.
|
|
func anyExact(toks []string, set []string) bool {
|
|
for _, tok := range toks {
|
|
t := cleanWord(tok)
|
|
for _, w := range set {
|
|
if t == 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
|
|
}
|