router: introduce CapabilitySelection type and SelectCapability stage (slice 6b)
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.
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
package router
|
||||
|
||||
// CapabilitySelection — the result of the explicit capability-selection stage.
|
||||
// It says what executable capability matched, separate from what kind of turn
|
||||
// this is (RouteDecision/Decision) and separate from the downstream action
|
||||
// artifact (ActionCandidate).
|
||||
//
|
||||
// CapabilitySelection is the authoritative record of which exact function was
|
||||
// selected and by which component. Decision.Slots.Fn/Args/HasFn remain as
|
||||
// compatibility representations that are populated FROM the selection; later
|
||||
// action execution must not depend on those compatibility fields.
|
||||
type CapabilitySelection struct {
|
||||
// Fn — the resolved function/tool identity. Empty when no capability
|
||||
// matched the input.
|
||||
Fn string
|
||||
|
||||
// Args — positional arguments passed to the tool. May be nil when Fn
|
||||
// is empty or when the match produced no arguments.
|
||||
Args []string
|
||||
|
||||
// Resolved — whether a capability was matched. Fn may be non-empty
|
||||
// even when Resolved is false (grammar-fixed paths set Fn without
|
||||
// going through the general selector). This field distinguishes "the
|
||||
// selector ran and matched" from "a deterministic path set Fn".
|
||||
Resolved bool
|
||||
|
||||
// Method — which component actually selected the function. Five
|
||||
// disjoint values from ActionResolutionMethod; empty when no function
|
||||
// was resolved.
|
||||
Method ActionResolutionMethod
|
||||
|
||||
// InputKind — what the selector selected against. Distinguishes the
|
||||
// raw utterance from LLM-cleaned text, so the observability trace
|
||||
// can name the exact input the matcher saw.
|
||||
InputKind SelectionInputKind
|
||||
|
||||
// Producer — which cascade stage produced the routing decision that
|
||||
// led here. Carried for observability; not used for dispatch.
|
||||
Producer RouteProducer
|
||||
|
||||
// Confidence — the routing confidence from the decision. Carried for
|
||||
// observability; not used for dispatch.
|
||||
Confidence float64
|
||||
}
|
||||
|
||||
// SelectionInputKind — what the selector selected against. Three disjoint
|
||||
// values.
|
||||
type SelectionInputKind string
|
||||
|
||||
const (
|
||||
// SelectionRaw — the selector matched against the original raw
|
||||
// utterance from the user.
|
||||
SelectionRaw SelectionInputKind = "raw"
|
||||
|
||||
// SelectionLLMText — the selector matched against LLM-normalized or
|
||||
// cleaned action text from Slots.Text, which may differ from the raw
|
||||
// utterance.
|
||||
SelectionLLMText SelectionInputKind = "llm_text"
|
||||
|
||||
// SelectionDeterministic — the selector was bypassed because a
|
||||
// deterministic grammar already resolved the function. The selector
|
||||
// did not run; this records that the bypass happened.
|
||||
SelectionDeterministic SelectionInputKind = "deterministic"
|
||||
)
|
||||
|
||||
// applyCapabilityToSlots propagates the CapabilitySelection result into the
|
||||
// Decision.Slots compatibility fields. This preserves backward compatibility
|
||||
// for code that still reads Slots.Fn/Args/HasFn, while CapabilitySelection
|
||||
// remains the authoritative record. Later action execution must read the
|
||||
// candidate produced from CapabilitySelection, not these compatibility fields.
|
||||
func applyCapabilityToSlots(dec *Decision, sel CapabilitySelection) {
|
||||
dec.CapabilitySelection = sel
|
||||
if sel.Resolved {
|
||||
dec.Slots.Fn = sel.Fn
|
||||
dec.Slots.Args = sel.Args
|
||||
dec.Slots.HasFn = true
|
||||
dec.Slots.ResolvedBy = sel.Method
|
||||
}
|
||||
}
|
||||
|
||||
// SelectCapability is the explicit capability-selection stage. It sits between
|
||||
// route resolution and action candidate production, answering: which exact
|
||||
// executable capability matched this turn?
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. HasFn already set (grammar-fixed or extractor raw): bypass the general
|
||||
// selector. CapabilitySelection records the existing result with
|
||||
// InputKind=SelectionDeterministic.
|
||||
// 2. Raw utterance not matching the allowlist, but LLM cleaned text available:
|
||||
// try Acts.Match(LLM text). This is the extractor_llm_text path.
|
||||
// 3. No match on either input: unresolved.
|
||||
//
|
||||
// The matcher algorithm, enabled-tool set, alias behavior, fuzzy-prefix
|
||||
// behavior, and ordering are all unchanged — SelectCapability delegates to
|
||||
// the same Acts.Match call that Extract and ResolveActionCandidate always used.
|
||||
func SelectCapability(dec Decision, m ActMatcher) CapabilitySelection {
|
||||
// Non-act intents have no capability to select.
|
||||
if dec.Intent != IntentAct {
|
||||
return CapabilitySelection{
|
||||
Producer: dec.Producer,
|
||||
Confidence: dec.Confidence,
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic path: a grammar or the raw extractor already resolved
|
||||
// the function. The general selector does not re-run.
|
||||
if dec.Slots.HasFn {
|
||||
return CapabilitySelection{
|
||||
Fn: dec.Slots.Fn,
|
||||
Args: dec.Slots.Args,
|
||||
Resolved: true,
|
||||
Method: dec.Slots.ResolvedBy,
|
||||
InputKind: SelectionDeterministic,
|
||||
Producer: dec.Producer,
|
||||
Confidence: dec.Confidence,
|
||||
}
|
||||
}
|
||||
|
||||
// General path: the raw utterance did not match. Try the LLM-cleaned
|
||||
// text when it differs from the raw utterance.
|
||||
if m != nil && dec.Slots.Text != "" && dec.Slots.Text != dec.Utterance {
|
||||
if fn, args, ok := m.Match(dec.Slots.Text); ok {
|
||||
return CapabilitySelection{
|
||||
Fn: fn,
|
||||
Args: args,
|
||||
Resolved: true,
|
||||
Method: ActionResolutionExtractorLLMText,
|
||||
InputKind: SelectionLLMText,
|
||||
Producer: dec.Producer,
|
||||
Confidence: dec.Confidence,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unresolved: no capability matched on any input.
|
||||
return CapabilitySelection{
|
||||
Producer: dec.Producer,
|
||||
Confidence: dec.Confidence,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user