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 JSON ARRAY of fixed-shape // action objects (one per ask; compound utterances → multiple). Enum + key set // prevent free-form drift from a sub-1B model. const routeGrammar = ` root ::= "[" ws action ("," ws action)* ws "]" action ::= "{" 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 = `Классифицируй ровно одно сообщение пользователя. Верни ОДИН JSON-массив действий. Ровно одно намерение: fact, reminder, note, query, act, chat, system. Классифицируй по цели пользователя. Порядок решения: 1. Хочет напоминание в будущем → reminder 2. Явно просит сохранить информацию → note 3. Сообщает или обновляет текущее состояние/событие → fact 4. Хочет получить информацию → query 5. Просит выполнить работу → act 6. Про ассистента, настройки или память → system 7. Иначе → chat Различия: - note — сохранить информацию, без напоминания. text = суть. - reminder — уведомить позже. text = что напомнить. - fact — неявное обновление: пользователь сообщает, что что-то в мире изменилось (текущее/изменённое состояние, случившееся событие). key/value. Примеры: "запиши пароль" → {"intent":"note","text":"пароль"} "напомни купить молоко" → {"intent":"reminder","text":"купить молоко"} "запиши купить молоко" → {"intent":"note","text":"купить молоко"} "я выпил воду" → {"intent":"fact","key":"water","value":"выпил"} "мой любимый фильм — Интерстеллар" → {"intent":"note","text":"любимый фильм — Интерстеллар"} "что такое docker?" → {"intent":"query","text":"что такое docker"} "напиши письмо" → {"intent":"act","verb":"написать письмо"} "очисти память" → {"intent":"system"} "привет" → {"intent":"chat","text":"привет"} Ответ — JSON-массив: по одному объекту на каждую просьбу. Обычно один. Если в реплике несколько просьб — по объекту на каждую. "напомни купить молоко, и запиши что кофе кончился" → [{"intent":"reminder","text":"купить молоко"},{"intent":"note","text":"кофе кончился"}]. Только 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 } acts, err := parseActions(strings.TrimSpace(raw)) if err != nil { return Decision{}, false, fmt.Errorf("llmrouter: parse %q: %w", raw, err) } if len(acts) == 0 { return Decision{}, false, fmt.Errorf("llmrouter: empty action list %q", raw) } // ponytail: contract is an array (compound utterances → N actions), but the // Router cascade still returns one Decision. Dispatch of all actions lands // with the engine turn-on (Router.Route → []Decision, both voice.go handlers // loop). Until then only the first ask is honored. a := acts[0] 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 } // parseActions accepts the array contract (`[{...},...]`) or a bare object // (`{...}`) for robustness against a model that drops the wrapper. func parseActions(raw string) ([]routeAction, error) { if strings.HasPrefix(raw, "[") { var as []routeAction return as, json.Unmarshal([]byte(raw), &as) } var a routeAction if err := json.Unmarshal([]byte(raw), &a); err != nil { return nil, err } return []routeAction{a}, nil } func firstNonEmpty(a, b string) string { if strings.TrimSpace(a) != "" { return a } return b }