diff --git a/internal/router/llmrouter.go b/internal/router/llmrouter.go index 3a3f4e2..5fdef26 100644 --- a/internal/router/llmrouter.go +++ b/internal/router/llmrouter.go @@ -23,17 +23,27 @@ 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. +// 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 ::= "\"" ([^"\\] | "\\" .)* "\"" +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. @@ -41,22 +51,26 @@ const routeSystem = `Классифицируй ровно одно сообще Классифицируй по цели пользователя. Порядок решения: 1. Хочет напоминание в будущем → reminder 2. Явно просит сохранить информацию → note -3. Сообщает или обновляет текущее состояние/событие → fact -4. Хочет получить информацию → query -5. Просит выполнить работу → act -6. Про ассистента, настройки или память → system -7. Иначе → chat +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":"написать письмо"} @@ -65,6 +79,11 @@ const routeSystem = `Классифицируй ровно одно сообще Ответ — 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"` @@ -74,7 +93,13 @@ type routeAction struct { } 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}) + 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 } diff --git a/internal/router/llmrouter_test.go b/internal/router/llmrouter_test.go index 9a736b7..7c73852 100644 --- a/internal/router/llmrouter_test.go +++ b/internal/router/llmrouter_test.go @@ -3,15 +3,61 @@ package router import ( "context" "fmt" + "strings" "testing" "time" "github.com/kami/maven/internal/llm" ) -type mockLLM struct{ out string; err error } +type mockLLM struct { + out string + err error + got *llm.Req // last request, when the test wants to inspect it +} -func (m mockLLM) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err } +func (m mockLLM) Complete(_ context.Context, r llm.Req) (string, error) { + if m.got != nil { + *m.got = r + } + return m.out, m.err +} + +// Without a repeat penalty the model loops inside the text field until MaxTokens +// and the truncated JSON fails to parse. +func TestLLMRouterSetsRepeatPenalty(t *testing.T) { + var got llm.Req + lr := NewLLMRouter(mockLLM{out: `{"intent":"chat","text":"привет"}`, got: &got}) + if _, _, err := lr.Route(context.Background(), "привет", time.Now()); err != nil { + t.Fatalf("route: %v", err) + } + if got.RepeatPenalty <= 1 { + t.Fatalf("want repeat penalty above 1, got %v", got.RepeatPenalty) + } +} + +// An unbounded string rule lets one field eat the whole token budget. +func TestRouteGrammarBoundsStrings(t *testing.T) { + if !strings.Contains(routeGrammar, `string ::= "\"" ([^"\\] | "\\" .){0,120} "\""`) { + t.Fatal("grammar string rule lost its length bound") + } +} + +// A question naming a fact key used to be stored as a fact because the fact rule +// was tested first. Keep the query rule above it. +func TestRoutePromptTestsQueryBeforeFact(t *testing.T) { + query := strings.Index(routeSystem, "→ query") + fact := strings.Index(routeSystem, "состояние/событие → fact") + if query < 0 || fact < 0 { + t.Fatalf("prompt lost a rule: query=%d fact=%d", query, fact) + } + if query > fact { + t.Fatal("query rule must come before the fact rule") + } + if !strings.Contains(routeSystem, "Задаёт вопрос") { + t.Fatal("prompt lost the explicit question test") + } +} func TestLLMRouterFactMapping(t *testing.T) { lr := NewLLMRouter(mockLLM{out: `{"intent":"fact","key":"water","value":"выпил"}`})