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 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 } // fillSlots — run stage-2 extraction on an LLM decision and fill only the slots // the model left empty. The LLM wins where it answered: it saw the sentence, the // parsers are keyword tables. Extraction covers what the model cannot produce at // all — a parsed reminder time and an allowlist fn. // // 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. func (r *Router) fillSlots(ctx context.Context, d *Decision, now time.Time) { 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 } // 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) }