8015fdbb79
Replace nearest-neighbour personal routing with a frozen class-balanced linear head measured on historical, stratified, cross-validation, holdout, and fresh challenge gates (V-702). Close the four repair handoff holes, preserve nested clarification flows, and route Russian possession statements through structural grammar rather than lexical exceptions (V-573). Owner explicitly requested direct commits to master.
371 lines
16 KiB
Go
371 lines
16 KiB
Go
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
|
||
// 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,
|
||
}
|
||
r.fillSlots(ctx, &d, now)
|
||
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"
|
||
}
|
||
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
|
||
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),
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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)
|
||
}
|