router: wire SelectCapability into Router.Route pipeline (slice 6b)

Call SelectCapability after each cascade path (grammar, heads, LLM,
classifier) and propagate the result via applyCapabilityToSlots.

Remove LLM text capability backfill from fillSlots — SelectCapability
now owns that path. fillMatchedSlots retains raw extractor capability
extraction for backward compatibility with stage-0 grammars.

gateLLMDecision now reads CapabilitySelection.Resolved instead of
Slots.HasFn for the act-intent confidence thinning check.
This commit is contained in:
2026-09-06 21:02:05 +04:00
parent a6a62a2d56
commit 8d83c66d11
+31 -14
View File
@@ -105,6 +105,11 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
// 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)
// Capability selection: deterministic grammars that already resolved Fn
// (wakeword-act, praxis, task-status) bypass the general selector.
// SelectCapability records the existing result with
// InputKind=SelectionDeterministic.
applyCapabilityToSlots(&d, SelectCapability(d, r.extractor.Acts))
r.noteGrammarOutcomes(ctx, i+1, declinedBuild, g.Name, d.Intent)
return d, nil
}
@@ -142,6 +147,7 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
Producer: RouteProducerHeads,
}
r.fillSlots(ctx, &d, now)
applyCapabilityToSlots(&d, SelectCapability(d, r.extractor.Acts))
// 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
@@ -184,6 +190,13 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
d.Utterance = utterance
d.Producer = RouteProducerLLM
r.fillSlots(ctx, &d, now)
// Capability selection: after fillSlots populates Text, SelectCapability
// tries the LLM-cleaned text against the allowlist when the raw
// utterance did not match. This replaces the former LLM text backfill
// that was embedded in fillSlots. Must run before gateLLMDecision so
// the gate sees the correct resolution state for acts.
sel := SelectCapability(d, r.extractor.Acts)
applyCapabilityToSlots(&d, sel)
before := d.Confidence
r.gateLLMDecision(&d)
// The classifier is the floor and it never ran, which is the whole
@@ -244,6 +257,12 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
Slots: r.extractor.Extract(ctx, best.Intent, utterance, now),
Producer: RouteProducerClassifier,
}
// Capability selection: for the classifier path, the raw extractor
// already filled Slots.Fn/Args via Extract(IntentAct). SelectCapability
// records this as a deterministic bypass. If the raw match missed,
// there is no LLM text to fall back to (Text == Utterance), so the
// selection remains unresolved.
applyCapabilityToSlots(&d, SelectCapability(d, r.extractor.Acts))
// stage 3 — confidence gate. Below threshold ⇒ clarify, don't guess.
if d.Confidence < r.threshold {
@@ -302,27 +321,22 @@ func (r *Router) fillMatchedSlots(ctx context.Context, d *Decision, now time.Tim
if !d.Slots.HasKey && ex.HasKey {
d.Slots.Key, d.Slots.Value, d.Slots.HasKey = ex.Key, ex.Value, ex.HasKey
}
// Capability selection (Fn/Args/HasFn) is now handled by
// SelectCapability, not here. The extractor still fills them via
// Extract(IntentAct), but the authoritative record lives in
// Decision.CapabilitySelection. See capability.go.
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.
// fillSlots — fillMatchedSlots for an LLM decision, plus the text backfill.
// Capability selection (Fn/Args) is now handled by SelectCapability, called
// after fillSlots in the routing pipeline. This function fills only time, key,
// and text slots.
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
@@ -354,11 +368,14 @@ func (r *Router) fillSlots(ctx context.Context, d *Decision, now time.Time) {
// 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.
//
// The act check now reads CapabilitySelection.Resolved (set by
// SelectCapability before this function runs) rather than Slots.HasFn.
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 {
if d.Intent == IntentAct && !d.CapabilitySelection.Resolved && d.Confidence > llmThinConfidence {
d.Confidence = llmThinConfidence
}
// A reminder with no subject: she knows when but not what to say then.