064747f192
Introduce the typed boundary between routing and action resolution: - ActionCandidate: Fn, Args, Source (route|matcher), Producer, Confidence - ResolveActionCandidate(dec, m): standalone function usable by both the daemon and the eval harness - Update eval harness Reach() to use ResolveActionCandidate instead of duplicating the matcher fallback logic This is the routing-side half of the action-resolution boundary. The daemon integration follows in the next commit.
152 lines
4.3 KiB
Go
152 lines
4.3 KiB
Go
package router
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
// TestResolveActionCandidate_RouteSource pins that an act with HasFn=true
|
|
// produces a candidate from the route, not the matcher.
|
|
func TestResolveActionCandidate_RouteSource(t *testing.T) {
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
|
}
|
|
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 len(c.Args) != 1 || c.Args[0] != "nginx" {
|
|
t.Errorf("Args = %v, want [nginx]", c.Args)
|
|
}
|
|
if c.Source != ActionSourceRoute {
|
|
t.Errorf("Source = %q, want route", c.Source)
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_MatcherSource pins that an act without Fn
|
|
// invokes the matcher and produces a candidate from it.
|
|
func TestResolveActionCandidate_MatcherSource(t *testing.T) {
|
|
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{Text: "restart nginx"},
|
|
}
|
|
c := ResolveActionCandidate(dec, m)
|
|
if !c.ActionResolved() {
|
|
t.Fatal("expected resolved candidate")
|
|
}
|
|
if c.Fn != "restart" {
|
|
t.Errorf("Fn = %q, want restart", c.Fn)
|
|
}
|
|
if c.Source != ActionSourceMatcher {
|
|
t.Errorf("Source = %q, want matcher", c.Source)
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_MatcherMiss pins that a matcher miss produces
|
|
// an unresolved candidate.
|
|
func TestResolveActionCandidate_MatcherMiss(t *testing.T) {
|
|
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{Text: "deploy the thing"},
|
|
}
|
|
c := ResolveActionCandidate(dec, m)
|
|
if c.ActionResolved() {
|
|
t.Fatal("expected unresolved candidate")
|
|
}
|
|
if c.Fn != "" {
|
|
t.Errorf("Fn = %q, want empty", c.Fn)
|
|
}
|
|
if c.Source != "" {
|
|
t.Errorf("Source = %q, want empty", c.Source)
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_NonAct pins that a non-act decision produces
|
|
// an empty candidate.
|
|
func TestResolveActionCandidate_NonAct(t *testing.T) {
|
|
dec := Decision{
|
|
Intent: IntentFact,
|
|
Slots: Slots{Key: "water", Value: "drank", HasKey: true},
|
|
}
|
|
c := ResolveActionCandidate(dec, nil)
|
|
if c.ActionResolved() {
|
|
t.Fatal("expected unresolved candidate for non-act")
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_Stage0Match pins that a stage-0 act (which
|
|
// sets HasFn=true) produces a route-sourced candidate.
|
|
func TestResolveActionCandidate_Stage0Match(t *testing.T) {
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Stage: 0,
|
|
Confidence: 1.0,
|
|
Slots: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
|
Producer: RouteProducerGrammar,
|
|
}
|
|
c := ResolveActionCandidate(dec, nil)
|
|
if !c.ActionResolved() {
|
|
t.Fatal("expected resolved candidate")
|
|
}
|
|
if c.Source != ActionSourceRoute {
|
|
t.Errorf("Source = %q, want route", c.Source)
|
|
}
|
|
if c.Producer != RouteProducerGrammar {
|
|
t.Errorf("Producer = %q, want grammar", c.Producer)
|
|
}
|
|
if c.Confidence != 1.0 {
|
|
t.Errorf("Confidence = %f, want 1.0", c.Confidence)
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_LearnedRouterNoFn pins that a learned-router
|
|
// act without Fn falls through to the matcher.
|
|
func TestResolveActionCandidate_LearnedRouterNoFn(t *testing.T) {
|
|
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Stage: 1,
|
|
Confidence: 0.85,
|
|
Slots: Slots{Text: "restart the server"},
|
|
Producer: RouteProducerLLM,
|
|
}
|
|
c := ResolveActionCandidate(dec, m)
|
|
if !c.ActionResolved() {
|
|
t.Fatal("expected resolved candidate from matcher fallback")
|
|
}
|
|
if c.Fn != "restart" {
|
|
t.Errorf("Fn = %q, want restart", c.Fn)
|
|
}
|
|
if c.Source != ActionSourceMatcher {
|
|
t.Errorf("Source = %q, want matcher", c.Source)
|
|
}
|
|
}
|
|
|
|
// TestResolveActionCandidate_AliasMatch pins that aliases resolve through
|
|
// the matcher path.
|
|
func TestResolveActionCandidate_AliasMatch(t *testing.T) {
|
|
m := DefaultActMatcher{
|
|
Fns: []string{"restart"},
|
|
Aliases: map[string][]string{"restart": {"перезагрузи"}},
|
|
}
|
|
dec := Decision{
|
|
Intent: IntentAct,
|
|
Slots: Slots{Text: "перезагрузи роутер"},
|
|
}
|
|
c := ResolveActionCandidate(dec, m)
|
|
if !c.ActionResolved() {
|
|
t.Fatal("expected resolved candidate from alias match")
|
|
}
|
|
if c.Fn != "restart" {
|
|
t.Errorf("Fn = %q, want restart", c.Fn)
|
|
}
|
|
if len(c.Args) != 1 || c.Args[0] != "роутер" {
|
|
t.Errorf("Args = %v, want [роутер]", c.Args)
|
|
}
|
|
}
|