869580c913
"напомни в 11:00 позвонить маме" answered "Когда?" about an hour he had just said. ReminderGrammar builds its slots by hand and the router ran no extraction over a stage-0 decision, so HasTime was false however plainly the hour was spoken; missingFor read the silence as absence. The fix runs the stage-2 extractor over every stage-0 decision, filling only the slots the grammar left empty. A matched value always wins: the rule read a literal pattern, the extractor guesses. This is the same hole the LLM path already had, so fillSlots and the new stage-0 call share one fillMatchedSlots. Enabled for all ten grammars rather than 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 clock, agenda, feed, list, task, Praxis-adjacent and narrative rules emit. The two act rules, wakeword-act and the Praxis ones, already carry an Fn or they do not match, so the matcher has nothing left to fill. Measured rather than asserted: benchmarked at 20000x, a stage-0 query is 3.7µs against 3.9µs before and a clock or act rule is 0.7µs either way, both inside the noise. The reminder rule is the one that gains, and its date parse is not new spend — actionReminder was already running exactly that parse one layer down, and now skips it. Slots.Text is deliberately not filled. Extract sets it to the raw utterance, and a grammar that left it empty meant it: agendaQueryBuild hands the query chain the sentence itself, and narrativeQueryBuild's Text is the topic. Fixture unchanged at 64/91 (70.3%) on TestONNXBaseline, no case regressed, no new false clarify. What moved is the line the fixture calls "slots deferred to daemon": 6 to 0. Verified on homesrv: "напомни в 11:00 позвонить маме" now answers "хорошо, напомню сегодня в 11:00."
251 lines
11 KiB
Go
251 lines
11 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
|
||
// 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)
|
||
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
|
||
}
|
||
|
||
// 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)
|
||
}
|