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 { // 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 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)) // 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 " 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 " 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) --- // "в " 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 } // "через " / "через " (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: "сегодня", "завтра", "послезавтра" [в] 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 }