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:
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 "приняла."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user