router: migrate refusesCommand and ActHasEntityTarget to CapabilitySelection (slice 6c)
Migrate the two remaining action-routing consumers from compatibility Decision.Slings fields to authoritative Decision.CapabilitySelection: - refusesCommand: reads CapabilitySelection.Fn instead of Slots.Fn - ActHasEntityTarget: reads CapabilitySelection.Resolved and CapabilitySelection.Args instead of Slots.HasFn and Slots.Args Slots.Text remains the source for entity text when positional args do not contain the target (unchanged). Regression tests prove: - prohibited sentinel preserved byte-for-byte through SelectCapability - blanked Slots.Fn/Args/HasFn do not affect migrated consumers - Praxis/Hexis entity-target routing unchanged - stage-0 deterministic act unchanged - classifier/extractor act unchanged do not remove the compatibility mirrors yet.
This commit is contained in:
@@ -45,5 +45,5 @@ func (h *reactiveHandler) resolveCommandProhibition(ctx context.Context, text st
|
||||
// The sentinel cannot be renamed into an enabled function, and the original
|
||||
// utterance remains the authority even when a model rewrites Slots.Text.
|
||||
func refusesCommand(dec router.Decision) bool {
|
||||
return dec.Slots.Fn == router.ProhibitedActFn || router.IsCommandProhibition(dec.Utterance)
|
||||
return dec.CapabilitySelection.Fn == router.ProhibitedActFn || router.IsCommandProhibition(dec.Utterance)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestRefusesCommandUsesCapabilitySelection proves that refusesCommand reads
|
||||
// CapabilitySelection.Fn rather than the compatibility Slots.Fn. When
|
||||
// CapabilitySelection is populated with the prohibited sentinel but Slots.Fn
|
||||
// is blank, the refusal must still fire.
|
||||
func TestRefusesCommandUsesCapabilitySelection(t *testing.T) {
|
||||
dec := router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
CapabilitySelection: router.CapabilitySelection{
|
||||
Fn: router.ProhibitedActFn,
|
||||
Resolved: true,
|
||||
},
|
||||
// Slots compatibility fields deliberately blank.
|
||||
}
|
||||
if !refusesCommand(dec) {
|
||||
t.Fatal("refusesCommand should return true when CapabilitySelection.Fn == ProhibitedActFn")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefusesCommandUtteranceFallback proves that the utterance-based
|
||||
// prohibition check still works as a defense-in-depth belt when
|
||||
// CapabilitySelection does not carry the sentinel.
|
||||
func TestRefusesCommandUtteranceFallback(t *testing.T) {
|
||||
dec := router.Decision{
|
||||
Utterance: "don't restart nginx",
|
||||
}
|
||||
if !refusesCommand(dec) {
|
||||
t.Fatal("refusesCommand should return true for a prohibited utterance")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefusesCommandNonProhibitedCapability proves that an act with a
|
||||
// non-prohibited capability is NOT refused even when Slots.Fn happens to
|
||||
// carry the prohibited sentinel (cross-contamination).
|
||||
func TestRefusesCommandNonProhibitedCapability(t *testing.T) {
|
||||
dec := router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "restart nginx",
|
||||
CapabilitySelection: router.CapabilitySelection{
|
||||
Fn: "restart",
|
||||
Resolved: true,
|
||||
},
|
||||
}
|
||||
if refusesCommand(dec) {
|
||||
t.Fatal("refusesCommand should return false for a non-prohibited capability")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefusesCommandProhibitedSentinelPreservedThroughPipeline proves that the
|
||||
// command-prohibition grammar sentinel survives through SelectCapability into
|
||||
// CapabilitySelection byte-for-byte.
|
||||
func TestRefusesCommandProhibitedSentinelPreservedThroughPipeline(t *testing.T) {
|
||||
dec := router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{
|
||||
Fn: router.ProhibitedActFn,
|
||||
HasFn: true,
|
||||
},
|
||||
}
|
||||
sel := router.SelectCapability(dec, nil)
|
||||
dec.CapabilitySelection = sel
|
||||
|
||||
if dec.CapabilitySelection.Fn != router.ProhibitedActFn {
|
||||
t.Errorf("CapabilitySelection.Fn = %q, want %q", dec.CapabilitySelection.Fn, router.ProhibitedActFn)
|
||||
}
|
||||
if !dec.CapabilitySelection.Resolved {
|
||||
t.Error("CapabilitySelection.Resolved should be true")
|
||||
}
|
||||
if !refusesCommand(dec) {
|
||||
t.Fatal("refusesCommand should return true after pipeline preserves the sentinel")
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,12 @@ func ActHasEntityTarget(decision Decision) bool {
|
||||
if decision.Intent != IntentAct {
|
||||
return false
|
||||
}
|
||||
if decision.Slots.HasFn {
|
||||
if len(decision.Slots.Args) > 0 {
|
||||
return hasNamedEntityToken(decision.Slots.Args)
|
||||
if decision.CapabilitySelection.Resolved {
|
||||
if len(decision.CapabilitySelection.Args) > 0 {
|
||||
return hasNamedEntityToken(decision.CapabilitySelection.Args)
|
||||
}
|
||||
// A resident-model act may name the accepted verb and its target in
|
||||
// Text while leaving Args empty. HasFn establishes that the first token
|
||||
// Text while leaving Args empty. Resolved establishes that the first token
|
||||
// is the operation; only a meaningful tail can establish the entity.
|
||||
tokens := planTokens(decision.Slots.Text)
|
||||
return len(tokens) > 1 && hasNamedEntityToken(tokens[1:])
|
||||
|
||||
@@ -10,37 +10,42 @@ func TestActHasEntityTargetRequiresNamedTargetEvidence(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "matched function and argument",
|
||||
dec: Decision{Intent: IntentAct, Slots: Slots{
|
||||
Fn: "restart", HasFn: true, Args: []string{"nginx"}, Text: "restart nginx",
|
||||
}},
|
||||
dec: Decision{Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{Fn: "restart", Resolved: true, Args: []string{"nginx"}},
|
||||
Slots: Slots{Text: "restart nginx"},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "model function and target text",
|
||||
dec: Decision{Intent: IntentAct, Slots: Slots{
|
||||
Fn: "restart", HasFn: true, Text: "перезапусти гитею",
|
||||
}},
|
||||
dec: Decision{Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{Fn: "restart", Resolved: true},
|
||||
Slots: Slots{Text: "перезапусти гитею"},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "matched function alone",
|
||||
dec: Decision{Intent: IntentAct, Slots: Slots{
|
||||
Fn: "выключи", HasFn: true, Text: "выключи",
|
||||
}},
|
||||
dec: Decision{Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{Fn: "выключи", Resolved: true},
|
||||
Slots: Slots{Text: "выключи"},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "matched function with politeness only",
|
||||
dec: Decision{Intent: IntentAct, Slots: Slots{
|
||||
Fn: "выключи", HasFn: true, Args: []string{"пожалуйста"}, Text: "выключи пожалуйста",
|
||||
}},
|
||||
dec: Decision{Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{Fn: "выключи", Resolved: true, Args: []string{"пожалуйста"}},
|
||||
Slots: Slots{Text: "выключи пожалуйста"},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "matched function with anaphora only",
|
||||
dec: Decision{Intent: IntentAct, Slots: Slots{
|
||||
Fn: "выключи", HasFn: true, Args: []string{"его"}, Text: "выключи его",
|
||||
}},
|
||||
dec: Decision{Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{Fn: "выключи", Resolved: true, Args: []string{"его"}},
|
||||
Slots: Slots{Text: "выключи его"},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
@@ -67,3 +72,150 @@ func TestActHasEntityTargetRequiresNamedTargetEvidence(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestActHasEntityTarget_BlankedSlotsStillWorks proves that ActHasEntityTarget
|
||||
// reads CapabilitySelection.Resolved and CapabilitySelection.Args, not the
|
||||
// compatibility Slots.Fn/Args/HasFn. When Slots fields are blank but
|
||||
// CapabilitySelection is populated, behavior must remain correct.
|
||||
func TestActHasEntityTarget_BlankedSlotsStillWorks(t *testing.T) {
|
||||
// Resolved capability with entity in Args, but Slots.Fn/Args/HasFn are blank.
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: "restart",
|
||||
Resolved: true,
|
||||
Args: []string{"nginx"},
|
||||
},
|
||||
Slots: Slots{Text: "restart nginx"},
|
||||
}
|
||||
if !ActHasEntityTarget(dec) {
|
||||
t.Fatal("should detect entity in CapabilitySelection.Args even with blank Slots")
|
||||
}
|
||||
|
||||
// Resolved capability with entity in Text (no Args), Slots.Fn/HasFn blank.
|
||||
dec2 := Decision{
|
||||
Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: "restart",
|
||||
Resolved: true,
|
||||
},
|
||||
Slots: Slots{Text: "перезапусти гитею"},
|
||||
}
|
||||
if !ActHasEntityTarget(dec2) {
|
||||
t.Fatal("should detect entity in Slots.Text via CapabilitySelection.Resolved branch")
|
||||
}
|
||||
|
||||
// Unresolved capability with Slots.Fn set (simulating stale compat state).
|
||||
// Must NOT enter the resolved branch.
|
||||
dec3 := Decision{
|
||||
Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Resolved: false,
|
||||
},
|
||||
Slots: Slots{Fn: "restart", HasFn: true, Args: []string{"nginx"}, Text: "restart nginx"},
|
||||
}
|
||||
if !ActHasEntityTarget(dec3) {
|
||||
t.Fatal("unresolved capability with stale Slots should still find entity via text fallback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestActHasEntityTarget_PraxisHexisRoutingUnchanged proves that the Praxis
|
||||
// and Hexis entity-target routing semantics are unchanged by the migration.
|
||||
func TestActHasEntityTarget_PraxisHexisRoutingUnchanged(t *testing.T) {
|
||||
// Praxis act: resolved capability with entity target.
|
||||
praxis := Decision{
|
||||
Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: "entity_attention",
|
||||
Resolved: true,
|
||||
},
|
||||
Slots: Slots{Text: "что с muzick"},
|
||||
}
|
||||
if !ActHasEntityTarget(praxis) {
|
||||
t.Fatal("Praxis entity act should have entity target")
|
||||
}
|
||||
|
||||
// Hexis act: unresolved capability, entity in text.
|
||||
hexis := Decision{
|
||||
Intent: IntentAct,
|
||||
Slots: Slots{Text: "перезапусти muzick indexer"},
|
||||
}
|
||||
if !ActHasEntityTarget(hexis) {
|
||||
t.Fatal("Hexis entity act should have entity target")
|
||||
}
|
||||
}
|
||||
|
||||
// TestActHasEntityTarget_Stage0DeterministicActUnchanged proves that a
|
||||
// stage-0 grammar-fixed act (like command prohibition) is handled correctly.
|
||||
func TestActHasEntityTarget_Stage0DeterministicActUnchanged(t *testing.T) {
|
||||
// Prohibited act: resolved capability, no entity in Args or Text.
|
||||
// "вот это" — filler particle + demonstrative, no named entity.
|
||||
prohibited := Decision{
|
||||
Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: ProhibitedActFn,
|
||||
Resolved: true,
|
||||
},
|
||||
Slots: Slots{Text: "вот это"},
|
||||
}
|
||||
if ActHasEntityTarget(prohibited) {
|
||||
t.Fatal("prohibited act with demonstrative-only tail should not have entity target")
|
||||
}
|
||||
|
||||
// Prohibited act WITH entity text still reports the entity — the function
|
||||
// checks entity presence, not prohibition status.
|
||||
prohibitedWithEntity := Decision{
|
||||
Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: ProhibitedActFn,
|
||||
Resolved: true,
|
||||
},
|
||||
Slots: Slots{Text: "don't restart nginx"},
|
||||
}
|
||||
if !ActHasEntityTarget(prohibitedWithEntity) {
|
||||
t.Fatal("prohibited act with entity text should still report entity target")
|
||||
}
|
||||
|
||||
// Task status act: resolved capability, no entity.
|
||||
taskStatus := Decision{
|
||||
Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: TaskStatusFn,
|
||||
Resolved: true,
|
||||
},
|
||||
Slots: Slots{Text: "задачи"},
|
||||
}
|
||||
if ActHasEntityTarget(taskStatus) {
|
||||
t.Fatal("task status act should not have entity target")
|
||||
}
|
||||
}
|
||||
|
||||
// TestActHasEntityTarget_ClassifierExtractorActUnchanged proves that the
|
||||
// classifier/extractor act path is unchanged.
|
||||
func TestActHasEntityTarget_ClassifierExtractorActUnchanged(t *testing.T) {
|
||||
// Classifier resolved act with entity in Args.
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Fn: "restart",
|
||||
Resolved: true,
|
||||
Args: []string{"nginx"},
|
||||
},
|
||||
Slots: Slots{Text: "restart nginx"},
|
||||
}
|
||||
if !ActHasEntityTarget(dec) {
|
||||
t.Fatal("classifier resolved act with entity in Args should have entity target")
|
||||
}
|
||||
|
||||
// Classifier unresolved act: entity in text.
|
||||
dec2 := Decision{
|
||||
Intent: IntentAct,
|
||||
CapabilitySelection: CapabilitySelection{
|
||||
Resolved: false,
|
||||
},
|
||||
Slots: Slots{Text: "перезапусти muzick"},
|
||||
}
|
||||
if !ActHasEntityTarget(dec2) {
|
||||
t.Fatal("classifier unresolved act with entity in text should have entity target")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user