-
Notifications
You must be signed in to change notification settings - Fork 7
Maintenance: Refactor AI filter code #58
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
afcf19d
Move media item types to correct package
turt2live 20acf3d
Update package references
turt2live 6d4818f
Move event rendering to its own package
turt2live 9b487e8
Move AI Provider stuff to its own package
turt2live 9f382ac
Create a copy of the existing Omni AI provider
turt2live b8512fe
Move Instanced filter for AI filters to its own type
turt2live 2cd50b2
Replace OpenAI filter with a more specific "OpenAI Omni Moderation" one
turt2live ded6d03
Update dependencies
turt2live 3881b4c
Make the logs slightly less spammy with the library change
turt2live d2f7a1e
Update filter/filter_openai_omni_test.go
turt2live 52e441e
Move OpenAI moderation server to its own file
turt2live File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| package ai | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "errors" | ||
| "log" | ||
|
|
||
| "github.com/matrix-org/policyserv/config" | ||
| "github.com/matrix-org/policyserv/event" | ||
| "github.com/matrix-org/policyserv/filter/classification" | ||
| "github.com/openai/openai-go/v3" | ||
| "github.com/openai/openai-go/v3/option" | ||
| ) | ||
|
|
||
| type OpenAIOmniModerationConfig struct { | ||
| FailSecure bool | ||
| } | ||
|
|
||
| type OpenAIOmniModeration struct { | ||
| // Implements Provider[*OpenAIOmniModerationConfig] | ||
|
|
||
| client openai.Client | ||
| } | ||
|
|
||
| func NewOpenAIOmniModeration(cnf *config.InstanceConfig, additionalClientOptions ...option.RequestOption) (Provider[*OpenAIOmniModerationConfig], error) { | ||
| apiKey := cnf.OpenAIApiKey | ||
| if len(apiKey) == 0 { | ||
| return nil, errors.New("api key not set") | ||
| } | ||
| options := append([]option.RequestOption{option.WithAPIKey(apiKey)}, additionalClientOptions...) | ||
| client := openai.NewClient(options...) | ||
| return &OpenAIOmniModeration{ | ||
| client: client, | ||
| }, nil | ||
| } | ||
|
|
||
| func (m *OpenAIOmniModeration) CheckEvent(ctx context.Context, cnf *OpenAIOmniModerationConfig, input *Input) ([]classification.Classification, error) { | ||
| messages, err := event.RenderToText(input.Event) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| for _, message := range messages { | ||
| // Note: we don't want to log message contents in production | ||
| log.Printf("[%s | %s] Message sent by %s", input.Event.EventID(), input.Event.RoomID(), input.Event.SenderID()) | ||
| res, err := m.client.Moderations.New(ctx, openai.ModerationNewParams{ | ||
| Model: openai.ModerationModelOmniModerationLatest, | ||
| Input: openai.ModerationNewParamsInputUnion{ | ||
| OfString: openai.String(message), | ||
| }, | ||
| }) | ||
| if err != nil { | ||
| log.Printf("[%s | %s] Error checking message: %s", input.Event.EventID(), input.Event.RoomID(), err) | ||
| if cnf.FailSecure { | ||
| log.Printf("[%s | %s] Returning spam response to block events and discourage retries", input.Event.EventID(), input.Event.RoomID()) | ||
| return []classification.Classification{classification.Spam, classification.Frequency}, nil | ||
| } else { | ||
| log.Printf("[%s | %s] Returning neutral response despite error, per config", input.Event.EventID(), input.Event.RoomID()) | ||
| return nil, nil | ||
| } | ||
| } | ||
| for _, r := range res.Results { | ||
| // Note: we compress JSON here because the OpenAI library tends to return *a lot* of redundant detail, including JSON with newlines in it. | ||
| log.Printf("[%s | %s] Result for sender %s: Flagged=%t Flags=%s Scores=%s", input.Event.EventID(), input.Event.RoomID(), input.Event.SenderID(), r.Flagged, compressJsonResponse(r.Categories), compressJsonResponse(r.CategoryScores)) | ||
| if r.Flagged { | ||
| flags := []classification.Classification{classification.Spam} | ||
| if r.Categories.SexualMinors { | ||
| flags = append(flags, classification.CSAM) | ||
| } | ||
| return flags, nil | ||
| } | ||
| } | ||
| } | ||
| return nil, nil | ||
| } | ||
|
|
||
| type compressible interface { | ||
| RawJSON() string // same definition that's shared with the OpenAI response parts | ||
| } | ||
|
|
||
| func compressJsonResponse(target compressible) string { | ||
| raw := target.RawJSON() | ||
|
|
||
| val := make(map[string]any) | ||
| err := json.Unmarshal([]byte(raw), &val) | ||
| if err != nil { | ||
| log.Printf("Non-fatal error compressing JSON. Using uncompressed instead. %s", err) | ||
| return raw | ||
| } | ||
| b, err := json.Marshal(val) | ||
| if err != nil { | ||
| log.Printf("Non-fatal error compressing JSON. Using uncompressed instead. %s", err) | ||
| return raw | ||
| } | ||
|
|
||
| return string(b) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| package ai | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
|
|
||
| "github.com/matrix-org/policyserv/config" | ||
| "github.com/matrix-org/policyserv/filter/classification" | ||
| "github.com/matrix-org/policyserv/test" | ||
| "github.com/openai/openai-go/v3/option" | ||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
|
|
||
| func TestOpenAIOmniModeration(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| // Caution: this is a long test. It mocks the OpenAI API to drive our Omni provider through 4 test cases: | ||
| // 1. Regular spammy message (according to omni) | ||
| // 2. Spammy message flagged for CSAM by omni | ||
| // 3. Neutral message (according to omni) | ||
| // 4. Simulates a server error to test `FailSecure` in both operation modes | ||
|
|
||
| // We create our own HTTP client to intercept and act as the OpenAI API | ||
| apiKey := "not_a_real_key" | ||
| mockApi := test.MakeOpenAIModerationServer(t, apiKey) | ||
| defer mockApi.Close() | ||
| client := mockApi.Client() // get a client instance that trusts the mockApi certificate | ||
|
|
||
| // Create the provider | ||
| provider, err := NewOpenAIOmniModeration( | ||
| &config.InstanceConfig{OpenAIApiKey: apiKey}, | ||
| option.WithHTTPClient(client), | ||
| option.WithBaseURL(mockApi.URL), | ||
| ) | ||
| assert.NoError(t, err) | ||
| assert.NotNil(t, provider) | ||
|
|
||
| spammyEvent1 := test.MustMakeKeywordEvent(test.KeywordSpammy) | ||
| ret, err := provider.CheckEvent(context.Background(), &OpenAIOmniModerationConfig{FailSecure: true}, &Input{Event: spammyEvent1}) | ||
| assert.NoError(t, err) | ||
| assert.Equal(t, []classification.Classification{ | ||
| classification.Spam, | ||
| }, ret) | ||
|
|
||
| spammyEvent2 := test.MustMakeKeywordEvent(test.KeywordSpammyCSAM) | ||
| ret, err = provider.CheckEvent(context.Background(), &OpenAIOmniModerationConfig{FailSecure: true}, &Input{Event: spammyEvent2}) | ||
| assert.NoError(t, err) | ||
| assert.Equal(t, []classification.Classification{ | ||
| classification.Spam, | ||
| classification.CSAM, // should have been detected | ||
| }, ret) | ||
|
|
||
| neutralEvent := test.MustMakeKeywordEvent(test.KeywordNeutral) | ||
| ret, err = provider.CheckEvent(context.Background(), &OpenAIOmniModerationConfig{FailSecure: true}, &Input{Event: neutralEvent}) | ||
| assert.NoError(t, err) | ||
| assert.Nil(t, ret) // no classifications | ||
|
|
||
| failEvent := test.MustMakeKeywordEvent(test.KeywordIntentionalFail) | ||
|
|
||
| // First test that when FailSecure: true we return a spam classification | ||
| ret, err = provider.CheckEvent(context.Background(), &OpenAIOmniModerationConfig{FailSecure: true}, &Input{Event: failEvent}) | ||
| assert.NoError(t, err) | ||
| assert.Equal(t, []classification.Classification{ | ||
| classification.Spam, | ||
| classification.Frequency, // also added by the provider | ||
| }, ret) | ||
|
|
||
| // Now when FailSecure: false, we should get no classifications (but also no errors) | ||
| ret, err = provider.CheckEvent(context.Background(), &OpenAIOmniModerationConfig{FailSecure: false}, &Input{Event: failEvent}) | ||
| assert.NoError(t, err) | ||
| assert.Nil(t, ret) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package ai | ||
|
|
||
| import ( | ||
| "context" | ||
|
|
||
| "github.com/matrix-org/gomatrixserverlib" | ||
| "github.com/matrix-org/policyserv/filter/classification" | ||
| "github.com/matrix-org/policyserv/media" | ||
| ) | ||
|
|
||
| type Input struct { | ||
| Event gomatrixserverlib.PDU | ||
| Medias []*media.Item | ||
| } | ||
|
|
||
| type Provider[ConfigT any] interface { | ||
| CheckEvent(ctx context.Context, cnf ConfigT, input *Input) ([]classification.Classification, error) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| package filter | ||
|
|
||
| import ( | ||
| "context" | ||
| "slices" | ||
|
|
||
| "github.com/matrix-org/policyserv/ai" | ||
| "github.com/matrix-org/policyserv/filter/classification" | ||
| ) | ||
|
|
||
| type InstancedAIExecutorFilter[ConfigT any] struct { | ||
| name string | ||
| set *Set | ||
| config ConfigT | ||
| aiProvider ai.Provider[ConfigT] | ||
| inRoomIds []string | ||
| } | ||
|
|
||
| func NewInstancedAIExecutorFilter[ConfigT any](name string, set *Set, config ConfigT, aiProvider ai.Provider[ConfigT], inRoomIds []string) *InstancedAIExecutorFilter[ConfigT] { | ||
| return &InstancedAIExecutorFilter[ConfigT]{ | ||
| name: name, | ||
| set: set, | ||
| config: config, | ||
| aiProvider: aiProvider, | ||
| inRoomIds: inRoomIds, | ||
| } | ||
| } | ||
|
|
||
| func (f *InstancedAIExecutorFilter[ConfigT]) Name() string { | ||
| return f.name | ||
| } | ||
|
|
||
| func (f *InstancedAIExecutorFilter[ConfigT]) CheckEvent(ctx context.Context, input *Input) ([]classification.Classification, error) { | ||
| if !slices.Contains(f.inRoomIds, input.Event.RoomID().String()) { | ||
| return nil, nil // this filter isn't allowed to execute in here | ||
| } | ||
| return f.aiProvider.CheckEvent(ctx, f.config, &ai.Input{ | ||
| Event: input.Event, | ||
| Medias: input.Medias, | ||
| }) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this removes duplicate keys and newlines?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
in a way, yea. For whatever reason the OpenAI response structs duplicate all of their fields and then use "pretty" JSON too, which makes things noisy.