a6a62a2d56
Add the explicit capability-selection boundary between route resolution and action candidate production. SelectCapability is the single entry point for selecting which executable capability matched an IntentAct turn. Three input kinds: raw, llm_text, deterministic. Decision.CapabilitySelection is the authoritative record; Decision.Slots.Fn/Args/HasFn remain as compatibility representations populated from the selection.
397 lines
12 KiB
Go
397 lines
12 KiB
Go
package router
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
// --- SelectCapability tests ---
|
|
|
|
// TestSelectCapability_DeterministicBypass pins that a grammar-fixed act
|
|
// bypasses the general selector and records the existing result.
|
|
func TestSelectCapability_DeterministicBypass(t *testing.T) {
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{
|
|
Fn: "resolve_item", HasFn: true,
|
|
ResolvedBy: ActionResolutionGrammarFixed,
|
|
},
|
|
Producer: RouteProducerGrammar,
|
|
Confidence: 1.0,
|
|
}
|
|
sel := SelectCapability(dec, nil)
|
|
if !sel.Resolved {
|
|
t.Fatal("expected resolved")
|
|
}
|
|
if sel.Fn != "resolve_item" {
|
|
t.Errorf("Fn = %q, want resolve_item", sel.Fn)
|
|
}
|
|
if sel.Method != ActionResolutionGrammarFixed {
|
|
t.Errorf("Method = %q, want grammar_fixed", sel.Method)
|
|
}
|
|
if sel.InputKind != SelectionDeterministic {
|
|
t.Errorf("InputKind = %q, want deterministic", sel.InputKind)
|
|
}
|
|
if sel.Producer != RouteProducerGrammar {
|
|
t.Errorf("Producer = %q, want grammar", sel.Producer)
|
|
}
|
|
if sel.Confidence != 1.0 {
|
|
t.Errorf("Confidence = %f, want 1.0", sel.Confidence)
|
|
}
|
|
}
|
|
|
|
// TestSelectCapability_ExtractorRawBypass pins that an extractor-raw act
|
|
// bypasses the general selector.
|
|
func TestSelectCapability_ExtractorRawBypass(t *testing.T) {
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{
|
|
Fn: "restart", Args: []string{"nginx"}, HasFn: true,
|
|
ResolvedBy: ActionResolutionExtractorRaw,
|
|
},
|
|
Producer: RouteProducerClassifier,
|
|
}
|
|
sel := SelectCapability(dec, nil)
|
|
if !sel.Resolved {
|
|
t.Fatal("expected resolved")
|
|
}
|
|
if sel.Fn != "restart" {
|
|
t.Errorf("Fn = %q, want restart", sel.Fn)
|
|
}
|
|
if sel.Method != ActionResolutionExtractorRaw {
|
|
t.Errorf("Method = %q, want extractor_raw", sel.Method)
|
|
}
|
|
if sel.InputKind != SelectionDeterministic {
|
|
t.Errorf("InputKind = %q, want deterministic", sel.InputKind)
|
|
}
|
|
}
|
|
|
|
// TestSelectCapability_GrammarMatcherBypass pins that a grammar-matcher act
|
|
// (wakeword-act) bypasses the general selector.
|
|
func TestSelectCapability_GrammarMatcherBypass(t *testing.T) {
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{
|
|
Fn: "restart", Args: []string{"nginx"}, HasFn: true,
|
|
ResolvedBy: ActionResolutionGrammarMatcher,
|
|
},
|
|
Producer: RouteProducerGrammar,
|
|
}
|
|
sel := SelectCapability(dec, nil)
|
|
if !sel.Resolved {
|
|
t.Fatal("expected resolved")
|
|
}
|
|
if sel.Method != ActionResolutionGrammarMatcher {
|
|
t.Errorf("Method = %q, want grammar_matcher", sel.Method)
|
|
}
|
|
if sel.InputKind != SelectionDeterministic {
|
|
t.Errorf("InputKind = %q, want deterministic", sel.InputKind)
|
|
}
|
|
}
|
|
|
|
// TestSelectCapability_LLMTextMatch pins that when the raw utterance did not
|
|
// match but LLM cleaned text does, the selector resolves from LLM text.
|
|
func TestSelectCapability_LLMTextMatch(t *testing.T) {
|
|
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Utterance: "maven could you restart nginx",
|
|
Slots: Slots{
|
|
Text: "restart nginx",
|
|
},
|
|
Producer: RouteProducerLLM,
|
|
}
|
|
sel := SelectCapability(dec, m)
|
|
if !sel.Resolved {
|
|
t.Fatal("expected resolved from LLM text")
|
|
}
|
|
if sel.Fn != "restart" {
|
|
t.Errorf("Fn = %q, want restart", sel.Fn)
|
|
}
|
|
if len(sel.Args) != 1 || sel.Args[0] != "nginx" {
|
|
t.Errorf("Args = %v, want [nginx]", sel.Args)
|
|
}
|
|
if sel.Method != ActionResolutionExtractorLLMText {
|
|
t.Errorf("Method = %q, want extractor_llm_text", sel.Method)
|
|
}
|
|
if sel.InputKind != SelectionLLMText {
|
|
t.Errorf("InputKind = %q, want llm_text", sel.InputKind)
|
|
}
|
|
}
|
|
|
|
// TestSelectCapability_LLMTextSameAsUtterance pins that when Slots.Text equals
|
|
// the utterance, the selector does NOT try LLM text (no second attempt).
|
|
func TestSelectCapability_LLMTextSameAsUtterance(t *testing.T) {
|
|
m := DefaultActMatcher{Fns: []string{"restart"}}
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Utterance: "restart nginx",
|
|
Slots: Slots{Text: "restart nginx"},
|
|
Producer: RouteProducerClassifier,
|
|
}
|
|
sel := SelectCapability(dec, m)
|
|
// Text == Utterance means no LLM cleaned text; raw match should have
|
|
// been done by the extractor. Since HasFn is false, selector sees no
|
|
// LLM text to try.
|
|
if sel.Resolved {
|
|
t.Fatal("expected unresolved when Text == Utterance and no HasFn")
|
|
}
|
|
}
|
|
|
|
// TestSelectCapability_Unresolved pins that when neither the grammar/extractor
|
|
// nor the LLM text matches, the selection is unresolved.
|
|
func TestSelectCapability_Unresolved(t *testing.T) {
|
|
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Utterance: "deploy the thing",
|
|
Slots: Slots{Text: "deploy the thing"},
|
|
Producer: RouteProducerClassifier,
|
|
}
|
|
sel := SelectCapability(dec, m)
|
|
if sel.Resolved {
|
|
t.Fatal("expected unresolved")
|
|
}
|
|
if sel.Fn != "" {
|
|
t.Errorf("Fn = %q, want empty", sel.Fn)
|
|
}
|
|
}
|
|
|
|
// TestSelectCapability_NonActIntent pins that a non-act intent returns an
|
|
// empty selection.
|
|
func TestSelectCapability_NonActIntent(t *testing.T) {
|
|
dec := Decision{
|
|
Intent: IntentFact,
|
|
Slots: Slots{Key: "water", HasKey: true},
|
|
}
|
|
sel := SelectCapability(dec, nil)
|
|
if sel.Resolved {
|
|
t.Fatal("expected unresolved for non-act")
|
|
}
|
|
}
|
|
|
|
// TestSelectCapability_NilMatcher pins that a nil matcher does not panic
|
|
// and produces an unresolved selection when no grammar matched.
|
|
func TestSelectCapability_NilMatcher(t *testing.T) {
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Utterance: "restart nginx",
|
|
Slots: Slots{Text: "restart nginx"},
|
|
Producer: RouteProducerClassifier,
|
|
}
|
|
sel := SelectCapability(dec, nil)
|
|
if sel.Resolved {
|
|
t.Fatal("expected unresolved with nil matcher")
|
|
}
|
|
}
|
|
|
|
// --- applyCapabilityToSlots tests ---
|
|
|
|
// TestApplyCapabilityToSlots_PopulatesCompatibilityFields pins that the
|
|
// compatibility fields on Decision.Slots are populated from the selection.
|
|
func TestApplyCapabilityToSlots_PopulatesCompatibilityFields(t *testing.T) {
|
|
dec := Decision{}
|
|
sel := CapabilitySelection{
|
|
Fn: "restart",
|
|
Args: []string{"nginx"},
|
|
Resolved: true,
|
|
Method: ActionResolutionExtractorRaw,
|
|
InputKind: SelectionDeterministic,
|
|
Producer: RouteProducerClassifier,
|
|
Confidence: 0.85,
|
|
}
|
|
applyCapabilityToSlots(&dec, sel)
|
|
|
|
if dec.CapabilitySelection.Fn != "restart" {
|
|
t.Errorf("CapabilitySelection.Fn = %q, want restart", dec.CapabilitySelection.Fn)
|
|
}
|
|
if !dec.Slots.HasFn {
|
|
t.Error("Slots.HasFn should be true")
|
|
}
|
|
if dec.Slots.Fn != "restart" {
|
|
t.Errorf("Slots.Fn = %q, want restart", dec.Slots.Fn)
|
|
}
|
|
if len(dec.Slots.Args) != 1 || dec.Slots.Args[0] != "nginx" {
|
|
t.Errorf("Slots.Args = %v, want [nginx]", dec.Slots.Args)
|
|
}
|
|
if dec.Slots.ResolvedBy != ActionResolutionExtractorRaw {
|
|
t.Errorf("Slots.ResolvedBy = %q, want extractor_raw", dec.Slots.ResolvedBy)
|
|
}
|
|
}
|
|
|
|
// TestApplyCapabilityToSlots_UnresolvedDoesNotSetSlots pins that an unresolved
|
|
// selection does not populate the compatibility fields.
|
|
func TestApplyCapabilityToSlots_UnresolvedDoesNotSetSlots(t *testing.T) {
|
|
dec := Decision{Slots: Slots{Fn: "old", HasFn: true}}
|
|
sel := CapabilitySelection{
|
|
Resolved: false,
|
|
Producer: RouteProducerClassifier,
|
|
}
|
|
applyCapabilityToSlots(&dec, sel)
|
|
|
|
if dec.CapabilitySelection.Resolved {
|
|
t.Error("CapabilitySelection.Resolved should be false")
|
|
}
|
|
// Compatibility fields should remain unchanged.
|
|
if dec.Slots.Fn != "old" {
|
|
t.Errorf("Slots.Fn = %q, want old (unchanged)", dec.Slots.Fn)
|
|
}
|
|
if !dec.Slots.HasFn {
|
|
t.Error("Slots.HasFn should still be true")
|
|
}
|
|
}
|
|
|
|
// --- ResolveActionCandidate from CapabilitySelection tests ---
|
|
|
|
// TestResolveActionCandidate_CapabilitySelectionSource pins that a resolved
|
|
// CapabilitySelection produces a route-sourced candidate.
|
|
func TestResolveActionCandidate_CapabilitySelectionSource(t *testing.T) {
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
CapabilitySelection: CapabilitySelection{
|
|
Fn: "restart", Args: []string{"nginx"}, Resolved: true,
|
|
Method: ActionResolutionExtractorRaw,
|
|
},
|
|
Producer: RouteProducerClassifier,
|
|
}
|
|
c := ResolveActionCandidate(dec, nil)
|
|
if !c.ActionResolved() {
|
|
t.Fatal("expected resolved candidate")
|
|
}
|
|
if c.Fn != "restart" {
|
|
t.Errorf("Fn = %q, want restart", c.Fn)
|
|
}
|
|
if c.Source != ActionSourceRoute {
|
|
t.Errorf("Source = %q, want route", c.Source)
|
|
}
|
|
if c.ResolvedBy != ActionResolutionExtractorRaw {
|
|
t.Errorf("ResolvedBy = %q, want extractor_raw", c.ResolvedBy)
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_CapabilitySelectionOverSlots pins that
|
|
// CapabilitySelection takes precedence over Slots.HasFn when both are set.
|
|
func TestResolveActionCandidate_CapabilitySelectionOverSlots(t *testing.T) {
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{
|
|
Fn: "old_fn", HasFn: true,
|
|
ResolvedBy: ActionResolutionExtractorRaw,
|
|
},
|
|
CapabilitySelection: CapabilitySelection{
|
|
Fn: "new_fn", Resolved: true,
|
|
Method: ActionResolutionExtractorLLMText,
|
|
},
|
|
}
|
|
c := ResolveActionCandidate(dec, nil)
|
|
if c.Fn != "new_fn" {
|
|
t.Errorf("Fn = %q, want new_fn (CapabilitySelection wins)", c.Fn)
|
|
}
|
|
if c.ResolvedBy != ActionResolutionExtractorLLMText {
|
|
t.Errorf("ResolvedBy = %q, want extractor_llm_text", c.ResolvedBy)
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_BackwardCompatSlotsHasFn pins that decisions
|
|
// with Slots.HasFn but no CapabilitySelection still work (backward compat).
|
|
func TestResolveActionCandidate_BackwardCompatSlotsHasFn(t *testing.T) {
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{
|
|
Fn: "restart", Args: []string{"nginx"}, HasFn: true,
|
|
ResolvedBy: ActionResolutionGrammarFixed,
|
|
},
|
|
}
|
|
c := ResolveActionCandidate(dec, nil)
|
|
if !c.ActionResolved() {
|
|
t.Fatal("expected resolved candidate from backward compat")
|
|
}
|
|
if c.Fn != "restart" {
|
|
t.Errorf("Fn = %q, want restart", c.Fn)
|
|
}
|
|
if c.ResolvedBy != ActionResolutionGrammarFixed {
|
|
t.Errorf("ResolvedBy = %q, want grammar_fixed", c.ResolvedBy)
|
|
}
|
|
}
|
|
|
|
// --- Router integration: CapabilitySelection populated by Route ---
|
|
|
|
// TestRouterRoute_CapabilitySelectionPopulated pins that Router.Route sets
|
|
// CapabilitySelection on the returned Decision for each cascade path.
|
|
func TestRouterRoute_CapabilitySelectionPopulated(t *testing.T) {
|
|
r := newTestRouter(t, 0.3)
|
|
|
|
// Stage-0 grammar path: wakeword-act.
|
|
d, err := r.Route(t.Context(), "maven restart nginx", refNow())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !d.CapabilitySelection.Resolved {
|
|
t.Error("stage-0: CapabilitySelection not resolved")
|
|
}
|
|
if d.CapabilitySelection.Fn != "restart" {
|
|
t.Errorf("stage-0: Fn = %q, want restart", d.CapabilitySelection.Fn)
|
|
}
|
|
if d.CapabilitySelection.InputKind != SelectionDeterministic {
|
|
t.Errorf("stage-0: InputKind = %q, want deterministic", d.CapabilitySelection.InputKind)
|
|
}
|
|
|
|
// Classifier path: raw utterance matches allowlist.
|
|
d, err = r.Route(t.Context(), "restart nginx", refNow())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if d.Intent != IntentAct {
|
|
t.Skipf("classifier routed to %q, not act", d.Intent)
|
|
}
|
|
if !d.CapabilitySelection.Resolved {
|
|
t.Error("classifier: CapabilitySelection not resolved")
|
|
}
|
|
if d.CapabilitySelection.Fn != "restart" {
|
|
t.Errorf("classifier: Fn = %q, want restart", d.CapabilitySelection.Fn)
|
|
}
|
|
}
|
|
|
|
// TestRouterRoute_ClassifierUnresolvedAct pins that an act intent where the
|
|
// raw utterance does not match the allowlist has an unresolved selection.
|
|
func TestRouterRoute_ClassifierUnresolvedAct(t *testing.T) {
|
|
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
|
|
emb := NewHashEmbedder(1024)
|
|
c := NewClassifier(emb)
|
|
seedClassifier(t, c)
|
|
ex := Extractor{Acts: m}
|
|
r := New(Config{
|
|
Classifier: c,
|
|
Extractor: ex,
|
|
Threshold: 0.3,
|
|
})
|
|
|
|
d, err := r.Route(t.Context(), "deploy the thing", refNow())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if d.Intent != IntentAct {
|
|
t.Skipf("classifier routed to %q, not act", d.Intent)
|
|
}
|
|
if d.CapabilitySelection.Resolved {
|
|
t.Error("expected unresolved selection for non-matching utterance")
|
|
}
|
|
}
|
|
|
|
// --- SelectionInputKind constants ---
|
|
|
|
// TestSelectionInputKindConstants pins that the three input kind constants
|
|
// are distinct and non-empty.
|
|
func TestSelectionInputKindConstants(t *testing.T) {
|
|
kinds := []SelectionInputKind{SelectionRaw, SelectionLLMText, SelectionDeterministic}
|
|
seen := make(map[SelectionInputKind]bool)
|
|
for _, k := range kinds {
|
|
if k == "" {
|
|
t.Error("SelectionInputKind constant is empty")
|
|
}
|
|
if seen[k] {
|
|
t.Errorf("SelectionInputKind %q appears twice", k)
|
|
}
|
|
seen[k] = true
|
|
}
|
|
}
|