diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiModerationModel.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiModerationModel.java new file mode 100644 index 000000000..dbf662af0 --- /dev/null +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiModerationModel.java @@ -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 moderationResponseEntity = this.openAiModerationApi + .createModeration(moderationRequest); + + return convertResponse(moderationResponseEntity, moderationRequest); + }); + } + + private ModerationResponse convertResponse( + ResponseEntity 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 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(); + } + +} diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiModerationOptions.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiModerationOptions.java new file mode 100644 index 000000000..9abacec51 --- /dev/null +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiModerationOptions.java @@ -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; + } + +} diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiModerationApi.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiModerationApi.java new file mode 100644 index 000000000..bb9465e98 --- /dev/null +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiModerationApi.java @@ -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 https://platform.openai.com/docs/api-reference/moderations + */ +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 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 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); + } + +} diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/metadata/OpenAiModerationGenerationMetadata.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/metadata/OpenAiModerationGenerationMetadata.java new file mode 100644 index 000000000..b56226948 --- /dev/null +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/metadata/OpenAiModerationGenerationMetadata.java @@ -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() { + } + +} diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiTestConfiguration.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiTestConfiguration.java index 3eab4ca13..24be2e910 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiTestConfiguration.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiTestConfiguration.java @@ -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; + } + } diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/moderation/OpenAiModerationModelIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/moderation/OpenAiModerationModelIT.java new file mode 100644 index 000000000..ed0658862 --- /dev/null +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/moderation/OpenAiModerationModelIT.java @@ -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(); + + } + +} diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/moderation/OpenAiModerationModelTests.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/moderation/OpenAiModerationModelTests.java new file mode 100644 index 000000000..9d2fd6877 --- /dev/null +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/moderation/OpenAiModerationModelTests.java @@ -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 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); + } + + } + +} diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/testutils/AbstractIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/testutils/AbstractIT.java index ba3a25867..944852435 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/testutils/AbstractIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/testutils/AbstractIT.java @@ -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; diff --git a/spring-ai-core/src/main/java/org/springframework/ai/moderation/Categories.java b/spring-ai-core/src/main/java/org/springframework/ai/moderation/Categories.java new file mode 100644 index 000000000..3a170028e --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/moderation/Categories.java @@ -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 + '}'; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/moderation/CategoryScores.java b/spring-ai-core/src/main/java/org/springframework/ai/moderation/CategoryScores.java new file mode 100644 index 000000000..8429b7834 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/moderation/CategoryScores.java @@ -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 + '}'; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/moderation/Generation.java b/spring-ai-core/src/main/java/org/springframework/ai/moderation/Generation.java new file mode 100644 index 000000000..98a4cf5fd --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/moderation/Generation.java @@ -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 { + + 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 + '}'; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/moderation/Moderation.java b/spring-ai-core/src/main/java/org/springframework/ai/moderation/Moderation.java new file mode 100644 index 000000000..a98b94c72 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/moderation/Moderation.java @@ -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 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 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 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 results) { + this.moderationResultList = results; + return this; + } + + public Moderation build() { + return new Moderation(this); + } + + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationGenerationMetadata.java b/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationGenerationMetadata.java new file mode 100644 index 000000000..f186ec54d --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationGenerationMetadata.java @@ -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 { + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationMessage.java b/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationMessage.java new file mode 100644 index 000000000..455dd695c --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationMessage.java @@ -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); + } + +} \ No newline at end of file diff --git a/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationModel.java b/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationModel.java new file mode 100644 index 000000000..686736910 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationModel.java @@ -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 the type of the moderation prompt + * @param the type of the moderation response + * @author Ahmed Yousri + * @since 1.0.0 + */ +@FunctionalInterface +public interface ModerationModel extends Model { + + ModerationResponse call(ModerationPrompt request); + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationOptions.java b/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationOptions.java new file mode 100644 index 000000000..57ac68f43 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationOptions.java @@ -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(); + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationOptionsBuilder.java b/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationOptionsBuilder.java new file mode 100644 index 000000000..edacf2cba --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationOptionsBuilder.java @@ -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; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationPrompt.java b/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationPrompt.java new file mode 100644 index 000000000..e783cb84f --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationPrompt.java @@ -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 { + + 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); + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationResponse.java b/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationResponse.java new file mode 100644 index 000000000..5da1469f2 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationResponse.java @@ -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 { + + 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 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); + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationResponseMetadata.java b/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationResponseMetadata.java new file mode 100644 index 000000000..785d598c7 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationResponseMetadata.java @@ -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 { + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationResult.java b/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationResult.java new file mode 100644 index 000000000..d7ec33e5d --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/moderation/ModerationResult.java @@ -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 + '}'; + } + +} diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc index 681de5464..2077c27e0 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc @@ -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[] diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/audio/speech/openai-speech.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/audio/speech/openai-speech.adoc index bc3c0e2df..2ed3840e8 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/audio/speech/openai-speech.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/audio/speech/openai-speech.adoc @@ -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. diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/audio/transcriptions/openai-transcriptions.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/audio/transcriptions/openai-transcriptions.adoc index a9dc1e12d..5bfde4f53 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/audio/transcriptions/openai-transcriptions.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/audio/transcriptions/openai-transcriptions.adoc @@ -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. diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/moderation/openai-moderation.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/moderation/openai-moderation.adoc new file mode 100644 index 000000000..82fefe854 --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/moderation/openai-moderation.adoc @@ -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] +---- + + org.springframework.ai + spring-ai-openai-spring-boot-starter + +---- + +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] +---- + + org.springframework.ai + spring-ai-openai + +---- + +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. \ No newline at end of file diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java index 6555faf0a..e0586b50e 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java @@ -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 headers) { } -} +} \ No newline at end of file diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiModerationProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiModerationProperties.java new file mode 100644 index 000000000..d468f591c --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiModerationProperties.java @@ -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; + } + +}