Files
Maven/internal/router/slots.go
T
kami d00929ac0b Answer the day the user asked about and the city he named (#388)
replySystem had two arms that PR 30 made reachable, and both answered confidently wrong: the date arm keyword-matched "числ" and always answered today, so "какое число завтра" answered today; the clock arm ignored a named city and answered local time. The date arm now reads the day word through router.ParseCalendarDate (which grew послезавтра/вчера and now cuts the day boundary in the local zone instead of UTC). The clock arm answers the named zone when it resolves offline from the tz database embedded in the binary, and otherwise says plainly that she only knows local time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 14:02:57 +04:00

479 lines
16 KiB
Go

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
}
}
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 ""
}
// 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 <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.
for i := 0; i+1 < len(toks); i++ {
if toks[i] != "в" {
continue
}
if t, ok := parseClock(toks[i+1], now); ok {
return t, 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) {
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,
// Russian word numbers (gender variants cover natural речи)
"один": 1, "одна": 1, "одно": 1,
"два": 2, "две": 2, "три": 3, "четыре": 4,
"пять": 5, "шесть": 6, "семь": 7, "восемь": 8,
"девять": 9, "десять": 10,
}
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
// 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) * time.Hour, true
case "минута", "минуты", "минут":
return time.Duration(n) * time.Minute, true
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 a (key, value) pair the prior fact carried,
// or ("", "", false) when no pronoun is detected.
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
//
// Returns the matching pronoun type 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. Handles "сегодня", "завтра",
// "послезавтра", "вчера" (and the English words). Returns zero time + false
// if no match.
//
// "послезавтра" is checked before "завтра" because it contains it.
func ParseCalendarDate(text string, now time.Time) (time.Time, bool) {
lower := strings.ToLower(text)
switch {
case strings.Contains(lower, "сегодня") || strings.Contains(lower, "today"):
return midnight(now, 0), true
case strings.Contains(lower, "послезавтра") || strings.Contains(lower, "day after tomorrow"):
return midnight(now, 2), true
case strings.Contains(lower, "завтра") || strings.Contains(lower, "tomorrow"):
return midnight(now, 1), true
case strings.Contains(lower, "вчера") || strings.Contains(lower, "yesterday"):
return midnight(now, -1), true
}
return time.Time{}, false
}
// 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())
}