package router import ( "context" "strconv" "strings" "time" ) // 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 { if t, ok, err := e.Time.Parse(ctx, utterance, now); err == nil && ok { 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 } } } 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 "" } // 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(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 } } // 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) { 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 } // wordNumbers — small set, enough for natural test seeds ("four hours", // "thirty minutes"). Production dateparser handles the full ru/en range. var wordNumbers = map[string]int{ "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10, "eleven": 11, "twelve": 12, "fifteen": 15, "twenty": 20, "thirty": 30, "forty": 40, "fifty": 50, "sixty": 60, } func leadingWordNumber(s string) (int, string, bool) { toks := strings.Fields(s) if len(toks) == 0 { return 0, "", false } n, ok := wordNumbers[toks[0]] if !ok { return 0, "", false } return n, strings.Join(toks[1:], " "), true } func unitToDuration(n int, unit string) (time.Duration, bool) { switch unit { case "h", "hour", "hours", "hr", "hrs": return time.Duration(n) * time.Hour, true case "m", "min", "mins", "minute", "minutes": return time.Duration(n) * time.Minute, true case "s", "sec", "secs", "second", "seconds": return time.Duration(n) * time.Second, 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 }