diff --git a/cmd/mavend/command_prohibition.go b/cmd/mavend/command_prohibition.go index 339fdd6..51acd57 100644 --- a/cmd/mavend/command_prohibition.go +++ b/cmd/mavend/command_prohibition.go @@ -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) } diff --git a/cmd/mavend/command_prohibition_test.go b/cmd/mavend/command_prohibition_test.go new file mode 100644 index 0000000..7d5104a --- /dev/null +++ b/cmd/mavend/command_prohibition_test.go @@ -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") + } +} diff --git a/internal/router/acttarget.go b/internal/router/acttarget.go index 7982944..37a81d5 100644 --- a/internal/router/acttarget.go +++ b/internal/router/acttarget.go @@ -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:]) diff --git a/internal/router/acttarget_test.go b/internal/router/acttarget_test.go index 728bb51..6a1e1bd 100644 --- a/internal/router/acttarget_test.go +++ b/internal/router/acttarget_test.go @@ -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") + } +}