Fix the router prompt: questions were filed as facts (48.7% to 60.5%) #10
@@ -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
|
// routeGrammar — GBNF constraining the model to a JSON ARRAY of fixed-shape
|
||||||
// action objects (one per ask; compound utterances → multiple). Enum + key set
|
// 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 = `
|
const routeGrammar = `
|
||||||
root ::= "[" ws action ("," ws action)* ws "]"
|
root ::= "[" ws action ("," ws action)* ws "]"
|
||||||
action ::= "{" ws "\"intent\"" ws ":" ws intent ("," ws field)* ws "}"
|
action ::= "{" ws "\"intent\"" ws ":" ws intent ("," ws field)* ws "}"
|
||||||
intent ::= "\"fact\"" | "\"reminder\"" | "\"note\"" | "\"query\"" | "\"act\"" | "\"chat\"" | "\"system\""
|
intent ::= "\"fact\"" | "\"reminder\"" | "\"note\"" | "\"query\"" | "\"act\"" | "\"chat\"" | "\"system\""
|
||||||
field ::= key ws ":" ws string
|
field ::= key ws ":" ws string
|
||||||
key ::= "\"key\"" | "\"value\"" | "\"text\"" | "\"verb\""
|
key ::= "\"key\"" | "\"value\"" | "\"text\"" | "\"verb\""
|
||||||
string ::= "\"" ([^"\\] | "\\" .)* "\""
|
string ::= "\"" ([^"\\] | "\\" .){0,120} "\""
|
||||||
ws ::= [ \t\n]*
|
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-массив действий.
|
const routeSystem = `Классифицируй ровно одно сообщение пользователя. Верни ОДИН JSON-массив действий.
|
||||||
|
|
||||||
Ровно одно намерение: fact, reminder, note, query, act, chat, system.
|
Ровно одно намерение: fact, reminder, note, query, act, chat, system.
|
||||||
@@ -41,22 +51,26 @@ const routeSystem = `Классифицируй ровно одно сообще
|
|||||||
Классифицируй по цели пользователя. Порядок решения:
|
Классифицируй по цели пользователя. Порядок решения:
|
||||||
1. Хочет напоминание в будущем → reminder
|
1. Хочет напоминание в будущем → reminder
|
||||||
2. Явно просит сохранить информацию → note
|
2. Явно просит сохранить информацию → note
|
||||||
3. Сообщает или обновляет текущее состояние/событие → fact
|
3. Задаёт вопрос: есть вопросительное слово (сколько, что, какой, когда, где, кто, почему, как) или знак «?» → query
|
||||||
4. Хочет получить информацию → query
|
4. Хочет получить информацию, в том числе о своих же данных → query
|
||||||
5. Просит выполнить работу → act
|
5. Утверждает: сообщает или обновляет текущее состояние/событие → fact
|
||||||
6. Про ассистента, настройки или память → system
|
6. Просит выполнить работу → act
|
||||||
7. Иначе → chat
|
7. Про ассистента, настройки или память → system
|
||||||
|
8. Иначе → chat
|
||||||
|
|
||||||
Различия:
|
Различия:
|
||||||
- note — сохранить информацию, без напоминания. text = суть.
|
- note — сохранить информацию, без напоминания. text = суть.
|
||||||
- reminder — уведомить позже. text = что напомнить.
|
- reminder — уведомить позже. text = что напомнить.
|
||||||
- fact — неявное обновление: пользователь сообщает, что что-то в мире изменилось (текущее/изменённое состояние, случившееся событие). key/value.
|
- fact — неявное обновление: пользователь сообщает, что что-то в мире изменилось (текущее/изменённое состояние, случившееся событие). key/value.
|
||||||
|
- query против fact — решает форма реплики, а не тема. Вопрос о состоянии — это query, даже если названо то же самое, что бывает в fact. Только утверждение — это fact.
|
||||||
|
|
||||||
Примеры:
|
Примеры:
|
||||||
"запиши пароль" → {"intent":"note","text":"пароль"}
|
"запиши пароль" → {"intent":"note","text":"пароль"}
|
||||||
"напомни купить молоко" → {"intent":"reminder","text":"купить молоко"}
|
"напомни купить молоко" → {"intent":"reminder","text":"купить молоко"}
|
||||||
"запиши купить молоко" → {"intent":"note","text":"купить молоко"}
|
"запиши купить молоко" → {"intent":"note","text":"купить молоко"}
|
||||||
"я выпил воду" → {"intent":"fact","key":"water","value":"выпил"}
|
"я выпил воду" → {"intent":"fact","key":"water","value":"выпил"}
|
||||||
|
"сколько воды я выпил с утра" → {"intent":"query","text":"сколько воды я выпил с утра"}
|
||||||
|
"сколько раз я ел вчера?" → {"intent":"query","text":"сколько раз я ел вчера"}
|
||||||
"мой любимый фильм — Интерстеллар" → {"intent":"note","text":"любимый фильм — Интерстеллар"}
|
"мой любимый фильм — Интерстеллар" → {"intent":"note","text":"любимый фильм — Интерстеллар"}
|
||||||
"что такое docker?" → {"intent":"query","text":"что такое docker"}
|
"что такое docker?" → {"intent":"query","text":"что такое docker"}
|
||||||
"напиши письмо" → {"intent":"act","verb":"написать письмо"}
|
"напиши письмо" → {"intent":"act","verb":"написать письмо"}
|
||||||
@@ -65,6 +79,11 @@ const routeSystem = `Классифицируй ровно одно сообще
|
|||||||
|
|
||||||
Ответ — JSON-массив: по одному объекту на каждую просьбу. Обычно один. Если в реплике несколько просьб — по объекту на каждую. "напомни купить молоко, и запиши что кофе кончился" → [{"intent":"reminder","text":"купить молоко"},{"intent":"note","text":"кофе кончился"}]. Только JSON, без пояснений.`
|
Ответ — 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 {
|
type routeAction struct {
|
||||||
Intent string `json:"intent"`
|
Intent string `json:"intent"`
|
||||||
Key string `json:"key"`
|
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) {
|
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 {
|
if err != nil {
|
||||||
return Decision{}, false, err
|
return Decision{}, false, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,61 @@ package router
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/llm"
|
"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) {
|
func TestLLMRouterFactMapping(t *testing.T) {
|
||||||
lr := NewLLMRouter(mockLLM{out: `{"intent":"fact","key":"water","value":"выпил"}`})
|
lr := NewLLMRouter(mockLLM{out: `{"intent":"fact","key":"water","value":"выпил"}`})
|
||||||
|
|||||||
Reference in New Issue
Block a user