Files
Maven/internal/router/llmrouter.go
T
claude 3513e508b7 Give the router prompt a destination to write (V-660)
V-659 measured the destination at 12/33 on the classifier cascade and named
the gap: recall 0/15, because nothing anywhere names it. The model could not
help, for a structural reason rather than a capability one. Nothing in
routeSystem mentioned a Source and routeGrammar could not emit one, so there
was no string for it to write. Same shape as the Praxis reach V-517
measured at 0/12.

routeGrammar grows a source rule, closed over router.Sources plus the empty
floor. 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. The prompt
lists the twelve in Russian, one line each, and says plainly that "" is a
normal answer to give often: two sources that can both answer means the
chain walks, and guessing is the failure mode this whole field exists to
stop.

The read-back goes through ValidSource and runs on IntentQuery alone. The
grammar already bounds the enum, but it is a request to a server that may be
running another build, and only a query reaches queryWalk.

Measured against gemma-4-12b on the workstation, same fixture, cascade with
a hash fallback: destination 24/33 (72.7%) against the classifier's 12/33,
and intent 81/96 (84.4%) which is where it already was. Recall is the whole
move, 0/15 to 14/15. The model alone scores 26/33.

Four cases the cascade loses and llm-only wins are calendar. The possessive
agenda rules claim them at stage 0 and deliberately name nothing, because
"что у меня в списке покупок" matches the same rule and naming the calendar
would take the list source off the turn. So stage 0's caution now costs four
destination points it did not cost before. That is a real trade and it wants
its own argument, not a quiet edit here.

The resident Qwen3-1.7B is unmeasured: it binds --port 0 inside the
container and no host process can reach it.

llm/check_prompt_parity.py in the training workspace compares its copy of
routeSystem to this one and will fail until that copy gets the same edit.
V-362 covers the catch-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 18:22:00 +04:00

