Files
Maven/internal/router/router.go
T
claude 869580c913 a reminder said whole no longer asks "Когда?" (V-572)
"напомни в 11:00 позвонить маме" answered "Когда?" about an hour he had
just said. ReminderGrammar builds its slots by hand and the router ran no
extraction over a stage-0 decision, so HasTime was false however plainly
the hour was spoken; missingFor read the silence as absence.

The fix runs the stage-2 extractor over every stage-0 decision, filling
only the slots the grammar left empty. A matched value always wins: the
rule read a literal pattern, the extractor guesses. This is the same hole
the LLM path already had, so fillSlots and the new stage-0 call share one
fillMatchedSlots.

Enabled for all ten grammars rather than a chosen few, because for every
intent but reminder it is inert. Extract fills Time for a reminder, Fn for
an act and Key for a fact, and nothing at all for query, system, note or
chat — which is what the clock, agenda, feed, list, task, Praxis-adjacent
and narrative rules emit. The two act rules, wakeword-act and the Praxis
ones, already carry an Fn or they do not match, so the matcher has nothing
left to fill. Measured rather than asserted: benchmarked at 20000x, a
stage-0 query is 3.7µs against 3.9µs before and a clock or act rule is
0.7µs either way, both inside the noise. The reminder rule is the one that
gains, and its date parse is not new spend — actionReminder was already
running exactly that parse one layer down, and now skips it.

Slots.Text is deliberately not filled. Extract sets it to the raw
utterance, and a grammar that left it empty meant it: agendaQueryBuild
hands the query chain the sentence itself, and narrativeQueryBuild's Text
is the topic.

Fixture unchanged at 64/91 (70.3%) on TestONNXBaseline, no case regressed,
no new false clarify. What moved is the line the fixture calls "slots
deferred to daemon": 6 to 0.

Verified on homesrv: "напомни в 11:00 позвонить маме" now answers
"хорошо, напомню сегодня в 11:00."
2026-08-06 01:08:38 +04:00

