28a940ebbe
- 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.
99 lines
4.1 KiB
Go
99 lines
4.1 KiB
Go
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
|
|
}
|