Implement OpenAI Moderation model

This commit introduces the OpenAiModerationModel and related classes:

- Add OpenAiModerationModel for content moderation
- Create OpenAiModerationOptions for configuration
- Implement OpenAiModerationProperties for Spring Boot setup
- Add integration tests in OpenAiModerationModelIT
- Add documentation

Co-authored-by: hemeda3 <hemeda3@users.noreply.github.com>
This commit is contained in:
Mohammed, Ahmed yousri salama (Canada)
2024-02-14 17:34:58 -05:00
committed by Mark Pollack
parent 56e678c487
commit 189468127c
27 changed files with 2144 additions and 4 deletions

View File

@@ -0,0 +1,172 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.ai.moderation.*;
import org.springframework.ai.openai.api.OpenAiModerationApi;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.http.ResponseEntity;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.util.Assert;
import java.util.ArrayList;
import java.util.List;
/**
* OpenAiModerationModel is a class that implements the ModerationModel interface. It
* provides a client for calling the OpenAI moderation generation API.
*
* @author Ahmed Yousri
* @since 1.0.0
*/
public class OpenAiModerationModel implements ModerationModel {
private final Logger logger = LoggerFactory.getLogger(getClass());
private OpenAiModerationOptions defaultOptions;
private final OpenAiModerationApi openAiModerationApi;
private final RetryTemplate retryTemplate;
public OpenAiModerationModel(OpenAiModerationApi openAiModerationApi) {
this(openAiModerationApi, RetryUtils.DEFAULT_RETRY_TEMPLATE);
}
public OpenAiModerationModel(OpenAiModerationApi openAiModerationApi, RetryTemplate retryTemplate) {
Assert.notNull(openAiModerationApi, "OpenAiModerationApi must not be null");
Assert.notNull(retryTemplate, "retryTemplate must not be null");
this.openAiModerationApi = openAiModerationApi;
this.retryTemplate = retryTemplate;
}
public OpenAiModerationOptions getDefaultOptions() {
return this.defaultOptions;
}
public OpenAiModerationModel withDefaultOptions(OpenAiModerationOptions defaultOptions) {
this.defaultOptions = defaultOptions;
return this;
}
@Override
public ModerationResponse call(ModerationPrompt moderationPrompt) {
return this.retryTemplate.execute(ctx -> {
String instructions = moderationPrompt.getInstructions().getText();
OpenAiModerationApi.OpenAiModerationRequest moderationRequest = new OpenAiModerationApi.OpenAiModerationRequest(
instructions);
if (this.defaultOptions != null) {
moderationRequest = ModelOptionsUtils.merge(this.defaultOptions, moderationRequest,
OpenAiModerationApi.OpenAiModerationRequest.class);
}
if (moderationPrompt.getOptions() != null) {
moderationRequest = ModelOptionsUtils.merge(toOpenAiModerationOptions(moderationPrompt.getOptions()),
moderationRequest, OpenAiModerationApi.OpenAiModerationRequest.class);
}
ResponseEntity<OpenAiModerationApi.OpenAiModerationResponse> moderationResponseEntity = this.openAiModerationApi
.createModeration(moderationRequest);
return convertResponse(moderationResponseEntity, moderationRequest);
});
}
private ModerationResponse convertResponse(
ResponseEntity<OpenAiModerationApi.OpenAiModerationResponse> moderationResponseEntity,
OpenAiModerationApi.OpenAiModerationRequest openAiModerationRequest) {
OpenAiModerationApi.OpenAiModerationResponse moderationApiResponse = moderationResponseEntity.getBody();
if (moderationApiResponse == null) {
logger.warn("No moderation response returned for request: {}", openAiModerationRequest);
return new ModerationResponse(new Generation());
}
List<ModerationResult> moderationResults = new ArrayList<>();
if (moderationApiResponse.results() != null) {
for (OpenAiModerationApi.OpenAiModerationResult result : moderationApiResponse.results()) {
Categories categories = null;
CategoryScores categoryScores = null;
if (result.categories() != null) {
categories = Categories.builder()
.withSexual(result.categories().sexual())
.withHate(result.categories().hate())
.withHarassment(result.categories().harassment())
.withSelfHarm(result.categories().selfHarm())
.withSexualMinors(result.categories().sexualMinors())
.withHateThreatening(result.categories().hateThreatening())
.withViolenceGraphic(result.categories().violenceGraphic())
.withSelfHarmIntent(result.categories().selfHarmIntent())
.withSelfHarmInstructions(result.categories().selfHarmInstructions())
.withHarassmentThreatening(result.categories().harassmentThreatening())
.withViolence(result.categories().violence())
.build();
}
if (result.categoryScores() != null) {
categoryScores = CategoryScores.builder()
.withHate(result.categoryScores().hate())
.withHateThreatening(result.categoryScores().hateThreatening())
.withHarassment(result.categoryScores().harassment())
.withHarassmentThreatening(result.categoryScores().harassmentThreatening())
.withSelfHarm(result.categoryScores().selfHarm())
.withSelfHarmIntent(result.categoryScores().selfHarmIntent())
.withSelfHarmInstructions(result.categoryScores().selfHarmInstructions())
.withSexual(result.categoryScores().sexual())
.withSexualMinors(result.categoryScores().sexualMinors())
.withViolence(result.categoryScores().violence())
.withViolenceGraphic(result.categoryScores().violenceGraphic())
.build();
}
ModerationResult moderationResult = ModerationResult.builder()
.withCategories(categories)
.withCategoryScores(categoryScores)
.withFlagged(result.flagged())
.build();
moderationResults.add(moderationResult);
}
}
Moderation moderation = Moderation.builder()
.withId(moderationApiResponse.id())
.withModel(moderationApiResponse.model())
.withResults(moderationResults)
.build();
return new ModerationResponse(new Generation(moderation));
}
/**
* Convert the {@link ModerationOptions} into {@link OpenAiModerationOptions}.
* @return the converted {@link OpenAiModerationOptions}.
*/
private OpenAiModerationOptions toOpenAiModerationOptions(ModerationOptions runtimeModerationOptions) {
OpenAiModerationOptions.Builder openAiModerationOptionsBuilder = OpenAiModerationOptions.builder();
// Handle portable moderation options
if (runtimeModerationOptions != null && runtimeModerationOptions.getModel() != null) {
openAiModerationOptionsBuilder.withModel(runtimeModerationOptions.getModel());
}
return openAiModerationOptionsBuilder.build();
}
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.ai.moderation.ModerationOptions;
import org.springframework.ai.openai.api.OpenAiModerationApi;
/**
* OpenAI Moderation API options. OpenAiModerationOptions.java
*
* @author Ahmed Yousri
* @since 1.0.0
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class OpenAiModerationOptions implements ModerationOptions {
/**
* The model to use for moderation generation.
*/
@JsonProperty("model")
private String model = OpenAiModerationApi.DEFAULT_MODERATION_MODEL;
public static Builder builder() {
return new Builder();
}
public static class Builder {
private final OpenAiModerationOptions options;
private Builder() {
this.options = new OpenAiModerationOptions();
}
public Builder withModel(String model) {
options.setModel(model);
return this;
}
public OpenAiModerationOptions build() {
return options;
}
}
@Override
public String getModel() {
return this.model;
}
public void setModel(String model) {
this.model = model;
}
}