316 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
//
// EVERY repetition in this grammar is bounded, and ws is the one that matters
// most. An unbounded `ws ::= [ \t\n]*` is a licence to emit whitespace until
// max_tokens: the model opens the JSON, satisfies ws forever, and the only
// thing that stops it is the cap. That cost 24-30s a turn on the phrasing side
// (Vikunja #531), where nothing sent a repeat penalty. This path sends
// routeRepeatPenalty, which masked it here — the bound is what actually
// prevents it, so it does not depend on a sampler setting staying put.
//
// The string class excludes the control range and the escape alternatives are
// exact, both for the reason the phrasing grammar gives (Vikunja #537): a raw
// newline inside a JSON string does not parse, and `"\\" .` licensed `\q`,
// which does not parse either. A route that does not parse falls through to the
// classifier, so here the defect reads as lost accuracy rather than as an empty
// reply. Same class as internal/phraser's responseGrammar, on purpose.
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) | ("\"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
// rule first and was stored as an assertion — 15 of 76 fixture cases.
//
// Changed again 31-07-2026: added the "unknown" escape hatch so the model can
// admit it cannot route (Vikunja #359).
//
// Changed again 31-07-2026: added the clock/calendar rule (Vikunja #374). The
// prompt never said which side "который час" or "какое число завтра" belong on,
// so the model guessed — `system→query ×4` in every eval run. The rule sits
// above the question test on purpose: these utterances all carry a question
// word, so a later rule would never be reached. The boundary is what the
// daemon can actually answer: only replySystem in cmd/mavend/voice.go owns the
// clock and the calendar formatter, while the agenda ("что у меня завтра") is
// answered inside the query branch, so that side stays query.
//
// 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 edits —
// both the rule reorder and the "unknown" wording (Vikunja #362) — and now the
// clock/calendar rule too. The training workspace is not checked out on this
// box at all, so it could not be updated here; #362 still covers the catch-up.
const routeSystem = `Классифицируй ровно одно сообщение пользователя. Верни ОДИН JSON-массив действий.
Ровно одно намерение: fact, reminder, note, query, act, chat, system.
Есть восьмое значение unknown — только для случаев, когда просьбу невозможно понять.
Классифицируй по цели пользователя. Порядок решения:
1. Хочет напоминание в будущем → reminder
2. Явно просит сохранить информацию → note
3. Спрашивает только «который час» / «какое число» / «какой день недели» — сами часы или календарная дата, без своих данных → system
4. Задаёт вопрос: есть вопросительное слово (сколько, что, какой, когда, где, кто, почему, как) или знак «?» → query
5. Хочет получить информацию, в том числе о своих же данных → query
6. Утверждает: сообщает или обновляет текущее состояние/событие → fact
7. Просит выполнить работу → act
8. Про ассистента, настройки или память → system
9. Реплика — обрывок или указание на неназванное («это», «то», «потом»), и без него непонятно, что именно нужно сделать → unknown
10. Иначе → chat
Различия:
- note — сохранить информацию, без напоминания. text = суть.
- reminder — уведомить позже. text = что напомнить.
- fact — неявное обновление: пользователь сообщает, что что-то в мире изменилось (текущее/изменённое состояние, случившееся событие). key/value.
- unknown — редкий случай. Ставь его, только если в самой реплике нет ни предмета, ни действия. Короткая, простая или незнакомая тема — это не причина для unknown: приветствие и болтовня — это chat, вопрос на любую тему — это query, просьба сделать что-то названное — это act.
- system против query — часы и календарная дата сами по себе (сколько времени, какое число, какой день недели — можно и про завтра, и про другой город) — это system. А что записано в календаре или в памяти («что у меня завтра», «какие есть напоминания») — это query. Если в реплике есть просьба (напомни, запиши, сделай), то названное время — просто деталь просьбы, и это не system.
- 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":"system"}
"какое число завтра?" → {"intent":"system"}
"привет" → {"intent":"chat","text":"привет"}
"сделай это" → {"intent":"unknown"}
"ну это" → {"intent":"unknown"}
"потом" → {"intent":"unknown"}
Но не путай — здесь unknown не нужен:
"сделай кофе" → {"intent":"act","verb":"сделать кофе"}
"что такое кватернион?" → {"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
// 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
// routeIntentUnknown — the model's way of saying "I could not route this".
// It is a wire value only: it never becomes a router.Intent, it just makes
// Route return ok=false so the caller drops to the classifier cascade.
const routeIntentUnknown = "unknown"
// llmFullConfidence / llmThinConfidence — Vikunja #359. Confidence used to be
// hardcoded to 1.0 for every LLM decision, so the stage-3 gate in router.go
// never had anything to bite on and the LLM path could never produce a
// Clarify: on the 77-case RU fixture, 6/6 want_clarify cases were missed by
// EVERY model in the 31-07-2026 bake-off (0.8B through 2B) — proof this was a
// code bug, not a capability ceiling.
//
// The fix does not touch the prompt (routeSystem is under
// llm/check_prompt_parity.py in the training workspace; changing its text
// creates a parity break that has to be fixed there too — see Vikunja #362).
// Instead it reads structural signal that is already free:
// - a single-token utterance is thin evidence for anything a grammar
// didn't already catch at stage 0 — "вода" and "бэкап" alone don't say
// fact-vs-query or act-vs-report;
// - a fact with no key, or an act that never resolves to an allowlisted fn
// (checked in router.go, after slot-fill has had its say), is a decision
// with a hole in the one slot that makes it actionable.
//
// A model self-reporting confidence in the JSON was considered and rejected:
// a sub-2B is not calibrated (nothing stops it saying "confident" on exactly
// the cases it gets wrong today), and true logprobs would need a response
// field internal/llm.Client's Complete does not currently return — see
// internal/llm/client.go.
//
// llmThinConfidence sits below config.DefaultRouterThreshold (0.55) so the
// existing stage-3 gate in Router.Route treats it exactly like a low-scoring
// classifier result — same lane, same daemon-side clarify machinery
// (cmd/mavend/clarify.go), no new consumer to build.
const (
llmFullConfidence = 1.0
llmThinConfidence = 0.3
)
type routeAction struct {
Intent string `json:"intent"`
Key string `json:"key"`
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
// decision to use: either the model failed (err set) or it refused with the
// "unknown" intent (err nil). Both mean the same thing to the caller — use the
// classifier instead.
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]
// The model refused. Report "no decision" without an error, which is the
// same fall-through the caller already uses for a parse failure — the
// classifier cascade gets the turn and its own confidence gate decides
// whether to ask. Better a slower second opinion than a confident guess.
if a.Intent == routeIntentUnknown {
return Decision{}, false, nil
}
d := Decision{Utterance: utterance, Stage: 1, Confidence: llmFullConfidence}
// A bare one-word nominal is thin evidence: the model had nothing to
// disambiguate on ("вода" is a fact-or-query coin flip, "бэкап" an
// act-or-report one) and stage 0 would already have won on anything
// that pattern-matches cleanly. A greeting or an inflected verb is NOT
// thin, however short — see thinSingleToken. Flag it now; router.go's
// stage-3 gate (Router.Route) decides whether that trips Clarify.
if thinSingleToken(utterance) {
d.Confidence = llmThinConfidence
}
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
// No utterance fallback here, unlike every other intent below. The
// model returning no text for a reminder means it found no subject,
// and "напомни в 11" is not a subject. Leaving Text empty is what
// lets the gate turn that into a question (Vikunja #383).
d.Slots.Text = a.Text
case IntentNote:
d.Intent = IntentNote
// The utterance, never the model's text field (V-576). A note is his
// own words, and the daemon phrases the confirmation from this slot.
// The model is free to write anything here, and on the box it did: one
// fragment came back twice as two different sentences he never said.
d.Slots.Text = utterance
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)
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
}