06adc4702d
Assign provenance where the exact fn is produced: - grammar_fixed: praxis/task-status grammars hardcode fn - grammar_matcher: wakeword-act grammar invokes ActMatcher - extractor_raw: Extractor.Extract matches over raw utterance - extractor_llm_text: fillSlots LLM backfill matches cleaned text - fallback_matcher: ResolveActionCandidate runs the fallback matcher ResolveActionCandidate propagates Slots.ResolvedBy into ActionCandidate.ResolvedBy. No selection behavior changes.
620 lines
21 KiB
Go
620 lines
21 KiB
Go
package router
|
|
|
|
import (
|
|
"context"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"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
|
|
s.ResolvedBy = ActionResolutionExtractorRaw
|
|
}
|
|
}
|
|
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 phrase prefix + remainder-as-args. This is the only
|
|
// matcher there is: internal/tool.Matcher delegates here over the live enabled
|
|
// names, so a phrase that does not match exactly cannot reach a tool.
|
|
// "restart nginx" → fn=restart, args=[nginx]. Not on the list → ok=false → the
|
|
// router refuses the act.
|
|
//
|
|
// Aliases map a tool name to spoken phrases, so a Russian utterance reaches an
|
|
// English tool name. They come from the deployment config as data, never from a
|
|
// stem pattern in code, and they match as exact leading tokens: "перезагрузи
|
|
// роутер" is a command and "перезагрузил роутер" is a fact, and lemma matching
|
|
// cannot tell the two apart (the trap cmd/mavend/quiet_toggle.go documents).
|
|
type DefaultActMatcher struct {
|
|
Fns []string
|
|
Aliases map[string][]string
|
|
}
|
|
|
|
func (m DefaultActMatcher) Allowlist() []string { return m.Fns }
|
|
|
|
func (m DefaultActMatcher) Match(utterance string) (string, []string, bool) {
|
|
u := strings.TrimSpace(strings.ToLower(utterance))
|
|
u = strings.TrimRight(u, "?!.")
|
|
// One table of phrase → fn, so an alias and a name compete on length rather
|
|
// than on which loop ran first. Longest-first, so "docker-restart" cannot be
|
|
// shadowed by "restart" and a two-word alias beats the one-word one inside it.
|
|
phrases := make([]string, 0, len(m.Fns))
|
|
fnOf := make(map[string]string, len(m.Fns))
|
|
add := func(phrase, fn string) {
|
|
phrase = strings.TrimSpace(strings.ToLower(phrase))
|
|
if phrase == "" {
|
|
return
|
|
}
|
|
if _, seen := fnOf[phrase]; seen {
|
|
return
|
|
}
|
|
fnOf[phrase] = fn
|
|
phrases = append(phrases, phrase)
|
|
}
|
|
for _, fn := range m.Fns {
|
|
add(fn, fn)
|
|
for _, a := range m.Aliases[fn] {
|
|
add(a, fn)
|
|
}
|
|
}
|
|
sortDescByLen(phrases)
|
|
for _, p := range phrases {
|
|
if u == p {
|
|
return fnOf[p], nil, true
|
|
}
|
|
if strings.HasPrefix(u, p+" ") {
|
|
rest := strings.TrimSpace(strings.TrimPrefix(u, p+" "))
|
|
return fnOf[p], 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) {
|
|
// Pronouns are a closed grammatical class. Split on punctuation instead of
|
|
// trying to encode word boundaries in a regexp: "это?" and "его," are the
|
|
// same pronouns as "это" and "его", including next to Cyrillic letters.
|
|
toks := strings.FieldsFunc(strings.ToLower(strings.TrimSpace(text)), func(r rune) bool {
|
|
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
|
})
|
|
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
|
|
}
|