feat: LLM router with chat intent and Cyrillic wake-word support

- 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.
This commit is contained in:
kami
2026-07-10 15:48:48 +04:00
parent 6bab68e96d
commit 28a940ebbe
8 changed files with 280 additions and 4 deletions
+4 -1
View File
@@ -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"
)
+98
View File
@@ -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
}
+74
View File
@@ -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")
}
}
+27
View File
@@ -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 {
+46
View File
@@ -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)
+3
View File
@@ -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
}
+26 -3
View File
@@ -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,
},
}
+2
View File
@@ -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 "приняла."
}