Files
Maven/internal/router/router.go
T
claude 06adc4702d router: set ResolvedBy at each function selection point
Assign provenance where the exact fn is produced:
- grammar_fixed: praxis/task-status grammars hardcode fn
- grammar_matcher: wakeword-act grammar invokes ActMatcher
- extractor_raw: Extractor.Extract matches over raw utterance
- extractor_llm_text: fillSlots LLM backfill matches cleaned text
- fallback_matcher: ResolveActionCandidate runs the fallback matcher

ResolveActionCandidate propagates Slots.ResolvedBy into
ActionCandidate.ResolvedBy. No selection behavior changes.
2026-09-06 13:50:03 +04:00

392 lines
17 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"
"github.com/kami/maven/internal/decision"
)
// 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
// Heads — optional routing heads over the fine-tuned embedder copy. When
// set, Route consults them after stage 0 and before the LLM router. They
// decline below their own confidence threshold, so a low-confidence turn
// reaches the model exactly as it does today. Nil is the shipped-before
// behaviour and costs nothing.
Heads *RouterHeads
}
// 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
heads *RouterHeads
}
func New(cfg Config) *Router {
return &Router{
grammars: cfg.Grammars,
classifier: cfg.Classifier,
extractor: cfg.Extractor,
threshold: cfg.Threshold,
llm: cfg.LLM,
heads: cfg.Heads,
}
}
// 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)
// declinedBuild — the grammars that matched the shape and refused the
// content, kept for the decision record (V-564) so a reader can tell that
// rule from one whose pattern never fired.
var declinedBuild map[int]bool
for i, g := range r.grammars {
d, matched, ok := g.Evaluate(utterance)
if !matched && hadWake {
d, matched, ok = g.Evaluate(stripped)
}
if !matched {
continue
}
if !ok {
if declinedBuild == nil {
declinedBuild = map[int]bool{}
}
declinedBuild[i] = true
continue // grammar matched shape but not content → fall through
}
d.Utterance = utterance
d.Producer = RouteProducerGrammar
// A literal pattern named that destination, which is the one provenance
// allowed to take the personal boundary off a turn (V-666). Set here and
// nowhere else, so no other arm of the cascade can claim it.
d.SourceAnchored = d.Source != SourceUnknown
// 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)
r.noteGrammarOutcomes(ctx, i+1, declinedBuild, g.Name, d.Intent)
return d, nil
}
r.noteGrammarOutcomes(ctx, len(r.grammars), declinedBuild, "", "")
// stage 0b — routing heads (when wired). A softmax over the label set, so
// it cannot name an intent or a destination that does not exist, and its
// max is a real confidence. It runs before the model because it is three
// orders of magnitude faster and scores better on both halves of the route.
//
// It declines below its threshold rather than clarifying. A declined turn
// carries on to the model and then the classifier, which is what a box with
// no weights file does on every turn.
if r.heads != nil {
res, ok, err := r.heads.Route(ctx, utterance)
switch {
case err != nil:
log.Printf("router: heads fell through to the rest of the cascade: %v", err)
decision.Note(ctx, decision.Claim{
Stage: decision.StageRoute, Claimant: claimantHeads,
Outcome: decision.Declined, Reason: "error: " + err.Error(),
})
case !ok:
decision.Note(ctx, decision.Scored(decision.StageRoute, claimantHeads,
string(res.Intent), res.Confidence, decision.Declined,
"below the heads confidence threshold"))
default:
d := Decision{
Utterance: utterance,
Stage: 2,
Intent: res.Intent,
Confidence: res.Confidence,
Source: res.Source,
Clarify: res.Clarify,
Producer: RouteProducerHeads,
}
r.fillSlots(ctx, &d, now)
// The clarify head relearned the English assumption that one word
// cannot be a sentence. Russian verbs carry subject and tense, and a
// deterministic fact parser which also found a key gives both halves
// of a complete write. That structural evidence outranks this one
// learned veto; question-shaped turns remain untouched.
clarifyOverruled := d.Clarify && completeParsedSingleVerbFact(d)
if clarifyOverruled {
d.Clarify = false
}
decision.Note(ctx, decision.Claim{
Stage: decision.StageRoute, Claimant: claimantLLM,
Outcome: decision.NeverAsked, Reason: "the routing heads answered",
})
decision.Note(ctx, decision.Claim{
Stage: decision.StageRoute, Claimant: claimantClassifier,
Outcome: decision.NeverAsked, Reason: "the routing heads answered",
})
outcome, reason := decision.Won, ""
if d.Clarify {
outcome, reason = decision.Thinned, "the clarify head says there is too little here to act on"
} else if clarifyOverruled {
reason = "a parsed single-token Russian verb is a complete fact"
}
decision.Note(ctx, decision.Scored(decision.StageRoute, claimantHeads,
string(d.Intent), d.Confidence, outcome, reason))
return d, nil
}
} else {
decision.Note(ctx, decision.Claim{
Stage: decision.StageRoute, Claimant: claimantHeads,
Outcome: decision.NeverAsked, Reason: "no routing heads are wired",
})
}
// 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
d.Producer = RouteProducerLLM
r.fillSlots(ctx, &d, now)
before := d.Confidence
r.gateLLMDecision(&d)
// The classifier is the floor and it never ran, which is the whole
// reason a wrong LLM route reads as unexplainable (V-564).
decision.Note(ctx, decision.Claim{
Stage: decision.StageRoute, Claimant: claimantClassifier,
Outcome: decision.NeverAsked, Reason: "the LLM router answered",
})
outcome, reason := decision.Won, ""
if d.Confidence < before {
outcome, reason = decision.Thinned, thinReason(&d)
}
decision.Note(ctx, decision.Scored(decision.StageRoute, claimantLLM,
string(d.Intent), d.Confidence, outcome, reason))
return d, nil
} else if err != nil {
log.Printf("router: llm route fell back to classifier: %v", err)
decision.Note(ctx, decision.Claim{
Stage: decision.StageRoute, Claimant: claimantLLM,
Outcome: decision.Declined, Reason: "error: " + err.Error(),
})
} else {
decision.Note(ctx, decision.Claim{
Stage: decision.StageRoute, Claimant: claimantLLM,
Outcome: decision.Declined, Reason: "no parsable route in the reply",
})
}
} else {
decision.Note(ctx, decision.Claim{
Stage: decision.StageRoute, Claimant: claimantLLM,
Outcome: decision.NeverAsked, Reason: "no LLM router is wired",
})
}
// stage 1 — intent classifier.
results, err := r.classifier.Classify(ctx, utterance)
if err != nil {
return Decision{}, err
}
best := results[0]
// The runners-up are the interesting part: two intents a hundredth apart is
// a different defect from one that won outright (V-564). Two are enough to
// see that, and the rest of a seven-intent scoreboard is noise on the page.
if rec := decision.From(ctx); rec != nil {
for _, res := range results[1:min(len(results), 3)] {
rec.Note(decision.Scored(decision.StageRoute, claimantClassifier,
string(res.Intent), res.Score, decision.LostOnScore,
"lower similarity than "+string(best.Intent)))
}
}
// 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),
Producer: RouteProducerClassifier,
}
// stage 3 — confidence gate. Below threshold ⇒ clarify, don't guess.
if d.Confidence < r.threshold {
d.Stage = 3
d.Clarify = true
}
outcome, reason := decision.Won, ""
if d.Clarify {
outcome, reason = decision.Thinned, "below the clarify threshold, so she asks instead"
}
decision.Note(ctx, decision.Scored(decision.StageRoute, claimantClassifier,
string(d.Intent), d.Confidence, outcome, reason))
return d, nil
}
func completeParsedSingleVerbFact(decision Decision) bool {
return decision.Intent == IntentFact && decision.Slots.HasKey &&
completeSingleVerb(decision.Utterance) && !IsQuestionShaped(decision.Utterance)
}
// 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, d.Slots.ResolvedBy = ex.Fn, ex.Args, ex.HasFn, ex.ResolvedBy
}
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
d.Slots.ResolvedBy = ActionResolutionExtractorLLMText
}
}
// 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)
}