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 }