251 lines
11 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"
"errors"
"log"
"time"
)
// Config — wires the cascade. Build via New; a zero-value Router is unusable.
type Config struct {
// Grammars — stage-0 exact-match rules. DefaultGrammars(actMatcher) wires
// the wake-word act fast path; the daemon may append more.
Grammars []Grammar
// Classifier — stage-1 nearest-centroid classifier. Must be seeded with
// ~10 examples/intent at bootstrap (per spec) before free-form routing
// is trustworthy; until then Route returns ErrNoIntents on free-form input.
Classifier *Classifier
// Extractor — stage-2 per-intent slot extraction. Any nil sub-parser just
// leaves the corresponding Has* flag false for that intent.
Extractor Extractor
// Threshold — stage-3 confidence gate. Below ⇒ Clarify, don't guess. The
// spec leaves this open (defines how often maven asks vs guesses on free-
// form input; the whole reactive mvp feel rides on it). The daemon sets it.
Threshold float64
// LLM — optional agentic router. When set, Route consults it after stage-0
// and before the classifier cascade, classifying the utterance via a
// grammar-constrained call to the resident model (Qwen3-1.7B). On any
// error/parse failure, falls through to the classifier (never fails the
// turn on the model).
LLM *LLMRouter
}
// Router — the deterministic cascade. Route never guesses: stage 0 wins
// outright, stage 1 scores, stage 2 extracts, stage 3 gates. The SLM only
// phrases the reply — it never owns the route.
type Router struct {
grammars []Grammar
classifier *Classifier
extractor Extractor
threshold float64
llm *LLMRouter
}
func New(cfg Config) *Router {
return &Router{
grammars: cfg.Grammars,
classifier: cfg.Classifier,
extractor: cfg.Extractor,
threshold: cfg.Threshold,
llm: cfg.LLM,
}
}
// Route — the cascade: stage 0 (exact match) → 1 (classify) → 2 (extract) →
// 3 (confidence gate).
//
// Stage 0 wins outright: returns at confidence 1.0, no classifier.
// Otherwise the classifier scores every intent; the best wins; slots are
// extracted for that intent. If the winning score < threshold the Decision
// is flagged Clarify (the daemon asks rather than guesses — same shape as
// since(key)==null → don't fire: a misrouted fact is a confident wrong write,
// worse than a gap).
func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (Decision, error) {
// stage 0 — exact match / grammar. First match wins; grammars are ordered.
// Grammars like time/date/reminder don't expect a wake-word prefix, but
// the STT often includes one (transcribed phonetically, any script) — try
// the wake-stripped utterance too so those grammars still fire.
stripped, hadWake := StripWakeToken(utterance)
for _, g := range r.grammars {
m := g.Pattern.FindStringSubmatch(utterance)
if m == nil && hadWake {
m = g.Pattern.FindStringSubmatch(stripped)
}
if m == nil {
continue
}
d, ok := g.Build(m)
if !ok {
continue // grammar matched shape but not content → fall through
}
d.Utterance = utterance
// The grammar decided the intent; the extractor fills the slots it did
// not match (V-572). See fillMatchedSlots for why every grammar gets it.
r.fillMatchedSlots(ctx, &d, now)
return d, nil
}
// stage 1a — LLM router (when wired). It reasons over the utterance instead
// of nearest-centroid guessing. On any error/parse-fail, fall through to the
// classifier cascade (never fail the turn on the model).
if r.llm != nil {
if d, ok, err := r.llm.Route(ctx, utterance, now); err == nil && ok {
d.Utterance = utterance
r.fillSlots(ctx, &d, now)
r.gateLLMDecision(&d)
return d, nil
} else if err != nil {
log.Printf("router: llm route fell back to classifier: %v", err)
}
}
// stage 1 — intent classifier.
results, err := r.classifier.Classify(ctx, utterance)
if err != nil {
return Decision{}, err
}
best := results[0]
// stage 2 — slot extraction for the winning intent.
d := Decision{
Utterance: utterance,
Stage: 2,
Intent: best.Intent,
Confidence: best.Score,
Slots: r.extractor.Extract(ctx, best.Intent, utterance, now),
}
// stage 3 — confidence gate. Below threshold ⇒ clarify, don't guess.
if d.Confidence < r.threshold {
d.Stage = 3
d.Clarify = true
}
return d, nil
}
// fillMatchedSlots — run stage-2 extraction over a decision some earlier
// claimant produced, and fill only the slots that claimant left empty. A
// matched value always wins: the claimant read the sentence, the extractor
// guesses from keyword tables.
//
// Shared by the stage-0 grammars and the LLM router, which had the same hole
// for the same reason. A grammar asserts an intent at confidence 1.0 and says
// nothing about the slots, so "напомни в 11:00 позвонить маме" arrived with
// HasTime false however plainly the hour was spoken, and the daemon read the
// silence as absence and asked "Когда?" (V-572). The alternative was ten
// grammars each re-implementing extraction.
//
// It is applied to every stage-0 decision rather than to a chosen few, because
// for every intent but reminder it is inert: Extract fills Time for a reminder,
// Fn for an act and Key for a fact, and nothing at all for query, system, note
// or chat, which is what the query, clock, agenda, feed, list, task and
// narrative rules emit. The act rules — wakeword-act and the Praxis ones —
// already carry an Fn or they do not match, so there is nothing left for the
// matcher to fill. The reminder rule is the one that gains, and its time parse
// is a cost the daemon was already paying one layer down in actionReminder.
//
// Slots.Text is deliberately NOT filled here. Extract sets it to the raw
// utterance, and a grammar that left it empty meant it: agendaQueryBuild hands
// the query chain the utterance itself, and narrativeQueryBuild's Text is the
// topic, not the sentence.
//
// If a reminder still has no time, leave it missing. The daemon then says it
// could not read the time; inventing one would set a wrong alarm.
// Returns what the extractor read, so a caller that wants more of it does not
// pay for a second extraction — the reminder parser is the expensive one.
func (r *Router) fillMatchedSlots(ctx context.Context, d *Decision, now time.Time) Slots {
ex := r.extractor.Extract(ctx, d.Intent, d.Utterance, now)
if !d.Slots.HasTime && ex.HasTime {
d.Slots.Time, d.Slots.HasTime = ex.Time, ex.HasTime
}
if !d.Slots.HasKey && ex.HasKey {
d.Slots.Key, d.Slots.Value, d.Slots.HasKey = ex.Key, ex.Value, ex.HasKey
}
if !d.Slots.HasFn && ex.HasFn {
d.Slots.Fn, d.Slots.Args, d.Slots.HasFn = ex.Fn, ex.Args, ex.HasFn
}
return ex
}
// fillSlots — fillMatchedSlots for an LLM decision, plus the two backfills that
// only make sense there. The LLM wins where it answered: it saw the sentence,
// the parsers are keyword tables.
func (r *Router) fillSlots(ctx context.Context, d *Decision, now time.Time) {
ex := r.fillMatchedSlots(ctx, d, now)
// For an act the model returns the verb in Text ("restart nginx"), which is
// often cleaner than the raw utterance ("maven, could you restart nginx").
// Try it too when the utterance did not match the allowlist.
if d.Intent == IntentAct && !d.Slots.HasFn && r.extractor.Acts != nil &&
d.Slots.Text != "" && d.Slots.Text != d.Utterance {
if fn, args, ok := r.extractor.Acts.Match(d.Slots.Text); ok {
d.Slots.Fn, d.Slots.Args, d.Slots.HasFn = fn, args, true
}
}
// The extractor's Text is the raw utterance, which is the payload for a
// note, a query or a chat turn but not for a reminder — there Text is the
// subject, what she says at the hour. Backfilling it made Text impossible
// to be empty, so StillMissing never reported SlotText and "О чём
// напомнить?" was unaskable; the answer to a question she did manage to
// ask then overwrote the whole request instead of filling one gap
// (Vikunja #383). A reminder with no subject stays empty and is gated
// below into a question.
if d.Slots.Text == "" && d.Intent != IntentReminder {
d.Slots.Text = ex.Text
}
// Stage stays 1: it says who decided the route, and that was the LLM.
}
// gateLLMDecision — stage 3 for the LLM path (Vikunja #359). This used to be
// the classifier's job alone (see the threshold check at the bottom of
// Route): the LLM branch returned straight from fillSlots and never touched
// r.threshold at all, so a hardcoded Confidence: 1.0 in llmrouter.go could
// never gate. Two more structural holes are checked here, after fillSlots
// has had a chance to fill them from the deterministic parsers — checking
// before fillSlots would flag e.g. every keyless fact the fact parser goes
// on to resolve (TestLLMFactGetsKeyFromParser):
// - a fact with no key even after the parser tried — nothing to write, or
// worse, a confident write under the wrong key;
// - an act that never resolved to an allowlisted fn — a confident guess
// here means either silently doing nothing or, if the daemon is lax,
// running something never on the allowlist. Don't guess; ask.
//
// Anything below threshold gets the exact same Clarify=true treatment the
// classifier path already produces — same field, same daemon-side consumer
// (cmd/mavend/clarify.go), nothing new to wire.
func (r *Router) gateLLMDecision(d *Decision) {
if d.Intent == IntentFact && !d.Slots.HasKey && d.Confidence > llmThinConfidence {
d.Confidence = llmThinConfidence
}
if d.Intent == IntentAct && !d.Slots.HasFn && d.Confidence > llmThinConfidence {
d.Confidence = llmThinConfidence
}
// A reminder with no subject: she knows when but not what to say then.
// Setting it anyway fires an empty reminder at the hour, which reads as a
// bug to him and cannot be repaired after the fact. Ask (Vikunja #383).
//
// The test is what the text slot CONTAINS, not whether it is set. It was the
// latter until 05-08-2026, and the slot is never empty: fillSlots hands it
// the utterance, so "напомни" arrived here with Text:напомни and the gate
// never fired (V-457). See remindersubject.go.
if d.Intent == IntentReminder && !reminderHasSubject(d.Slots.Text) && d.Confidence > llmThinConfidence {
d.Confidence = llmThinConfidence
}
if d.Confidence < r.threshold {
d.Clarify = true
}
}
// CorrectMisroute — the user corrected a bad classification. Appends a new
// example for the corrected intent (append-only — grows the classifier, no
// retrain). Same shape as nudges.outcome tuning cooldowns: more reliable over
// time, introspectable, no model surgery.
func (r *Router) CorrectMisroute(ctx context.Context, utterance string, corrected Intent) error {
if r == nil || r.classifier == nil {
// The LLM router can run with no classifier wired. The correction has
// nowhere to land then, and the caller redoes the request anyway.
return errors.New("router: no classifier to correct")
}
return r.classifier.AddExample(ctx, corrected, utterance)
}