f0f7ebc9b2
Confidence was hardcoded to 1.0 for every LLM decision, and the LLM branch
in Router.Route returned straight from fillSlots without ever touching the
stage-3 threshold gate — so the LLM path could not produce a Clarify no
matter what confidence a model reported. That is why all 6 want_clarify
cases in the 77-case RU fixture were missed by every model in the bake-off.
Fix reads structural signal instead of changing the (parity-locked) router
prompt: a single-token utterance ("вода", "бэкап") is flagged thin evidence
in llmrouter.go; a fact left keyless or an act that never resolves to an
allowlisted fn, checked after fillSlots so the deterministic parsers get
first crack, is flagged in router.go's new gateLLMDecision. Anything below
config.DefaultRouterThreshold (0.55) now sets Clarify=true through the same
path the classifier already uses.
Added unit tests with a stubbed Completer proving both directions: thin
cases clarify, clean multi-word/resolved-slot cases stay confident. The
77-case fixture re-run against a live llama-server is still needed to
confirm the 6/6 moves — not done here, no llama-server on this box.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
192 lines
7.5 KiB
Go
192 lines
7.5 KiB
Go
package router
|
|
|
|
import (
|
|
"context"
|
|
"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
|
|
}
|
|
}
|
|
if d.Slots.Text == "" {
|
|
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
|
|
}
|
|
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 {
|
|
return r.classifier.AddExample(ctx, corrected, utterance)
|
|
}
|