From 17b47ce20640f3ef2ea704e8853534cc8b82e567 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 02:10:37 +0400 Subject: [PATCH 1/2] Route questions to query, not fact The router prompt tested "reports current state -> fact" before "wants information -> query", so a question naming a fact key was written as a fact. Query now comes first, plus an explicit question test. Reviewers: the prompt block in llmrouter.go, and the note about the training-side copy of the prompt that needs the same edit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- internal/router/llmrouter.go | 22 +++++++++++++++++----- internal/router/llmrouter_test.go | 22 +++++++++++++++++++++- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/internal/router/llmrouter.go b/internal/router/llmrouter.go index 3a3f4e2..40aeb18 100644 --- a/internal/router/llmrouter.go +++ b/internal/router/llmrouter.go @@ -34,6 +34,14 @@ string ::= "\"" ([^"\\] | "\\" .)* "\"" 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 +49,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":"написать письмо"} diff --git a/internal/router/llmrouter_test.go b/internal/router/llmrouter_test.go index 9a736b7..de8cde5 100644 --- a/internal/router/llmrouter_test.go +++ b/internal/router/llmrouter_test.go @@ -3,16 +3,36 @@ 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 +} func (m mockLLM) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err } +// 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":"выпил"}`}) d, ok, err := lr.Route(context.Background(), "я выпил воду", time.Now()) From d30618ecb7955c000467ff9c5b1a05e4bdfc224f Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 02:11:38 +0400 Subject: [PATCH 2/2] Stop the router repetition loop 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 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- internal/router/llmrouter.go | 19 ++++++++++++++++--- internal/router/llmrouter_test.go | 28 +++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/internal/router/llmrouter.go b/internal/router/llmrouter.go index 40aeb18..5fdef26 100644 --- a/internal/router/llmrouter.go +++ b/internal/router/llmrouter.go @@ -23,14 +23,16 @@ 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]* ` @@ -77,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"` @@ -86,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 de8cde5..7c73852 100644 --- a/internal/router/llmrouter_test.go +++ b/internal/router/llmrouter_test.go @@ -13,9 +13,35 @@ import ( 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.