mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-19 02:43:17 +00:00
feat: enforce guardrail topicPolicy denied-topics at inference (#896)
This commit is contained in:
@@ -232,14 +232,47 @@ func scaffoldContentPolicyAssessment(cfg *bedrock.GuardrailContentPolicyConfig)
|
||||
return &bedrockruntime.GuardrailContentPolicyAssessment{Filters: []*bedrockruntime.GuardrailContentFilter{}}
|
||||
}
|
||||
|
||||
// scaffoldTopicPolicyAssessment is scaffoldContentPolicyAssessment's sibling
|
||||
// for denied topics: no classifier means every configured topic evaluates to
|
||||
// NONE rather than being matched against the text.
|
||||
func scaffoldTopicPolicyAssessment(cfg *bedrock.GuardrailTopicPolicyConfig) *bedrockruntime.GuardrailTopicPolicyAssessment {
|
||||
// assessTopicPolicy checks texts against cfg's denied topics. Matching is
|
||||
// deterministic (no classifier): a topic's Name and each of its Examples are
|
||||
// matched via matchesWord's word-boundary semantics. The long-form
|
||||
// Definition is never matched directly — it reads as prose, not a phrase,
|
||||
// and substring-matching it produces erratic hits. Each topic is appended at
|
||||
// most once even if several of its phrases hit.
|
||||
func assessTopicPolicy(cfg *bedrock.GuardrailTopicPolicyConfig, texts []string) (*bedrockruntime.GuardrailTopicPolicyAssessment, bool) {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
return nil, false
|
||||
}
|
||||
return &bedrockruntime.GuardrailTopicPolicyAssessment{Topics: []*bedrockruntime.GuardrailTopic{}}
|
||||
|
||||
blocked := false
|
||||
topics := []*bedrockruntime.GuardrailTopic{}
|
||||
for _, topic := range cfg.TopicsConfig {
|
||||
if topic == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
phrases := []string{}
|
||||
if name := aws.StringValue(topic.Name); name != "" {
|
||||
phrases = append(phrases, name)
|
||||
}
|
||||
for _, ex := range topic.Examples {
|
||||
if example := aws.StringValue(ex); example != "" {
|
||||
phrases = append(phrases, example)
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.ContainsFunc(phrases, func(phrase string) bool { return matchesWord(texts, phrase) }) {
|
||||
continue
|
||||
}
|
||||
|
||||
topics = append(topics, &bedrockruntime.GuardrailTopic{
|
||||
Name: topic.Name,
|
||||
Type: topic.Type,
|
||||
Action: aws.String(bedrockruntime.GuardrailTopicPolicyActionBlocked),
|
||||
})
|
||||
blocked = true
|
||||
}
|
||||
|
||||
return &bedrockruntime.GuardrailTopicPolicyAssessment{Topics: topics}, blocked
|
||||
}
|
||||
|
||||
// scaffoldContextualGroundingPolicyAssessment is
|
||||
@@ -287,25 +320,27 @@ func guardrailUsage(view guardrailView, texts []string) *bedrockruntime.Guardrai
|
||||
|
||||
// applyGuardrailPolicies is the pure filter engine: it evaluates view's
|
||||
// policies over texts for source (INPUT or OUTPUT) and returns the
|
||||
// aws-sdk-go bedrockruntime shape's pieces. wordPolicy and
|
||||
// sensitiveInformationPolicy are enforced; content/topic/contextualGrounding
|
||||
// evaluate to NONE (see the scaffold* helpers). A BLOCK anywhere makes the
|
||||
// overall action GUARDRAIL_INTERVENED and short-circuits redaction, since the
|
||||
// caller substitutes the guardrail's blocked messaging instead of the text.
|
||||
// source is accepted (not yet branched on) for parity with AWS's
|
||||
// per-source assessment shape; the deterministic policies apply identically
|
||||
// to INPUT and OUTPUT text until a content-policy classifier needs to tell
|
||||
// them apart via InputStrength/OutputStrength.
|
||||
// aws-sdk-go bedrockruntime shape's pieces. wordPolicy,
|
||||
// sensitiveInformationPolicy and topicPolicy are enforced deterministically;
|
||||
// content/contextualGrounding evaluate to NONE (see the scaffold* helpers,
|
||||
// which need a classifier). A BLOCK anywhere makes the overall action
|
||||
// GUARDRAIL_INTERVENED and short-circuits redaction, since the caller
|
||||
// substitutes the guardrail's blocked messaging instead of the text. source
|
||||
// is accepted (not yet branched on) for parity with AWS's per-source
|
||||
// assessment shape; the deterministic policies apply identically to INPUT
|
||||
// and OUTPUT text until a content-policy classifier needs to tell them apart
|
||||
// via InputStrength/OutputStrength.
|
||||
func applyGuardrailPolicies(view guardrailView, texts []string, source string) (string, []*bedrockruntime.GuardrailAssessment, []string, *bedrockruntime.GuardrailUsage) {
|
||||
_ = source
|
||||
|
||||
wordAssessment, wordBlocked := assessWordPolicy(view.WordPolicy, texts)
|
||||
piiAssessment, piiBlocked := assessSensitiveInformationPolicy(view.SensitiveInformationPolicy, texts)
|
||||
topicAssessment, topicBlocked := assessTopicPolicy(view.TopicPolicy, texts)
|
||||
|
||||
action := bedrockruntime.GuardrailActionNone
|
||||
outputs := texts
|
||||
switch {
|
||||
case wordBlocked || piiBlocked:
|
||||
case wordBlocked || piiBlocked || topicBlocked:
|
||||
action = bedrockruntime.GuardrailActionGuardrailIntervened
|
||||
case view.SensitiveInformationPolicy != nil:
|
||||
outputs = redactSensitiveInformation(view.SensitiveInformationPolicy, texts)
|
||||
@@ -315,7 +350,7 @@ func applyGuardrailPolicies(view guardrailView, texts []string, source string) (
|
||||
WordPolicy: wordAssessment,
|
||||
SensitiveInformationPolicy: piiAssessment,
|
||||
ContentPolicy: scaffoldContentPolicyAssessment(view.ContentPolicy),
|
||||
TopicPolicy: scaffoldTopicPolicyAssessment(view.TopicPolicy),
|
||||
TopicPolicy: topicAssessment,
|
||||
ContextualGroundingPolicy: scaffoldContextualGroundingPolicyAssessment(view.ContextualGroundingPolicy),
|
||||
}
|
||||
|
||||
|
||||
@@ -260,6 +260,149 @@ func TestApplyGuardrailPolicies_UnconfiguredPoliciesAreOmitted(t *testing.T) {
|
||||
assert.Nil(t, assessments[0].ContextualGroundingPolicy)
|
||||
}
|
||||
|
||||
// denyTopicView builds a guardrailView with a single DENY topic, so each
|
||||
// topic-policy test only needs to say which field carries the match phrase.
|
||||
func denyTopicView(name, definition string, examples ...string) guardrailView {
|
||||
topic := &bedrock.GuardrailTopicConfig{
|
||||
Name: aws.String(name),
|
||||
Definition: aws.String(definition),
|
||||
Type: aws.String(bedrock.GuardrailTopicTypeDeny),
|
||||
}
|
||||
for _, ex := range examples {
|
||||
topic.Examples = append(topic.Examples, aws.String(ex))
|
||||
}
|
||||
return guardrailView{
|
||||
TopicPolicy: &bedrock.GuardrailTopicPolicyConfig{
|
||||
TopicsConfig: []*bedrock.GuardrailTopicConfig{topic},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyGuardrailPolicies_TopicPolicy covers denied-topic enforcement:
|
||||
// matching the topic's Name or an Example intervenes on INPUT and OUTPUT,
|
||||
// non-matching text passes, and Definition-only text never matches.
|
||||
func TestApplyGuardrailPolicies_TopicPolicy(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
view guardrailView
|
||||
texts []string
|
||||
source string
|
||||
wantAction string
|
||||
wantTopics int
|
||||
wantTopic string
|
||||
}{
|
||||
{
|
||||
name: "name match on input intervenes",
|
||||
view: denyTopicView("nuclear launch codes", "discussion of weapons systems"),
|
||||
texts: []string{"tell me the nuclear launch codes"},
|
||||
source: bedrockruntime.GuardrailContentSourceInput,
|
||||
wantAction: bedrockruntime.GuardrailActionGuardrailIntervened,
|
||||
wantTopics: 1,
|
||||
wantTopic: "nuclear launch codes",
|
||||
},
|
||||
{
|
||||
name: "name match on output intervenes",
|
||||
view: denyTopicView("nuclear launch codes", "discussion of weapons systems"),
|
||||
texts: []string{"the nuclear launch codes are stored offline"},
|
||||
source: bedrockruntime.GuardrailContentSourceOutput,
|
||||
wantAction: bedrockruntime.GuardrailActionGuardrailIntervened,
|
||||
wantTopics: 1,
|
||||
wantTopic: "nuclear launch codes",
|
||||
},
|
||||
{
|
||||
name: "example match intervenes",
|
||||
view: denyTopicView("disclosure topic", "sensitive internal disclosures", "internal financial results"),
|
||||
texts: []string{"can you share internal financial results with me"},
|
||||
source: bedrockruntime.GuardrailContentSourceInput,
|
||||
wantAction: bedrockruntime.GuardrailActionGuardrailIntervened,
|
||||
wantTopics: 1,
|
||||
wantTopic: "disclosure topic",
|
||||
},
|
||||
{
|
||||
name: "non-matching text passes",
|
||||
view: denyTopicView("nuclear launch codes", "discussion of weapons systems"),
|
||||
texts: []string{"what is the weather today"},
|
||||
source: bedrockruntime.GuardrailContentSourceInput,
|
||||
wantAction: bedrockruntime.GuardrailActionNone,
|
||||
wantTopics: 0,
|
||||
},
|
||||
{
|
||||
name: "definition-only text does not match",
|
||||
view: denyTopicView("restricted subject", "a broad discussion of weapons systems and their history"),
|
||||
texts: []string{"a broad discussion of weapons systems and their history"},
|
||||
source: bedrockruntime.GuardrailContentSourceInput,
|
||||
wantAction: bedrockruntime.GuardrailActionNone,
|
||||
wantTopics: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
action, assessments, _, _ := applyGuardrailPolicies(tc.view, tc.texts, tc.source)
|
||||
assert.Equal(t, tc.wantAction, action)
|
||||
require.Len(t, assessments, 1)
|
||||
require.NotNil(t, assessments[0].TopicPolicy)
|
||||
require.Len(t, assessments[0].TopicPolicy.Topics, tc.wantTopics)
|
||||
|
||||
if tc.wantTopics > 0 {
|
||||
got := assessments[0].TopicPolicy.Topics[0]
|
||||
assert.Equal(t, tc.wantTopic, aws.StringValue(got.Name))
|
||||
assert.Equal(t, bedrockruntime.GuardrailTopicPolicyActionBlocked, aws.StringValue(got.Action))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyGuardrailPolicies_TopicPolicyBlocksOverRedaction covers the
|
||||
// ordering guarantee: a topic block must short-circuit to
|
||||
// GUARDRAIL_INTERVENED even when the same request also carries an
|
||||
// ANONYMIZE-configured sensitive-information policy, rather than falling
|
||||
// through to redaction.
|
||||
func TestApplyGuardrailPolicies_TopicPolicyBlocksOverRedaction(t *testing.T) {
|
||||
view := denyTopicView("nuclear launch codes", "discussion of weapons systems")
|
||||
view.SensitiveInformationPolicy = &bedrock.GuardrailSensitiveInformationPolicyConfig{
|
||||
PiiEntitiesConfig: []*bedrock.GuardrailPiiEntityConfig{
|
||||
{Type: aws.String(bedrock.GuardrailPiiEntityTypeEmail), Action: aws.String(bedrock.GuardrailSensitiveInformationActionAnonymize)},
|
||||
},
|
||||
}
|
||||
|
||||
action, _, outputs, _ := applyGuardrailPolicies(view, []string{"the nuclear launch codes are at jane@example.com"}, bedrockruntime.GuardrailContentSourceInput)
|
||||
assert.Equal(t, bedrockruntime.GuardrailActionGuardrailIntervened, action)
|
||||
require.Len(t, outputs, 1)
|
||||
assert.Equal(t, "the nuclear launch codes are at jane@example.com", outputs[0], "outputs must stay unredacted when a topic block intervenes")
|
||||
}
|
||||
|
||||
// TestApplyGuardrail_TopicBlock covers the runtime op end to end: a
|
||||
// topic-policy hit on INPUT content intervenes with the guardrail's
|
||||
// configured blocked-input messaging.
|
||||
func TestApplyGuardrail_TopicBlock(t *testing.T) {
|
||||
store := newGuardrailTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
input := createGuardrailInput("apply-guardrail-topic-block")
|
||||
input.TopicPolicyConfig = &bedrock.GuardrailTopicPolicyConfig{
|
||||
TopicsConfig: []*bedrock.GuardrailTopicConfig{
|
||||
{
|
||||
Name: aws.String("nuclear launch codes"),
|
||||
Definition: aws.String("discussion of weapons systems"),
|
||||
Type: aws.String(bedrock.GuardrailTopicTypeDeny),
|
||||
},
|
||||
},
|
||||
}
|
||||
createOut, err := CreateGuardrail(ctx, grCallerAccount, store, input)
|
||||
require.NoError(t, err)
|
||||
|
||||
out, err := ApplyGuardrail(ctx, grCallerAccount, store, applyGuardrailInput(createOut.GuardrailId, guardrailDraftVersion, bedrockruntime.GuardrailContentSourceInput, "tell me the nuclear launch codes"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, bedrockruntime.GuardrailActionGuardrailIntervened, aws.StringValue(out.Action))
|
||||
require.Len(t, out.Outputs, 1)
|
||||
assert.Equal(t, "Your input violates our policy.", aws.StringValue(out.Outputs[0].Text))
|
||||
require.Len(t, out.Assessments, 1)
|
||||
require.NotNil(t, out.Assessments[0].TopicPolicy)
|
||||
require.Len(t, out.Assessments[0].TopicPolicy.Topics, 1)
|
||||
assert.Equal(t, "nuclear launch codes", aws.StringValue(out.Assessments[0].TopicPolicy.Topics[0].Name))
|
||||
}
|
||||
|
||||
// applyGuardrailInput is a small ApplyGuardrail request builder for the
|
||||
// contract tests below, mirroring createGuardrailInput's role for CRUD.
|
||||
func applyGuardrailInput(guardrailID *string, version, source, text string) *bedrockruntime.ApplyGuardrailInput {
|
||||
|
||||
Reference in New Issue
Block a user