d850f1f5fd
The subjectless-reminder gate has been dead since V-383. It tested `d.Slots.Text == ""`, and that slot is never empty: fillSlots hands it the utterance when the model names nothing narrower. Measured on the box on 05-08-2026 — "напомни" alone routed to IntentReminder with Text:напомни, reached actionReminder, and answered "не получилось разобрать время напоминания." A parse error for a request he never finished asking about. "ну напомни же" did the same. The test is now what the slot CONTAINS. reminderHasSubject discounts the reminder verb by lemma and the filler particles, and asks whether anything is left. A day or an hour counts as a subject, which is why this does not reuse cmd/mavend/reminderbody.go — that one strips the time words too. filler_particles is the lexicon's 16th set. Not a stopword list: every word in it is one that cannot BE a reminder's subject. Measured against the 87-case fixture with and without the change: 65/87 both ways, identical clarify counts, because no case exercised the shape. So amb-007 "напомни" and amb-008 "ну напомни же" were added, both want_clarify. At 89 cases the cascade scores 67/89 (75.3% full, 79.8% intent-only), 3 false clarifies / 1 missed, p50 1.199s — the two new cases clarify, and nothing else moved. The classifier path still guesses both (62/89, 8 missed clarify); the gate is on the LLM arm only. The box needs a rebuild for this to take effect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
217 lines
8.9 KiB
Go
217 lines
8.9 KiB
Go
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)
|
||
}
|