From 28a940ebbe71e7b0badc46dba1358625f86ede72 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 10 Jul 2026 15:48:48 +0400 Subject: [PATCH] feat: LLM router with chat intent and Cyrillic wake-word support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add LLMRouter: grammar-constrained LFM call for intent classification after stage-0, before classifier cascade. Errors fall through gracefully. - Add IntentChat: conversational intent with no store side-effect, routed through LLM -> phraser chat endpoint. - Extract slots for Chat: no structured slots, full utterance is payload. - Extend stage-0 grammars to fire through Cyrillic wake-word spellings (Мэйвен/Мейвен/Майвен/etc.) produced by Russian STT model. - StripWakeToken helper strips leading wake in any script so time/date grammars still match when wake is present. - Add classifier examples for chat utterances (EN + RU). - Wire LLMRouter into Router.Config; optional, nil-safe. --- internal/router/intent.go | 5 +- internal/router/llmrouter.go | 98 +++++++++++++++++++++++++++++++ internal/router/llmrouter_test.go | 74 +++++++++++++++++++++++ internal/router/router.go | 27 +++++++++ internal/router/router_test.go | 46 +++++++++++++++ internal/router/slots.go | 3 + internal/router/stage0.go | 29 ++++++++- internal/voice/replier.go | 2 + 8 files changed, 280 insertions(+), 4 deletions(-) create mode 100644 internal/router/llmrouter.go create mode 100644 internal/router/llmrouter_test.go diff --git a/internal/router/intent.go b/internal/router/intent.go index 9bea415..4623bf8 100644 --- a/internal/router/intent.go +++ b/internal/router/intent.go @@ -28,7 +28,7 @@ package router import "time" -// Intent — the five save-where labels from the spec's routing table. The +// Intent — the six save-where labels from the spec's routing table. The // discriminator is "does the loop evaluate a predicate against it?": // // - act: command now, not stored (function call into the allowlist) @@ -36,6 +36,8 @@ import "time" // - fact: structured state the loop reasons over → facts (sqlite) // - note: recall/preference, no predicate touches it → chroma // - query: answer, don't store → slm reads sqlite or chroma (RAG) +// - chat: conversational, no store side-effect — LLM replies from +// dialogue history + general knowledge // // fact-vs-note is the whole line: predicate will read it → structured facts // row; just "recall when relevant" → semantic store. reminder splits off by @@ -48,6 +50,7 @@ const ( IntentFact Intent = "fact" IntentNote Intent = "note" IntentQuery Intent = "query" + IntentChat Intent = "chat" IntentSystem Intent = "system" ) diff --git a/internal/router/llmrouter.go b/internal/router/llmrouter.go new file mode 100644 index 0000000..a4b90b8 --- /dev/null +++ b/internal/router/llmrouter.go @@ -0,0 +1,98 @@ +package router + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/kami/maven/internal/llm" +) + +// Completer — the LLM seam (mockable). *llm.Client satisfies it. +type Completer interface { + Complete(ctx context.Context, r llm.Req) (string, error) +} + +// LLMRouter — the agentic router. One grammar-constrained call classifies the +// utterance and pulls raw slots; deterministic parsers (time) refine downstream. +type LLMRouter struct{ c Completer } + +func NewLLMRouter(c Completer) *LLMRouter { return &LLMRouter{c: c} } + +// routeGrammar — GBNF constraining the model to a fixed-shape JSON object with +// an intent enum. Prevents free-form drift from a sub-1B model. +const routeGrammar = ` +root ::= "{" ws "\"intent\"" ws ":" ws intent ("," ws field)* ws "}" +intent ::= "\"fact\"" | "\"reminder\"" | "\"note\"" | "\"query\"" | "\"act\"" | "\"chat\"" | "\"system\"" +field ::= key ws ":" ws string +key ::= "\"key\"" | "\"value\"" | "\"text\"" | "\"verb\"" +string ::= "\"" ([^"\\] | "\\" .)* "\"" +ws ::= [ \t\n]* +` + +const routeSystem = `Ты — маршрутизатор Maven. По реплике верни ОДИН JSON-объект: {"intent": ...}. + +Интенты: +- fact — состояние/показатель, который надо запомнить и ОТСЛЕЖИВАТЬ во времени. "я выпил воду" → {"intent":"fact","key":"water","value":"выпил"}. +- reminder — просьба напомнить о чём-то В БУДУЩЕМ, есть время или срок ("напомни", "не забудь", "через час", "завтра", "в 9:00"). text = что напомнить. "напомни завтра в 9 позвонить маме" → {"intent":"reminder","text":"позвонить маме"}. +- note — заметка «на память» БЕЗ отслеживания и БЕЗ будущего времени ("запомни", "запиши", "заметь"). text = суть. "запомни что кофе закончился" → {"intent":"note","text":"кофе закончился"}. +- query — вопрос, требующий ответа. text = вопрос. +- act — команда выполнить действие на сервере. verb = глагол. +- chat — свободный разговор. text. +- system — вопрос о текущем времени/дате/дне недели. + +ВАЖНО: «запомни/запиши» = note (просто сохранить), «напомни/не забудь» = reminder (напомнить позже). Нет будущего времени и это не «напомни» → note, НЕ reminder. +Только JSON, без пояснений.` + +type routeAction struct { + Intent string `json:"intent"` + Key string `json:"key"` + Value string `json:"value"` + Text string `json:"text"` + Verb string `json:"verb"` +} + +func (lr *LLMRouter) Route(ctx context.Context, utterance string, now time.Time) (Decision, bool, error) { + raw, err := lr.c.Complete(ctx, llm.Req{System: routeSystem, User: utterance, Grammar: routeGrammar, MaxTokens: 128}) + if err != nil { + return Decision{}, false, err + } + var a routeAction + if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &a); err != nil { + return Decision{}, false, fmt.Errorf("llmrouter: parse %q: %w", raw, err) + } + d := Decision{Utterance: utterance, Stage: 1, Confidence: 1.0} + switch Intent(a.Intent) { + case IntentFact: + d.Intent = IntentFact + d.Slots.Key, d.Slots.Value = a.Key, a.Value + d.Slots.HasKey = a.Key != "" + case IntentReminder: + d.Intent = IntentReminder + d.Slots.Text = firstNonEmpty(a.Text, utterance) + case IntentNote: + d.Intent = IntentNote + d.Slots.Text = firstNonEmpty(a.Text, utterance) + case IntentQuery: + d.Intent = IntentQuery + d.Slots.Text = firstNonEmpty(a.Text, utterance) + case IntentAct: + d.Intent = IntentAct + d.Slots.Text = firstNonEmpty(a.Verb, utterance) + case IntentSystem: + d.Intent = IntentSystem + default: + d.Intent = IntentChat + d.Slots.Text = firstNonEmpty(a.Text, utterance) + } + return d, true, nil +} + +func firstNonEmpty(a, b string) string { + if strings.TrimSpace(a) != "" { + return a + } + return b +} diff --git a/internal/router/llmrouter_test.go b/internal/router/llmrouter_test.go new file mode 100644 index 0000000..9a736b7 --- /dev/null +++ b/internal/router/llmrouter_test.go @@ -0,0 +1,74 @@ +package router + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/kami/maven/internal/llm" +) + +type mockLLM struct{ out string; err error } + +func (m mockLLM) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err } + +func TestLLMRouterFactMapping(t *testing.T) { + lr := NewLLMRouter(mockLLM{out: `{"intent":"fact","key":"water","value":"выпил"}`}) + d, ok, err := lr.Route(context.Background(), "я выпил воду", time.Now()) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + if d.Intent != IntentFact || d.Slots.Key != "water" || !d.Slots.HasKey { + t.Fatalf("bad decision %+v", d) + } +} + +func TestLLMRouterNoteMapping(t *testing.T) { + lr := NewLLMRouter(mockLLM{out: `{"intent":"note","text":"кофе закончился"}`}) + d, ok, err := lr.Route(context.Background(), "запомни что кофе закончился", time.Now()) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + if d.Intent != IntentNote || d.Slots.Text != "кофе закончился" { + t.Fatalf("bad decision %+v", d) + } +} + +func TestLLMRouterBadJSONFallsBack(t *testing.T) { + lr := NewLLMRouter(mockLLM{out: `garbage`}) + _, ok, err := lr.Route(context.Background(), "x", time.Now()) + if ok || err == nil { + t.Fatal("want ok=false, err!=nil on bad json") + } +} + +func TestLLMRouterReminderMapping(t *testing.T) { + lr := NewLLMRouter(mockLLM{out: `{"intent":"reminder","text":"позвонить маме"}`}) + d, ok, err := lr.Route(context.Background(), "напомни позвонить маме", time.Now()) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + if d.Intent != IntentReminder || d.Slots.Text != "позвонить маме" { + t.Fatalf("bad decision %+v", d) + } +} + +func TestLLMRouterChatFallback(t *testing.T) { + lr := NewLLMRouter(mockLLM{out: `{"intent":"unknown"}`}) + d, ok, err := lr.Route(context.Background(), "как дела?", time.Now()) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + if d.Intent != IntentChat { + t.Fatalf("unknown intent should default to chat, got %s", d.Intent) + } +} + +func TestLLMRouterLLMError(t *testing.T) { + lr := NewLLMRouter(mockLLM{out: "", err: fmt.Errorf("llm down")}) + _, ok, err := lr.Route(context.Background(), "x", time.Now()) + if ok || err == nil { + t.Fatal("want ok=false, err!=nil on llm error") + } +} diff --git a/internal/router/router.go b/internal/router/router.go index 068044c..2fc5944 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -2,6 +2,7 @@ package router import ( "context" + "log" "time" ) @@ -21,6 +22,11 @@ type Config struct { // spec leaves this open (defines how often maven asks vs guesses on free- // form input; the whole reactive mvp feel rides on it). The daemon sets it. Threshold float64 + // LLM — optional agentic router. When set, Route consults it after stage-0 + // and before the classifier cascade, classifying the utterance via a + // grammar-constrained LFM call. On any error/parse failure, falls through + // to the classifier (never fails the turn on the model). + LLM *LLMRouter } // Router — the deterministic cascade. Route never guesses: stage 0 wins @@ -31,6 +37,7 @@ type Router struct { classifier *Classifier extractor Extractor threshold float64 + llm *LLMRouter } func New(cfg Config) *Router { @@ -39,6 +46,7 @@ func New(cfg Config) *Router { classifier: cfg.Classifier, extractor: cfg.Extractor, threshold: cfg.Threshold, + llm: cfg.LLM, } } @@ -53,8 +61,15 @@ func New(cfg Config) *Router { // worse than a gap). func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (Decision, error) { // stage 0 — exact match / grammar. First match wins; grammars are ordered. + // Grammars like time/date/reminder don't expect a wake-word prefix, but + // the STT often includes one (transcribed phonetically, any script) — try + // the wake-stripped utterance too so those grammars still fire. + stripped, hadWake := StripWakeToken(utterance) for _, g := range r.grammars { m := g.Pattern.FindStringSubmatch(utterance) + if m == nil && hadWake { + m = g.Pattern.FindStringSubmatch(stripped) + } if m == nil { continue } @@ -66,6 +81,18 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De return d, nil } + // stage 1a — LLM router (when wired). It reasons over the utterance instead + // of nearest-centroid guessing. On any error/parse-fail, fall through to the + // classifier cascade (never fail the turn on the model). + if r.llm != nil { + if d, ok, err := r.llm.Route(ctx, utterance, now); err == nil && ok { + d.Utterance = utterance + return d, nil + } else if err != nil { + log.Printf("router: llm route fell back to classifier: %v", err) + } + } + // stage 1 — intent classifier. results, err := r.classifier.Classify(ctx, utterance) if err != nil { diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 7222ba5..e8970fc 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -19,6 +19,7 @@ func seedClassifier(t *testing.T, c *Classifier) { facts := []string{"drank water", "i drank water", "ate lunch", "slept six hours", "had a meal"} notes := []string{"gpu driver fixed the flicker", "prefer backups at three am", "note that the router reboots on tuesday"} queries := []string{"is the backup up", "when did i last eat", "is nginx running", "how much water today"} + chats := []string{"what do you think about", "tell me something interesting", "how are you", "расскажи что-нибудь", "что ты думаешь", "как дела"} for _, x := range acts { if err := c.AddExample(ctx, IntentAct, x); err != nil { t.Fatalf("seed act %q: %v", x, err) @@ -44,6 +45,11 @@ func seedClassifier(t *testing.T, c *Classifier) { t.Fatalf("seed query %q: %v", x, err) } } + for _, x := range chats { + if err := c.AddExample(ctx, IntentChat, x); err != nil { + t.Fatalf("seed chat %q: %v", x, err) + } + } } func newTestRouter(t *testing.T, threshold float64) *Router { @@ -97,6 +103,24 @@ func TestStage0WakeWordFallsThroughOnUnknownAct(t *testing.T) { } } +func TestStage0GrammarFiresThroughCyrillicWakeWord(t *testing.T) { + // The STT is a Russian model — it transcribes the spoken wake word + // phonetically ("Мэйвен"), never as the Latin "maven". Grammars that + // don't expect a wake prefix (time/date) must still fire when one is + // there, otherwise these fall through to the classifier and get + // misrouted into IntentReminder (see stage0.go's wakeToken comment). + r := newTestRouter(t, 0.0) + r.grammars = append(r.grammars, SystemTimeDateGrammars()...) + + d, err := r.Route(context.Background(), "Мэйвен который час", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + if d.Stage != 0 || d.Intent != IntentSystem { + t.Fatalf("want stage0 system, got %+v", d) + } +} + // ----------------------------- stage 1 --------------------------------------- func TestStage1ClassifiesAct(t *testing.T) { @@ -300,6 +324,28 @@ func TestClassifierDeterministicOrdering(t *testing.T) { } } +func TestStage1ClassifiesChat(t *testing.T) { + r := newTestRouter(t, 0.0) + cases := []struct { + in string + want Intent + }{ + {"what do you think about ai", IntentChat}, + {"tell me something interesting", IntentChat}, + {"как дела", IntentChat}, + {"расскажи что-нибудь", IntentChat}, + } + for _, c := range cases { + d, err := r.Route(context.Background(), c.in, refNow()) + if err != nil { + t.Fatalf("route %q: %v", c.in, err) + } + if d.Intent != c.want { + t.Errorf("%q: want %s, got %s (conf %f)", c.in, c.want, d.Intent, d.Confidence) + } + } +} + func TestClassifierIntentsSorted(t *testing.T) { emb := NewHashEmbedder(32) c := NewClassifier(emb) diff --git a/internal/router/slots.go b/internal/router/slots.go index c8760eb..ea579f5 100644 --- a/internal/router/slots.go +++ b/internal/router/slots.go @@ -74,6 +74,9 @@ func (e Extractor) Extract(ctx context.Context, intent Intent, utterance string, 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 } diff --git a/internal/router/stage0.go b/internal/router/stage0.go index b6113b0..5c120ba 100644 --- a/internal/router/stage0.go +++ b/internal/router/stage0.go @@ -23,7 +23,30 @@ type Grammar struct { // is matched against the act allowlist. A non-match returns ok=false so the // cascade falls through to the classifier (a wakeword prefix alone doesn't // guarantee a known command — "maven, i'm tired" is a fact, not an act). -var wakeWordAct = regexp.MustCompile(`(?i)^\s*maven[,: ]+(.+)$`) +var wakeWordAct = regexp.MustCompile(`(?i)^\s*(?:maven|мэйвен|мейвен|майвен|мавена?|мэвен)[,:.!\s]+(.+)$`) + +// wakeToken matches a leading wake-word token in any script the STT commonly +// produces for "Maven" — Latin "maven" or a Cyrillic phonetic rendering. The +// STT is a Russian model, so it transcribes the spoken wake word phonetically +// almost every time; matching only the Latin spelling meant stage-0 grammars +// (time/date/reminder) silently missed nearly every wake-worded utterance and +// fell through to the classifier, which misroutes time queries into the +// reminder intent (dense time-vocab centroid, see SystemTimeDateGrammars). +var wakeToken = regexp.MustCompile(`(?i)^\s*(?:maven|мэйвен|мейвен|майвен|мавена?|мэвен)[,:.!\s]*`) + +// StripWakeToken removes a leading wake-word token (any script/spelling seen +// in wakeToken) and reports whether one was found. +func StripWakeToken(u string) (string, bool) { + loc := wakeToken.FindStringIndex(u) + if loc == nil { + return u, false + } + rest := strings.TrimSpace(u[loc[1]:]) + if rest == "" { + return u, false + } + return rest, true +} // DefaultGrammars — the wake-word act fast path. The ActMatcher is the same // allowlist stage-2 act extraction uses (single source of truth for the fn @@ -106,12 +129,12 @@ func SystemTimeDateGrammars() []Grammar { }, { Name: "clock-query", - Pattern: regexp.MustCompile(`(?i)^\s*который\s+(сейчас\s+)?час(\s+у\s+нас|\s+в\s+\w+)?\s*\??\s*$`), + Pattern: regexp.MustCompile(`(?i)^\s*который\s+(сейчас\s+)?час(\s+у\s+нас|\s+в\s+\w+)?\s*[?!.]?\s*$`), Build: timeDateBuild, }, { Name: "date-query", - Pattern: regexp.MustCompile(`(?i)^\s*(?:какой\s+сегодня\s+(?:день|день\s+недели|число)|какое\s+сегодня\s+число)\s*\??\s*$`), + Pattern: regexp.MustCompile(`(?i)^\s*(?:какой\s+сегодня\s+(?:день|день\s+недели|число)|какое\s+сегодня\s+число)\s*[?!.]?\s*$`), Build: timeDateBuild, }, } diff --git a/internal/voice/replier.go b/internal/voice/replier.go index 5d9e2b3..1f6e71f 100644 --- a/internal/voice/replier.go +++ b/internal/voice/replier.go @@ -78,6 +78,8 @@ func (s *StubReplier) Reply(d router.Decision) string { return "сохранила заметку." case router.IntentQuery: return "поискала в заметках — ничего не нашла." + case router.IntentChat: + return "поговорили." // stub — LLMReplier replaces this default: return "приняла." }