View File

@@ -0,0 +1,152 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.api;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.util.Assert;
import org.springframework.web.client.ResponseErrorHandler;
import org.springframework.web.client.RestClient;
import java.io.IOException;
import java.util.function.Consumer;
/**
* OpenAI Moderation API.
*
* @author Ahmed Yousri
* @see <a href=
* "https://platform.openai.com/docs/api-reference/moderations">https://platform.openai.com/docs/api-reference/moderations</a>
*/
public class OpenAiModerationApi {
private static final String DEFAULT_BASE_URL = "https://api.openai.com";
public static final String DEFAULT_MODERATION_MODEL = "text-moderation-latest";
private final RestClient restClient;
private final ObjectMapper objectMapper;
/**
* Create a new OpenAI Moderation api with base URL set to https://api.openai.com
* @param openAiToken OpenAI apiKey.
*/
public OpenAiModerationApi(String openAiToken) {
this(DEFAULT_BASE_URL, openAiToken, RestClient.builder(), RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER);
}
public OpenAiModerationApi(String baseUrl, String openAiToken, RestClient.Builder restClientBuilder,
ResponseErrorHandler responseErrorHandler) {
this.objectMapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
Consumer<HttpHeaders> jsonContentHeaders = headers -> {
headers.setBearerAuth(openAiToken);
headers.setContentType(MediaType.APPLICATION_JSON);
};
this.restClient = restClientBuilder.baseUrl(baseUrl)
.defaultHeaders(jsonContentHeaders)
.defaultStatusHandler(responseErrorHandler)
.build();
}
// @formatter:off
@JsonInclude(JsonInclude.Include.NON_NULL)
public record OpenAiModerationRequest (
@JsonProperty("input") String prompt,
@JsonProperty("model") String model
) {
public OpenAiModerationRequest(String prompt) {
this(prompt, null);
}
}
@JsonInclude(JsonInclude.Include.NON_NULL)
public record OpenAiModerationResponse(
@JsonProperty("id") String id,
@JsonProperty("model") String model,
@JsonProperty("results") OpenAiModerationResult[] results) {
}
@JsonInclude(JsonInclude.Include.NON_NULL)
public record OpenAiModerationResult(
@JsonProperty("flagged") boolean flagged,
@JsonProperty("categories") Categories categories,
@JsonProperty("category_scores") CategoryScores categoryScores) {
}
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Categories(
@JsonProperty("sexual") boolean sexual,
@JsonProperty("hate") boolean hate,
@JsonProperty("harassment") boolean harassment,
@JsonProperty("self-harm") boolean selfHarm,
@JsonProperty("sexual/minors") boolean sexualMinors,
@JsonProperty("hate/threatening") boolean hateThreatening,
@JsonProperty("violence/graphic") boolean violenceGraphic,
@JsonProperty("self-harm/intent") boolean selfHarmIntent,
@JsonProperty("self-harm/instructions") boolean selfHarmInstructions,
@JsonProperty("harassment/threatening") boolean harassmentThreatening,
@JsonProperty("violence") boolean violence) {
}
public record CategoryScores(
@JsonProperty("sexual") double sexual,
@JsonProperty("hate") double hate,
@JsonProperty("harassment") double harassment,
@JsonProperty("self-harm") double selfHarm,
@JsonProperty("sexual/minors") double sexualMinors,
@JsonProperty("hate/threatening") double hateThreatening,
@JsonProperty("violence/graphic") double violenceGraphic,
@JsonProperty("self-harm/intent") double selfHarmIntent,
@JsonProperty("self-harm/instructions") double selfHarmInstructions,
@JsonProperty("harassment/threatening") double harassmentThreatening,
@JsonProperty("violence") double violence) {
}
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Data(
@JsonProperty("url") String url,
@JsonProperty("b64_json") String b64Json,
@JsonProperty("revised_prompt") String revisedPrompt) {
}
// @formatter:onn
public ResponseEntity<OpenAiModerationResponse> createModeration(OpenAiModerationRequest openAiModerationRequest) {
Assert.notNull(openAiModerationRequest, "Moderation request cannot be null.");
Assert.hasLength(openAiModerationRequest.prompt(), "Prompt cannot be empty.");
return this.restClient.post()
.uri("v1/moderations")
.body(openAiModerationRequest)
.retrieve()
.toEntity(OpenAiModerationResponse.class);
}
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.metadata;
import org.springframework.ai.moderation.ModerationGenerationMetadata;
import java.util.Objects;
public class OpenAiModerationGenerationMetadata implements ModerationGenerationMetadata {
public OpenAiModerationGenerationMetadata() {
}
}

View File

@@ -19,6 +19,7 @@ import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.openai.api.OpenAiAudioApi;
import org.springframework.ai.openai.api.OpenAiImageApi;
import org.springframework.ai.openai.api.OpenAiApi.ChatModel;
import org.springframework.ai.openai.api.OpenAiModerationApi;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.util.StringUtils;
@@ -41,6 +42,11 @@ public class OpenAiTestConfiguration {
return new OpenAiAudioApi(getApiKey());
}
@Bean
public OpenAiModerationApi openAiModerationApi() {
return new OpenAiModerationApi(getApiKey());
}
private String getApiKey() {
String apiKey = System.getenv("OPENAI_API_KEY");
if (!StringUtils.hasText(apiKey)) {
@@ -81,4 +87,10 @@ public class OpenAiTestConfiguration {
return new OpenAiEmbeddingModel(api);
}
@Bean
public OpenAiModerationModel openAiModerationClient(OpenAiModerationApi openAiModerationApi) {
OpenAiModerationModel openAiModerationModel = new OpenAiModerationModel(openAiModerationApi);
return openAiModerationModel;
}
}

View File

@@ -0,0 +1,143 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.moderation;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.moderation.*;
import org.springframework.ai.openai.OpenAiTestConfiguration;
import org.springframework.ai.openai.testutils.AbstractIT;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Ahmed Yousri
* @since 0.9.0
*/
@SpringBootTest(classes = OpenAiTestConfiguration.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
public class OpenAiModerationModelIT extends AbstractIT {
@Test
void moderationAsUrlTestPositive() {
var options = ModerationOptionsBuilder.builder().withModel("text-moderation-stable").build();
var instructions = """
I want to kill them.!".""";
ModerationPrompt moderationPrompt = new ModerationPrompt(instructions, options);
ModerationResponse moderationResponse = openAiModerationModel.call(moderationPrompt);
assertThat(moderationResponse.getResults()).hasSize(1);
var generation = moderationResponse.getResult();
Moderation moderation = generation.getOutput();
assertThat(moderation.getId()).isNotEmpty();
assertThat(moderation.getResults()).isNotNull();
assertThat(moderation.getResults().size()).isNotZero();
System.out.println(moderation.getResults().toString());
assertThat(moderation.getId()).isNotNull();
assertThat(moderation.getModel()).isNotNull();
ModerationResult result = moderation.getResults().get(0);
assertThat(result.isFlagged()).isTrue();
Categories categories = result.getCategories();
assertThat(categories).isNotNull();
assertThat(categories.isSexual()).isNotNull();
assertThat(categories.isHate()).isNotNull();
assertThat(categories.isHarassment()).isNotNull();
assertThat(categories.isSelfHarm()).isNotNull();
assertThat(categories.isSexualMinors()).isNotNull();
assertThat(categories.isHateThreatening()).isNotNull();
assertThat(categories.isViolenceGraphic()).isNotNull();
assertThat(categories.isSelfHarmIntent()).isNotNull();
assertThat(categories.isSelfHarmInstructions()).isNotNull();
assertThat(categories.isHarassmentThreatening()).isNotNull();
assertThat(categories.isViolence()).isTrue();
CategoryScores scores = result.getCategoryScores();
assertThat(scores.getSexual()).isNotNull();
assertThat(scores.getHate()).isNotNull();
assertThat(scores.getHarassment()).isNotNull();
assertThat(scores.getSelfHarm()).isNotNull();
assertThat(scores.getSexualMinors()).isNotNull();
assertThat(scores.getHateThreatening()).isNotNull();
assertThat(scores.getViolenceGraphic()).isNotNull();
assertThat(scores.getSelfHarmIntent()).isNotNull();
assertThat(scores.getSelfHarmInstructions()).isNotNull();
assertThat(scores.getHarassmentThreatening()).isNotNull();
assertThat(scores.getViolence()).isNotNull();
}
@Test
void moderationAsUrlTestNegative() {
var options = ModerationOptionsBuilder.builder().withModel("text-moderation-stable").build();
var instructions = """
A light cream colored mini golden doodle with a sign that contains the message "I'm on my way to BARCADE!".""";
ModerationPrompt moderationPrompt = new ModerationPrompt(instructions, options);
ModerationResponse moderationResponse = openAiModerationModel.call(moderationPrompt);
assertThat(moderationResponse.getResults()).hasSize(1);
var generation = moderationResponse.getResult();
Moderation moderation = generation.getOutput();
assertThat(moderation.getId()).isNotEmpty();
assertThat(moderation.getResults()).isNotNull();
assertThat(moderation.getResults().size()).isNotZero();
System.out.println(moderation.getResults().toString());
assertThat(moderation.getId()).isNotNull();
assertThat(moderation.getModel()).isNotNull();
ModerationResult result = moderation.getResults().get(0);
assertThat(result.isFlagged()).isFalse();
Categories categories = result.getCategories();
assertThat(categories.isSexual()).isFalse();
assertThat(categories.isHate()).isFalse();
assertThat(categories.isHarassment()).isFalse();
assertThat(categories.isSelfHarm()).isFalse();
assertThat(categories.isSexualMinors()).isFalse();
assertThat(categories.isHateThreatening()).isFalse();
assertThat(categories.isViolenceGraphic()).isFalse();
assertThat(categories.isSelfHarmIntent()).isFalse();
assertThat(categories.isSelfHarmInstructions()).isFalse();
assertThat(categories.isHarassmentThreatening()).isFalse();
assertThat(categories.isViolence()).isFalse();
CategoryScores scores = result.getCategoryScores();
assertThat(scores.getSexual()).isNotNull();
assertThat(scores.getHate()).isNotNull();
assertThat(scores.getHarassment()).isNotNull();
assertThat(scores.getSelfHarm()).isNotNull();
assertThat(scores.getSexualMinors()).isNotNull();
assertThat(scores.getHateThreatening()).isNotNull();
assertThat(scores.getViolenceGraphic()).isNotNull();
assertThat(scores.getSelfHarmIntent()).isNotNull();
assertThat(scores.getSelfHarmInstructions()).isNotNull();
assertThat(scores.getHarassmentThreatening()).isNotNull();
assertThat(scores.getViolence()).isNotNull();
}
}

View File

@@ -0,0 +1,186 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.moderation;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.moderation.*;
import org.springframework.ai.openai.OpenAiModerationModel;
import org.springframework.ai.openai.api.OpenAiModerationApi;
import org.springframework.ai.openai.metadata.support.OpenAiApiResponseHeaders;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
/**
* @author Ahmed Yousri
* @since 0.9.0
*/
@RestClientTest(OpenAiModerationModelTests.Config.class)
public class OpenAiModerationModelTests {
private static String TEST_API_KEY = "sk-1234567890";
@Autowired
private OpenAiModerationModel openAiModerationModel;
@Autowired
private MockRestServiceServer server;
@AfterEach
void resetMockServer() {
server.reset();
}
@Test
void aiResponseContainsModerationResponseMetadata() {
prepareMock();
ModerationPrompt prompt = new ModerationPrompt("I want to kill them..");
ModerationResponse response = this.openAiModerationModel.call(prompt);
assertThat(response).isNotNull();
Generation generation = response.getResult();
assertThat(generation).isNotNull();
Moderation moderation = response.getResult().getOutput();
assertThat(moderation).isNotNull();
assertThat(moderation.getId()).isEqualTo("modr-XXXXX");
assertThat(moderation.getModel()).isEqualTo("text-moderation-005");
List<ModerationResult> results = moderation.getResults();
ModerationResult result = results.get(0);
assertThat(result.isFlagged()).isTrue();
// Assert Categories
Categories categories = result.getCategories();
assertThat(categories.isSexual()).isFalse();
assertThat(categories.isHate()).isFalse();
assertThat(categories.isHarassment()).isFalse();
assertThat(categories.isSelfHarm()).isFalse();
assertThat(categories.isSexualMinors()).isFalse();
assertThat(categories.isHateThreatening()).isFalse();
assertThat(categories.isViolenceGraphic()).isFalse();
assertThat(categories.isSelfHarmIntent()).isFalse();
assertThat(categories.isSelfHarmInstructions()).isFalse();
assertThat(categories.isHarassmentThreatening()).isTrue();
assertThat(categories.isViolence()).isTrue();
// Assert CategoryScores
CategoryScores scores = result.getCategoryScores();
assertThat(scores.getSexual()).isEqualTo(1.2282071E-6);
assertThat(scores.getHate()).isEqualTo(0.010696256);
assertThat(scores.getHarassment()).isEqualTo(0.29842457);
assertThat(scores.getSelfHarm()).isEqualTo(1.5236925E-8);
assertThat(scores.getSexualMinors()).isEqualTo(5.7246268E-8);
assertThat(scores.getHateThreatening()).isEqualTo(0.0060676364);
assertThat(scores.getViolenceGraphic()).isEqualTo(4.435014E-6);
assertThat(scores.getSelfHarmIntent()).isEqualTo(8.098441E-10);
assertThat(scores.getSelfHarmInstructions()).isEqualTo(2.8498655E-11);
assertThat(scores.getHarassmentThreatening()).isEqualTo(0.63055265);
assertThat(scores.getViolence()).isEqualTo(0.99011886);
}
private void prepareMock() {
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.set(OpenAiApiResponseHeaders.REQUESTS_LIMIT_HEADER.getName(), "4000");
httpHeaders.set(OpenAiApiResponseHeaders.REQUESTS_REMAINING_HEADER.getName(), "999");
httpHeaders.set(OpenAiApiResponseHeaders.REQUESTS_RESET_HEADER.getName(), "2d16h15m29s");
httpHeaders.set(OpenAiApiResponseHeaders.TOKENS_LIMIT_HEADER.getName(), "725000");
httpHeaders.set(OpenAiApiResponseHeaders.TOKENS_REMAINING_HEADER.getName(), "112358");
httpHeaders.set(OpenAiApiResponseHeaders.TOKENS_RESET_HEADER.getName(), "27h55s451ms");
server.expect(requestTo("v1/moderations"))
.andExpect(method(HttpMethod.POST))
.andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer " + TEST_API_KEY))
.andRespond(withSuccess(getJson(), MediaType.APPLICATION_JSON).headers(httpHeaders));
}
private String getJson() {
return """
{
"id": "modr-XXXXX",
"model": "text-moderation-005",
"results": [
{
"flagged": true,
"categories": {
"sexual": false,
"hate": false,
"harassment": false,
"self-harm": false,
"sexual/minors": false,
"hate/threatening": false,
"violence/graphic": false,
"self-harm/intent": false,
"self-harm/instructions": false,
"harassment/threatening": true,
"violence": true
},
"category_scores": {
"sexual": 1.2282071e-06,
"hate": 0.010696256,
"harassment": 0.29842457,
"self-harm": 1.5236925e-08,
"sexual/minors": 5.7246268e-08,
"hate/threatening": 0.0060676364,
"violence/graphic": 4.435014e-06,
"self-harm/intent": 8.098441e-10,
"self-harm/instructions": 2.8498655e-11,
"harassment/threatening": 0.63055265,
"violence": 0.99011886
}
}
]
}
""";
}
@SpringBootConfiguration
static class Config {
@Bean
public OpenAiModerationApi moderationGenerationApi(RestClient.Builder builder) {
return new OpenAiModerationApi("", TEST_API_KEY, builder, RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER);
}
@Bean
public OpenAiModerationModel openAiModerationClient(OpenAiModerationApi openAiModerationApi) {
return new OpenAiModerationModel(openAiModerationApi);
}
}
}

View File

@@ -29,11 +29,13 @@ import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.image.ImageModel;
import org.springframework.ai.openai.OpenAiAudioSpeechModel;
import org.springframework.ai.openai.OpenAiAudioTranscriptionModel;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiModerationModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
@@ -66,6 +68,9 @@ public abstract class AbstractIT {
@Autowired
protected EmbeddingModel embeddingModel;
@Autowired
protected OpenAiModerationModel openAiModerationModel;
@Value("classpath:/prompts/eval/qa-evaluator-accurate-answer.st")
protected Resource qaEvaluatorAccurateAnswerResource;

View File

@@ -0,0 +1,213 @@
package org.springframework.ai.moderation;
import java.util.Objects;
/**
* The Categories class represents a set of categories used to classify content. Each
* category can be either true (indicating that the content belongs to the category) or
* false (indicating that the content does not belong to the category).
*
* @author Ahmed Yousri
* @since 1.0.0
*/
public class Categories {
private boolean sexual;
private boolean hate;
private boolean harassment;
private boolean selfHarm;
private boolean sexualMinors;
private boolean hateThreatening;
private boolean violenceGraphic;
private boolean selfHarmIntent;
private boolean selfHarmInstructions;
private boolean harassmentThreatening;
private boolean violence;
private Categories(Builder builder) {
this.sexual = builder.sexual;
this.hate = builder.hate;
this.harassment = builder.harassment;
this.selfHarm = builder.selfHarm;
this.sexualMinors = builder.sexualMinors;
this.hateThreatening = builder.hateThreatening;
this.violenceGraphic = builder.violenceGraphic;
this.selfHarmIntent = builder.selfHarmIntent;
this.selfHarmInstructions = builder.selfHarmInstructions;
this.harassmentThreatening = builder.harassmentThreatening;
this.violence = builder.violence;
}
public boolean isSexual() {
return sexual;
}
public boolean isHate() {
return hate;
}
public boolean isHarassment() {
return harassment;
}
public boolean isSelfHarm() {
return selfHarm;
}
public boolean isSexualMinors() {
return sexualMinors;
}
public boolean isHateThreatening() {
return hateThreatening;
}
public boolean isViolenceGraphic() {
return violenceGraphic;
}
public boolean isSelfHarmIntent() {
return selfHarmIntent;
}
public boolean isSelfHarmInstructions() {
return selfHarmInstructions;
}
public boolean isHarassmentThreatening() {
return harassmentThreatening;
}
public boolean isViolence() {
return violence;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private boolean sexual;
private boolean hate;
private boolean harassment;
private boolean selfHarm;
private boolean sexualMinors;
private boolean hateThreatening;
private boolean violenceGraphic;
private boolean selfHarmIntent;
private boolean selfHarmInstructions;
private boolean harassmentThreatening;
private boolean violence;
public Builder withSexual(boolean sexual) {
this.sexual = sexual;
return this;
}
public Builder withHate(boolean hate) {
this.hate = hate;
return this;
}
public Builder withHarassment(boolean harassment) {
this.harassment = harassment;
return this;
}
public Builder withSelfHarm(boolean selfHarm) {
this.selfHarm = selfHarm;
return this;
}
public Builder withSexualMinors(boolean sexualMinors) {
this.sexualMinors = sexualMinors;
return this;
}
public Builder withHateThreatening(boolean hateThreatening) {
this.hateThreatening = hateThreatening;
return this;
}
public Builder withViolenceGraphic(boolean violenceGraphic) {
this.violenceGraphic = violenceGraphic;
return this;
}
public Builder withSelfHarmIntent(boolean selfHarmIntent) {
this.selfHarmIntent = selfHarmIntent;
return this;
}
public Builder withSelfHarmInstructions(boolean selfHarmInstructions) {
this.selfHarmInstructions = selfHarmInstructions;
return this;
}
public Builder withHarassmentThreatening(boolean harassmentThreatening) {
this.harassmentThreatening = harassmentThreatening;
return this;
}
public Builder withViolence(boolean violence) {
this.violence = violence;
return this;
}
public Categories build() {
return new Categories(this);
}
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof Categories))
return false;
Categories that = (Categories) o;
return sexual == that.sexual && hate == that.hate && harassment == that.harassment && selfHarm == that.selfHarm
&& sexualMinors == that.sexualMinors && hateThreatening == that.hateThreatening
&& violenceGraphic == that.violenceGraphic && selfHarmIntent == that.selfHarmIntent
&& selfHarmInstructions == that.selfHarmInstructions
&& harassmentThreatening == that.harassmentThreatening && violence == that.violence;
}
@Override
public int hashCode() {
return Objects.hash(sexual, hate, harassment, selfHarm, sexualMinors, hateThreatening, violenceGraphic,
selfHarmIntent, selfHarmInstructions, harassmentThreatening, violence);
}
@Override
public String toString() {
return "Categories{" + "sexual=" + sexual + ", hate=" + hate + ", harassment=" + harassment + ", selfHarm="
+ selfHarm + ", sexualMinors=" + sexualMinors + ", hateThreatening=" + hateThreatening
+ ", violenceGraphic=" + violenceGraphic + ", selfHarmIntent=" + selfHarmIntent
+ ", selfHarmInstructions=" + selfHarmInstructions + ", harassmentThreatening=" + harassmentThreatening
+ ", violence=" + violence + '}';
}
}

View File

@@ -0,0 +1,217 @@
package org.springframework.ai.moderation;
import java.util.Objects;
/**
* This class represents the scores for different categories of content. Each category has
* a score ranging from 0.0 to 1.0. The scores represent the severity or intensity of the
* content in each respective category.
*
* @author Ahmed Yousri
* @since 1.0.0
*/
public class CategoryScores {
private double sexual;
private double hate;
private double harassment;
private double selfHarm;
private double sexualMinors;
private double hateThreatening;
private double violenceGraphic;
private double selfHarmIntent;
private double selfHarmInstructions;
private double harassmentThreatening;
private double violence;
private CategoryScores(Builder builder) {
this.sexual = builder.sexual;
this.hate = builder.hate;
this.harassment = builder.harassment;
this.selfHarm = builder.selfHarm;
this.sexualMinors = builder.sexualMinors;
this.hateThreatening = builder.hateThreatening;
this.violenceGraphic = builder.violenceGraphic;
this.selfHarmIntent = builder.selfHarmIntent;
this.selfHarmInstructions = builder.selfHarmInstructions;
this.harassmentThreatening = builder.harassmentThreatening;
this.violence = builder.violence;
}
public double getSexual() {
return sexual;
}
public double getHate() {
return hate;
}
public double getHarassment() {
return harassment;
}
public double getSelfHarm() {
return selfHarm;
}
public double getSexualMinors() {
return sexualMinors;
}
public double getHateThreatening() {
return hateThreatening;
}
public double getViolenceGraphic() {
return violenceGraphic;
}
public double getSelfHarmIntent() {
return selfHarmIntent;
}
public double getSelfHarmInstructions() {
return selfHarmInstructions;
}
public double getHarassmentThreatening() {
return harassmentThreatening;
}
public double getViolence() {
return violence;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private double sexual;
private double hate;
private double harassment;
private double selfHarm;
private double sexualMinors;
private double hateThreatening;
private double violenceGraphic;
private double selfHarmIntent;
private double selfHarmInstructions;
private double harassmentThreatening;
private double violence;
public Builder withSexual(double sexual) {
this.sexual = sexual;
return this;
}
public Builder withHate(double hate) {
this.hate = hate;
return this;
}
public Builder withHarassment(double harassment) {
this.harassment = harassment;
return this;
}
public Builder withSelfHarm(double selfHarm) {
this.selfHarm = selfHarm;
return this;
}
public Builder withSexualMinors(double sexualMinors) {
this.sexualMinors = sexualMinors;
return this;
}
public Builder withHateThreatening(double hateThreatening) {
this.hateThreatening = hateThreatening;
return this;
}
public Builder withViolenceGraphic(double violenceGraphic) {
this.violenceGraphic = violenceGraphic;
return this;
}
public Builder withSelfHarmIntent(double selfHarmIntent) {
this.selfHarmIntent = selfHarmIntent;
return this;
}
public Builder withSelfHarmInstructions(double selfHarmInstructions) {
this.selfHarmInstructions = selfHarmInstructions;
return this;
}
public Builder withHarassmentThreatening(double harassmentThreatening) {
this.harassmentThreatening = harassmentThreatening;
return this;
}
public Builder withViolence(double violence) {
this.violence = violence;
return this;
}
public CategoryScores build() {
return new CategoryScores(this);
}
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof CategoryScores))
return false;
CategoryScores that = (CategoryScores) o;
return Double.compare(that.sexual, sexual) == 0 && Double.compare(that.hate, hate) == 0
&& Double.compare(that.harassment, harassment) == 0 && Double.compare(that.selfHarm, selfHarm) == 0
&& Double.compare(that.sexualMinors, sexualMinors) == 0
&& Double.compare(that.hateThreatening, hateThreatening) == 0
&& Double.compare(that.violenceGraphic, violenceGraphic) == 0
&& Double.compare(that.selfHarmIntent, selfHarmIntent) == 0
&& Double.compare(that.selfHarmInstructions, selfHarmInstructions) == 0
&& Double.compare(that.harassmentThreatening, harassmentThreatening) == 0
&& Double.compare(that.violence, violence) == 0;
}
@Override
public int hashCode() {
return Objects.hash(sexual, hate, harassment, selfHarm, sexualMinors, hateThreatening, violenceGraphic,
selfHarmIntent, selfHarmInstructions, harassmentThreatening, violence);
}
@Override
public String toString() {
return "CategoryScores{" + "sexual=" + sexual + ", hate=" + hate + ", harassment=" + harassment + ", selfHarm="
+ selfHarm + ", sexualMinors=" + sexualMinors + ", hateThreatening=" + hateThreatening
+ ", violenceGraphic=" + violenceGraphic + ", selfHarmIntent=" + selfHarmIntent
+ ", selfHarmInstructions=" + selfHarmInstructions + ", harassmentThreatening=" + harassmentThreatening
+ ", violence=" + violence + '}';
}
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.moderation;
import org.springframework.ai.model.ModelResult;
import org.springframework.lang.Nullable;
/**
* The Generation class represents a response from a moderation process. It encapsulates
* the moderation generation metadata and the moderation object.
*
* @author Ahmed Yousri
* @since 1.0.0
*/
public class Generation implements ModelResult<Moderation> {
private ModerationGenerationMetadata moderationGenerationMetadata;
private Moderation moderation;
public Generation() {
}
public Generation(Moderation moderation) {
this.moderation = moderation;
}
public Generation(Moderation moderation, ModerationGenerationMetadata moderationGenerationMetadata) {
this.moderation = moderation;
this.moderationGenerationMetadata = moderationGenerationMetadata;
}
public Generation withGenerationMetadata(@Nullable ModerationGenerationMetadata moderationGenerationMetadata) {
this.moderationGenerationMetadata = moderationGenerationMetadata;
return this;
}
@Override
public Moderation getOutput() {
return moderation;
}
@Override
public ModerationGenerationMetadata getMetadata() {
return moderationGenerationMetadata;
}
@Override
public String toString() {
return "Generation{" + "moderationGenerationMetadata=" + moderationGenerationMetadata + ", moderation="
+ moderation + '}';
}
}

View File

@@ -0,0 +1,96 @@
package org.springframework.ai.moderation;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
/**
* The Moderation class represents the result of a moderation process. It contains the
* moderation ID, model, and a list of moderation results. To create an instance of
* Moderation, use the Builder class.
*
* @author Ahmed Yousri
* @since 1.0.0
*/
public class Moderation {
private final String id;
private final String model;
private final List<ModerationResult> results;
private Moderation(Builder builder) {
this.id = builder.id;
this.model = builder.model;
this.results = builder.moderationResultList;
}
public String getId() {
return id;
}
public String getModel() {
return model;
}
public List<ModerationResult> getResults() {
return results;
}
@Override
public String toString() {
return "Moderation{" + "id='" + id + '\'' + ", model='" + model + '\'' + ", results="
+ Arrays.toString(results.toArray()) + '}';
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof Moderation))
return false;
Moderation that = (Moderation) o;
return Objects.equals(id, that.id) && Objects.equals(model, that.model)
&& Objects.equals(results, that.results);
}
@Override
public int hashCode() {
return Objects.hash(id, model, results);
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private String id;
private String model;
private List<ModerationResult> moderationResultList;
public Builder withId(String id) {
this.id = id;
return this;
}
public Builder withModel(String model) {
this.model = model;
return this;
}
public Builder withResults(List<ModerationResult> results) {
this.moderationResultList = results;
return this;
}
public Moderation build() {
return new Moderation(this);
}
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.moderation;
import org.springframework.ai.model.ResultMetadata;
/**
* An interface that represents metadata associated with the results of a moderation
* generation process. This interface extends the ResultMetadata interface, which provides
* general information about the results generated by an AI model.
*
* @author Ahmed Yousri
* @since 1.0.0
*/
public interface ModerationGenerationMetadata extends ResultMetadata {
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.moderation;
import java.util.Objects;
/**
* Represents a single message intended for moderation, encapsulating the text content.
* This class provides a basic structure for messages that can be submitted to moderation
* processes.
*
* @author Ahmed Yousri
* @since 1.0.0
*/
public class ModerationMessage {
private String text;
public ModerationMessage(String text) {
this.text = text;
}
public String getText() {
return text;
}
public void setText(String text) {
this.text = text;
}
@Override
public String toString() {
return "ModerationMessage{" + "text='" + text + '\'' + '}';
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof ModerationMessage))
return false;
ModerationMessage that = (ModerationMessage) o;
return Objects.equals(text, that.text);
}
@Override
public int hashCode() {
return Objects.hash(text);
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.moderation;
import org.springframework.ai.model.Model;
/**
* The ModerationModel interface defines a generic AI model for moderation. It extends the
* Model interface to handle the interaction with various types of AI models. It provides
* a single method, call, which takes a ModerationPrompt as input and returns a
* ModerationResponse.
*
* @param <ModerationPrompt> the type of the moderation prompt
* @param <ModerationResponse> the type of the moderation response
* @author Ahmed Yousri
* @since 1.0.0
*/
@FunctionalInterface
public interface ModerationModel extends Model<ModerationPrompt, ModerationResponse> {
ModerationResponse call(ModerationPrompt request);
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.moderation;
import org.springframework.ai.model.ModelOptions;
/**
* Represents the options for moderation.
*
* @author Ahmed Yousri
* @since 1.0.0
*/
public interface ModerationOptions extends ModelOptions {
String getModel();
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.moderation;
/**
* A builder class for creating instances of ModerationOptions. Use the builder() method
* to obtain a new instance of ModerationOptionsBuilder. Use the withModel() method to set
* the model for moderation. Use the build() method to build the ModerationOptions
* instance.
*
* @author Ahmed Yousri
* @since 1.0.0
*/
public class ModerationOptionsBuilder {
private class ModerationModelOptionsImpl implements ModerationOptions {
private String model;
public void setModel(String model) {
this.model = model;
}
@Override
public String getModel() {
return model;
}
}
private final ModerationModelOptionsImpl options = new ModerationModelOptionsImpl();
private ModerationOptionsBuilder() {
}
public static ModerationOptionsBuilder builder() {
return new ModerationOptionsBuilder();
}
public ModerationOptionsBuilder withModel(String model) {
options.setModel(model);
return this;
}
public ModerationOptions build() {
return options;
}
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.moderation;
import org.springframework.ai.model.ModelRequest;
import java.util.Objects;
/**
* Represents a prompt for moderation containing a single message and the options for the
* moderation model. This class offers constructors to create a prompt from a single
* message or a simple instruction string, allowing for customization of moderation
* options through `ModerationOptions`. It simplifies creating moderation requests for
* different use cases.
*
* @author Ahmed Yousri
* @since 1.0.0
*/
public class ModerationPrompt implements ModelRequest<ModerationMessage> {
private final ModerationMessage message;
private ModerationOptions moderationModelOptions;
public ModerationPrompt(ModerationMessage message, ModerationOptions moderationModelOptions) {
this.message = message;
this.moderationModelOptions = moderationModelOptions;
}
public ModerationPrompt(String instructions, ModerationOptions moderationOptions) {
this(new ModerationMessage(instructions), moderationOptions);
}
public ModerationPrompt(String instructions) {
this(new ModerationMessage(instructions), ModerationOptionsBuilder.builder().build());
}
@Override
public ModerationMessage getInstructions() {
return message;
}
public ModerationOptions getOptions() {
return moderationModelOptions;
}
public void setOptions(ModerationOptions moderationModelOptions) {
this.moderationModelOptions = moderationModelOptions;
}
@Override
public String toString() {
return "ModerationPrompt{" + "message=" + message + ", moderationModelOptions=" + moderationModelOptions + '}';
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof ModerationPrompt))
return false;
ModerationPrompt that = (ModerationPrompt) o;
return Objects.equals(message, that.message)
&& Objects.equals(moderationModelOptions, that.moderationModelOptions);
}
@Override
public int hashCode() {
return Objects.hash(message, moderationModelOptions);
}
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.moderation;
import org.springframework.ai.model.ModelResponse;
import java.util.List;
import java.util.Objects;
/**
* Represents a response from a moderation process, encapsulating the moderation metadata
* and the generated content. This class provides access to both the single generation
* result and a list containing that result, alongside the metadata associated with the
* moderation response. Designed for flexibility, it allows retrieval of
* moderation-specific metadata as well as the moderated content.
*
* @author Ahmed Yousri
* @since 1.0.0
*/
public class ModerationResponse implements ModelResponse<Generation> {
private final ModerationResponseMetadata moderationResponseMetadata;
private final Generation generations;
public ModerationResponse(Generation generations) {
this(generations, new ModerationResponseMetadata());
}
public ModerationResponse(Generation generations, ModerationResponseMetadata moderationResponseMetadata) {
this.moderationResponseMetadata = moderationResponseMetadata;
this.generations = generations;
}
@Override
public Generation getResult() {
return generations;
}
@Override
public List<Generation> getResults() {
return List.of(generations);
}
@Override
public ModerationResponseMetadata getMetadata() {
return moderationResponseMetadata;
}
@Override
public String toString() {
return "ModerationResponse{" + "moderationResponseMetadata=" + moderationResponseMetadata + ", generations="
+ generations + '}';
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof ModerationResponse that))
return false;
return Objects.equals(moderationResponseMetadata, that.moderationResponseMetadata)
&& Objects.equals(generations, that.generations);
}
@Override
public int hashCode() {
return Objects.hash(moderationResponseMetadata, generations);
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.moderation;
import org.springframework.ai.model.AbstractResponseMetadata;
import org.springframework.ai.model.ResponseMetadata;
/**
* Defines the metadata associated with a moderation response, extending a base response
* interface. This interface is intended to provide additional context or data about the
* moderation process result.
*
* @author Ahmed Yousri
* @since 1.0.0
*/
public class ModerationResponseMetadata extends AbstractResponseMetadata implements ResponseMetadata {
}

View File

@@ -0,0 +1,106 @@
package org.springframework.ai.moderation;
import java.util.Objects;
/**
* Represents the result of a moderation process, indicating whether content was flagged,
* the categories of moderation, and detailed scores for each category. This class is
* designed to be constructed via its Builder inner class.
*
* @author Ahmed Yousri
* @since 1.0.0
*/
public class ModerationResult {
private boolean flagged;
private Categories categories;
private CategoryScores categoryScores;
private ModerationResult(Builder builder) {
this.flagged = builder.flagged;
this.categories = builder.categories;
this.categoryScores = builder.categoryScores;
}
public boolean isFlagged() {
return flagged;
}
public void setFlagged(boolean flagged) {
this.flagged = flagged;
}
public Categories getCategories() {
return categories;
}
public void setCategories(Categories categories) {
this.categories = categories;
}
public CategoryScores getCategoryScores() {
return categoryScores;
}
public void setCategoryScores(CategoryScores categoryScores) {
this.categoryScores = categoryScores;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private boolean flagged;
private Categories categories;
private CategoryScores categoryScores;
public Builder withFlagged(boolean flagged) {
this.flagged = flagged;
return this;
}
public Builder withCategories(Categories categories) {
this.categories = categories;
return this;
}
public Builder withCategoryScores(CategoryScores categoryScores) {
this.categoryScores = categoryScores;
return this;
}
public ModerationResult build() {
return new ModerationResult(this);
}
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof ModerationResult))
return false;
ModerationResult that = (ModerationResult) o;
return flagged == that.flagged && Objects.equals(categories, that.categories)
&& Objects.equals(categoryScores, that.categoryScores);
}
@Override
public int hashCode() {
return Objects.hash(flagged, categories, categoryScores);
}
@Override
public String toString() {
return "ModerationResult{" + "flagged=" + flagged + ", categories=" + categories + ", categoryScores="
+ categoryScores + '}';
}
}

View File

@@ -66,6 +66,9 @@
**** xref:api/audio/transcriptions/openai-transcriptions.adoc[OpenAI]
*** xref:api/audio/speech.adoc[]
**** xref:api/audio/speech/openai-speech.adoc[OpenAI]
** xref:api/moderation[Moderation Model API]
*** xref:api/moderation/openai-moderation.adoc[OpenAI]
** xref:api/vectordbs.adoc[]
*** xref:api/vectordbs/azure.adoc[]
*** xref:api/vectordbs/apache-cassandra.adoc[]

View File

@@ -55,7 +55,7 @@ The prefix `spring.ai.openai` is used as the property prefix that lets you conne
TIP: For users that belong to multiple organizations (or are accessing their projects through their legacy user API key), optionally, you can specify which organization and project is used for an API request.
Usage from these API requests will count as usage for the specified organization and project.
=== Configuraiton Properties
=== Configuration Properties
The prefix `spring.ai.openai.audio.speech` is used as the property prefix that lets you configure the OpenAI Text-to-Speech client.

View File

@@ -53,7 +53,7 @@ The prefix `spring.ai.openai` is used as the property prefix that lets you conne
TIP: For users that belong to multiple organizations (or are accessing their projects through their legacy user API key), optionally, you can specify which organization and project is used for an API request.
Usage from these API requests will count as usage for the specified organization and project.
==== Configuraiton Properties
==== Configuration Properties
The prefix `spring.ai.openai.audio.transcription` is used as the property prefix that lets you configure the retry mechanism for the OpenAI transcription model.

View File

@@ -0,0 +1,172 @@
= Moderation
== Introduction
Spring AI supports OpenAI's Moderation model, which allows you to detect potentially harmful or sensitive content in text.
Follow this https://platform.openai.com/docs/guides/moderation[guide] to for more information on OpenAI's moderation model.
== Prerequisites
. Create an OpenAI account and obtain an API key. You can sign up at the https://platform.openai.com/signup[OpenAI signup page] and generate an API key on the https://platform.openai.com/account/api-keys[API Keys page].
. Add the `spring-ai-openai` dependency to your project's build file. For more information, refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section.
== Auto-configuration
Spring AI provides Spring Boot auto-configuration for the OpenAI Text-to-Speech Client.
To enable it add the following dependency to your project's Maven `pom.xml` file:
[source,xml]
----
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
----
or to your Gradle `build.gradle` build file:
[source,groovy]
----
dependencies {
implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter'
}
----
TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file.
== Moderation Properties
=== Connection Properties
The prefix spring.ai.openai is used as the property prefix that lets you connect to OpenAI.
[cols="3,5,1"]
|====
| Property | Description | Default
| spring.ai.openai.base-url | The URL to connect to | https://api.openai.com
| spring.ai.openai.api-key | The API Key | -
| spring.ai.openai.organization-id | Optionally you can specify which organization is used for an API request. | -
| spring.ai.openai.project-id | Optionally, you can specify which project is used for an API request. | -
|====
TIP: For users that belong to multiple organizations (or are accessing their projects through their legacy user API key), optionally, you can specify which organization and project is used for an API request.
Usage from these API requests will count as usage for the specified organization and project.
=== Configuration Properties
The prefix spring.ai.openai.moderation is used as the property prefix for configuring the OpenAI moderation model.
[cols="3,5,2"]
|====
| Property | Description | Default
| spring.ai.openai.moderation.base-url | The URL to connect to | https://api.openai.com
| spring.ai.openai.moderation.api-key | The API Key | -
| spring.ai.openai.moderation.organization-id | Optionally you can specify which organization is used for an API request. | -
| spring.ai.openai.moderation.project-id | Optionally, you can specify which project is used for an API request. | -
| spring.ai.openai.moderation.options.model | ID of the model to use for moderation. | text-moderation-latest
|====
NOTE: You can override the common `spring.ai.openai.base-url`, `spring.ai.openai.api-key`, `spring.ai.openai.organization-id` and `spring.ai.openai.project-id` properties.
The `spring.ai.openai.moderation.base-url`, `spring.ai.openai.moderation.api-key`, `spring.ai.openai.moderation.organization-id` and `spring.ai.openai.moderation.project-id` properties, if set, take precedence over the common properties.
This is useful if you want to use different OpenAI accounts for different models and different model endpoints.
TIP: All properties prefixed with `spring.ai.openai.moderation.options` can be overridden at runtime.
== Runtime Options
The OpenAiModerationOptions class provides the options to use when making a moderation request.
On start-up, the options specified by spring.ai.openai.moderation are used, but you can override these at runtime.
For example:
[source,java]
----
OpenAiModerationOptions moderationOptions = OpenAiModerationOptions.builder()
.withModel("text-moderation-latest")
.build();
ModerationPrompt moderationPrompt = new ModerationPrompt("Text to be moderated", moderationOptions);
ModerationResponse response = openAiModerationModel.call(moderationPrompt);
// Access the moderation results
Moderation moderation = moderationResponse.getResult().getOutput();
// Print general information
System.out.println("Moderation ID: " + moderation.getId());
System.out.println("Model used: " + moderation.getModel());
// Access the moderation results (there's usually only one, but it's a list)
for (ModerationResult result : moderation.getResults()) {
System.out.println("\nModeration Result:");
System.out.println("Flagged: " + result.isFlagged());
// Access categories
Categories categories = result.getCategories();
System.out.println("\nCategories:");
System.out.println("Sexual: " + categories.isSexual());
System.out.println("Hate: " + categories.isHate());
System.out.println("Harassment: " + categories.isHarassment());
System.out.println("Self-Harm: " + categories.isSelfHarm());
System.out.println("Sexual/Minors: " + categories.isSexualMinors());
System.out.println("Hate/Threatening: " + categories.isHateThreatening());
System.out.println("Violence/Graphic: " + categories.isViolenceGraphic());
System.out.println("Self-Harm/Intent: " + categories.isSelfHarmIntent());
System.out.println("Self-Harm/Instructions: " + categories.isSelfHarmInstructions());
System.out.println("Harassment/Threatening: " + categories.isHarassmentThreatening());
System.out.println("Violence: " + categories.isViolence());
// Access category scores
CategoryScores scores = result.getCategoryScores();
System.out.println("\nCategory Scores:");
System.out.println("Sexual: " + scores.getSexual());
System.out.println("Hate: " + scores.getHate());
System.out.println("Harassment: " + scores.getHarassment());
System.out.println("Self-Harm: " + scores.getSelfHarm());
System.out.println("Sexual/Minors: " + scores.getSexualMinors());
System.out.println("Hate/Threatening: " + scores.getHateThreatening());
System.out.println("Violence/Graphic: " + scores.getViolenceGraphic());
System.out.println("Self-Harm/Intent: " + scores.getSelfHarmIntent());
System.out.println("Self-Harm/Instructions: " + scores.getSelfHarmInstructions());
System.out.println("Harassment/Threatening: " + scores.getHarassmentThreatening());
System.out.println("Violence: " + scores.getViolence());
}
----
== Manual Configuration
Add the `spring-ai-openai` dependency to your project's Maven `pom.xml` file:
[source,xml]
----
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai</artifactId>
</dependency>
----
or to your Gradle `build.gradle` build file:
[source,groovy]
----
dependencies {
implementation 'org.springframework.ai:spring-ai-openai'
}
----
TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file.
Next, create an OpenAiModerationModel:
[source,java]
----
OpenAiModerationApi openAiModerationApi = new OpenAiModerationApi(System.getenv("OPENAI_API_KEY"));
OpenAiModerationModel openAiModerationModel = new OpenAiModerationModel(openAiModerationApi);
OpenAiModerationOptions moderationOptions = OpenAiModerationOptions.builder()
.withModel("text-moderation-latest")
.build();
ModerationPrompt moderationPrompt = new ModerationPrompt("Text to be moderated", moderationOptions);
ModerationResponse response = openAiModerationModel.call(moderationPrompt);
----
== Example Code
The `OpenAiModerationModelIT` test provides some general examples of how to use the library. You can refer to this test for more detailed usage examples.

View File

@@ -31,9 +31,11 @@ import org.springframework.ai.openai.OpenAiAudioTranscriptionModel;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiEmbeddingModel;
import org.springframework.ai.openai.OpenAiImageModel;
import org.springframework.ai.openai.OpenAiModerationModel;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.openai.api.OpenAiAudioApi;
import org.springframework.ai.openai.api.OpenAiImageApi;
import org.springframework.ai.openai.api.OpenAiModerationApi;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
@@ -66,7 +68,7 @@ import io.micrometer.observation.ObservationRegistry;
@ConditionalOnClass(OpenAiApi.class)
@EnableConfigurationProperties({ OpenAiConnectionProperties.class, OpenAiChatProperties.class,
OpenAiEmbeddingProperties.class, OpenAiImageProperties.class, OpenAiAudioTranscriptionProperties.class,
OpenAiAudioSpeechProperties.class })
OpenAiAudioSpeechProperties.class, OpenAiModerationProperties.class })
@ImportAutoConfiguration(classes = { SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class,
WebClientAutoConfiguration.class })
public class OpenAiAutoConfiguration {
@@ -180,6 +182,22 @@ public class OpenAiAutoConfiguration {
}
@Bean
@ConditionalOnMissingBean
public OpenAiModerationModel openAiModerationClient(OpenAiConnectionProperties commonProperties,
OpenAiModerationProperties moderationProperties, RetryTemplate retryTemplate,
RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) {
ResolvedConnectionProperties resolved = resolveConnectionProperties(commonProperties, moderationProperties,
"moderation");
var openAiModerationApi = new OpenAiModerationApi(resolved.baseUrl, resolved.apiKey(), restClientBuilder,
responseErrorHandler);
return new OpenAiModerationModel(openAiModerationApi, retryTemplate)
.withDefaultOptions(moderationProperties.getOptions());
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = OpenAiAudioSpeechProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true",
@@ -239,4 +257,4 @@ public class OpenAiAutoConfiguration {
private record ResolvedConnectionProperties(String baseUrl, String apiKey, MultiValueMap<String, String> headers) {
}
}
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.openai;
import org.springframework.ai.openai.OpenAiModerationOptions;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.NestedConfigurationProperty;
/**
* OpenAI Moderation autoconfiguration properties.
*
* @author Ahmed Yousri
* @since 0.9.0
*/
@ConfigurationProperties(OpenAiModerationProperties.CONFIG_PREFIX)
public class OpenAiModerationProperties extends OpenAiParentProperties {
public static final String CONFIG_PREFIX = "spring.ai.openai.moderation";
/**
* Options for OpenAI Moderation API.
*/
@NestedConfigurationProperty
private OpenAiModerationOptions options = OpenAiModerationOptions.builder().build();
public OpenAiModerationOptions getOptions() {
return options;
}
public void setOptions(OpenAiModerationOptions options) {
this.options = options;
}
}