330 lines
10 KiB
Go
330 lines
10 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
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|
|
// 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
|
|
}
|