diff --git a/internal/router/llmrouter.go b/internal/router/llmrouter.go index 1889efc..d609b3c 100644 --- a/internal/router/llmrouter.go +++ b/internal/router/llmrouter.go @@ -45,12 +45,19 @@ 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\"" | "\"unknown\"" -field ::= key ws ":" ws string +field ::= (key ws ":" ws string) | ("\"source\"" ws ":" ws source) key ::= "\"key\"" | "\"value\"" | "\"text\"" | "\"verb\"" +source ::= "\"recall\"" | "\"calendar\"" | "\"tasks\"" | "\"list\"" | "\"money\"" | "\"weather\"" | "\"home\"" | "\"network\"" | "\"feeds\"" | "\"attention\"" | "\"self\"" | "\"world\"" | "\"\"" string ::= "\"" ([^"\\\x00-\x1F] | "\\" ["\\/bfnrt] | "\\u" [0-9a-fA-F]{4}){0,120} "\"" ws ::= [ \t\n]{0,4} ` +// TestRouteGrammarCoversSources holds the source rule above to router.Sources. +// The enum is the point: a grammar cannot emit a destination that does not +// exist, which is the guarantee V-546 wants from a softmax and gets here for +// free. Empty is the thirteenth alternative and it is not an oversight — it is +// the SourceUnknown floor, and the model must be able to decline. + // 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 @@ -121,6 +128,31 @@ const routeSystem = `Классифицируй ровно одно сообще "что такое кватернион?" → {"intent":"query","text":"что такое кватернион"} "ага" → {"intent":"chat","text":"ага"} +Только для query добавь поле source — где лежит ответ: +- recall — его заметки, факты и то, что он раньше говорил +- calendar — встречи и события +- tasks — список задач +- list — списки покупок и другие именованные списки +- money — траты +- weather — погода +- home — свет, устройства, дом +- network — локальная сеть, сервер, диски +- feeds — новостные ленты +- attention — что требует внимания сейчас +- self — вопрос про самого ассистента +- world — всё остальное: определения, счёт, люди, факты о мире + +Пустое значение "" — нормальный ответ и его надо ставить часто. Ставь "", если ответ могут дать сразу два источника или если не уверен: тогда проверяются все по порядку, и это правильно. Никогда не угадывай. + +"сколько воды я выпил с утра" → {"intent":"query","text":"сколько воды я выпил с утра","source":"recall"} +"что я записывал про кота" → {"intent":"query","text":"что я записывал про кота","source":"recall"} +"во сколько у меня встреча" → {"intent":"query","text":"во сколько у меня встреча","source":"calendar"} +"что такое docker?" → {"intent":"query","text":"что такое docker","source":"world"} +"кто такой Линус Торвальдс?" → {"intent":"query","text":"кто такой Линус Торвальдс","source":"world"} +"сколько будет 17 на 23?" → {"intent":"query","text":"сколько будет 17 на 23","source":"world"} +"почему сервер тормозит" → {"intent":"query","text":"почему сервер тормозит","source":""} +"есть новости по бэкапу базы" → {"intent":"query","text":"есть новости по бэкапу базы","source":""} + Ответ — JSON-массив: по одному объекту на каждую просьбу. Обычно один. Если в реплике несколько просьб — по объекту на каждую. "напомни купить молоко, и запиши что кофе кончился" → [{"intent":"reminder","text":"купить молоко"},{"intent":"note","text":"кофе кончился"}]. Только JSON, без пояснений.` // routeRepeatPenalty — the sub-1B model loops one sentence inside the text field @@ -172,6 +204,7 @@ type routeAction struct { Value string `json:"value"` Text string `json:"text"` Verb string `json:"verb"` + Source string `json:"source"` } // Route asks the model for one decision. The bool is false when there is no @@ -240,6 +273,14 @@ func (lr *LLMRouter) Route(ctx context.Context, utterance string, now time.Time) case IntentQuery: d.Intent = IntentQuery d.Slots.Text = firstNonEmpty(a.Text, utterance) + // Through ValidSource, and on query alone. The grammar already bounds + // the enum, but the grammar is a request to a server that may be + // running a different build, and a destination this binary does not + // know would take real query sources off the turn. Anything unknown + // drops to SourceUnknown, which is the floor and costs nothing. + if ValidSource(Source(a.Source)) { + d.Source = Source(a.Source) + } case IntentAct: d.Intent = IntentAct d.Slots.Text = firstNonEmpty(a.Verb, utterance) diff --git a/internal/router/llmrouter_test.go b/internal/router/llmrouter_test.go index 492061f..bd58d27 100644 --- a/internal/router/llmrouter_test.go +++ b/internal/router/llmrouter_test.go @@ -398,3 +398,70 @@ func TestLLMReminderWithSubjectIsNotGated(t *testing.T) { t.Fatalf("a complete reminder was sent back as a question: %+v", d.Slots) } } + +// TestRouteGrammarCoversSources — the grammar enum and router.Sources are two +// hand-written lists of the same twelve destinations, and nothing else notices +// when one grows. A destination missing from the grammar is a destination the +// model is structurally unable to name, which is the exact defect V-517 +// measured for Praxis: not a weak model, an absent string. +func TestRouteGrammarCoversSources(t *testing.T) { + for _, s := range Sources { + if !strings.Contains(routeGrammar, `"\"`+string(s)+`\""`) { + t.Errorf("routeGrammar cannot emit %q — the model can never name it", s) + } + } + // The floor has to be reachable too, or the model is forced to pick one. + if !strings.Contains(routeGrammar, `"\"\""`) { + t.Error(`routeGrammar cannot emit "" — the model cannot decline a destination`) + } + // Count the alternatives on the source rule: an extra one is a destination + // the daemon would drop to SourceUnknown after the model spent tokens on it. + for _, line := range strings.Split(routeGrammar, "\n") { + if !strings.HasPrefix(line, "source ") { + continue + } + if got, want := strings.Count(line, "|")+1, len(Sources)+1; got != want { + t.Errorf("source rule has %d alternatives, want %d (Sources plus the floor)", got, want) + } + } +} + +// The destination is read back only through ValidSource. A model on an older or +// newer build can write a string this binary does not know, and trusting it +// would take real query sources off the turn for a name nothing answers. +func TestLLMUnknownSourceFallsToTheFloor(t *testing.T) { + r := newLLMTestRouter(t, `{"intent":"query","text":"что там с бэкапами","source":"praxis"}`) + d, err := r.Route(context.Background(), "что там с бэкапами", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + if d.Source != SourceUnknown { + t.Fatalf("invented destination %q was trusted, want the floor", d.Source) + } +} + +// And a known one survives, or the read-back is just a filter. +func TestLLMNamedSourceSurvives(t *testing.T) { + r := newLLMTestRouter(t, `{"intent":"query","text":"кто такой Линус Торвальдс","source":"world"}`) + d, err := r.Route(context.Background(), "кто такой Линус Торвальдс?", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + if d.Source != SourceWorld { + t.Fatalf("source %q, want %q", d.Source, SourceWorld) + } +} + +// A destination on anything but a query is dropped. Only IntentQuery reaches +// queryWalk, so a source elsewhere is a field nobody reads and a claim nobody +// checks. +func TestLLMSourceIsQueryOnly(t *testing.T) { + r := newLLMTestRouter(t, `{"intent":"note","text":"кофе кончился","source":"recall"}`) + d, err := r.Route(context.Background(), "запиши что кофе кончился", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + if d.Source != SourceUnknown { + t.Fatalf("a note carried destination %q", d.Source) + } +}