d30618ecb7
Route now sets RepeatPenalty on the request, and the grammar's string rule is capped at 120 characters. Two of 76 fixture cases looped one sentence inside the text field until MaxTokens, which cut the JSON in half. Reviewers: the new constant and the grammar string rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
165 lines
7.7 KiB
Go
165 lines
7.7 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 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. The string rule is length-bounded
|
|
// so a repetition loop cannot fill the whole token budget with one field and
|
|
// truncate the JSON.
|
|
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 ::= "\"" ([^"\\] | "\\" .){0,120} "\""
|
|
ws ::= [ \t\n]*
|
|
`
|
|
|
|
// routeSystem — the router prompt. Changed 31-07-2026: the query test now sits
|
|
// above the fact test and there is an explicit question test. Before that, a
|
|
// question naming a fact key ("сколько воды я выпил с утра") matched the fact
|
|
// rule first and was stored as an assertion — 15 of 76 fixture cases.
|
|
//
|
|
// The training workspace keeps its own copy of this prompt for relabelling, and
|
|
// `llm/check_prompt_parity.py` there compares the two. That copy is in another
|
|
// repo and was not touched, so parity will fail until it gets the same edit.
|
|
const routeSystem = `Классифицируй ровно одно сообщение пользователя. Верни ОДИН JSON-массив действий.
|
|
|
|
Ровно одно намерение: fact, reminder, note, query, act, chat, system.
|
|
|
|
Классифицируй по цели пользователя. Порядок решения:
|
|
1. Хочет напоминание в будущем → reminder
|
|
2. Явно просит сохранить информацию → note
|
|
3. Задаёт вопрос: есть вопросительное слово (сколько, что, какой, когда, где, кто, почему, как) или знак «?» → query
|
|
4. Хочет получить информацию, в том числе о своих же данных → query
|
|
5. Утверждает: сообщает или обновляет текущее состояние/событие → fact
|
|
6. Просит выполнить работу → act
|
|
7. Про ассистента, настройки или память → system
|
|
8. Иначе → chat
|
|
|
|
Различия:
|
|
- note — сохранить информацию, без напоминания. text = суть.
|
|
- reminder — уведомить позже. text = что напомнить.
|
|
- fact — неявное обновление: пользователь сообщает, что что-то в мире изменилось (текущее/изменённое состояние, случившееся событие). key/value.
|
|
- query против fact — решает форма реплики, а не тема. Вопрос о состоянии — это query, даже если названо то же самое, что бывает в fact. Только утверждение — это fact.
|
|
|
|
Примеры:
|
|
"запиши пароль" → {"intent":"note","text":"пароль"}
|
|
"напомни купить молоко" → {"intent":"reminder","text":"купить молоко"}
|
|
"запиши купить молоко" → {"intent":"note","text":"купить молоко"}
|
|
"я выпил воду" → {"intent":"fact","key":"water","value":"выпил"}
|
|
"сколько воды я выпил с утра" → {"intent":"query","text":"сколько воды я выпил с утра"}
|
|
"сколько раз я ел вчера?" → {"intent":"query","text":"сколько раз я ел вчера"}
|
|
"мой любимый фильм — Интерстеллар" → {"intent":"note","text":"любимый фильм — Интерстеллар"}
|
|
"что такое docker?" → {"intent":"query","text":"что такое docker"}
|
|
"напиши письмо" → {"intent":"act","verb":"написать письмо"}
|
|
"очисти память" → {"intent":"system"}
|
|
"привет" → {"intent":"chat","text":"привет"}
|
|
|
|
Ответ — JSON-массив: по одному объекту на каждую просьбу. Обычно один. Если в реплике несколько просьб — по объекту на каждую. "напомни купить молоко, и запиши что кофе кончился" → [{"intent":"reminder","text":"купить молоко"},{"intent":"note","text":"кофе кончился"}]. Только JSON, без пояснений.`
|
|
|
|
// routeRepeatPenalty — the sub-1B model loops one sentence inside the text field
|
|
// until it runs out of tokens, which truncates the JSON. 1.15 is enough to break
|
|
// the loop without hurting short slot values.
|
|
const routeRepeatPenalty = 1.15
|
|
|
|
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,
|
|
RepeatPenalty: routeRepeatPenalty,
|
|
})
|
|
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
|
|
}
|