From 3497a1ec7b1aa57141652fb18b94e79063310e45 Mon Sep 17 00:00:00 2001 From: wmz7year Date: Mon, 6 May 2024 22:48:41 +0800 Subject: [PATCH 01/39] Fix Bedrock Cohere embedding truncate type types - fix compilation errors and javadoc --- .../cohere/api/CohereEmbeddingBedrockApi.java | 16 ++++++------ .../api/CohereEmbeddingBedrockApiIT.java | 26 +++++++++++++++++++ ...ockCohereEmbeddingAutoConfigurationIT.java | 4 +-- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/cohere/api/CohereEmbeddingBedrockApi.java b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/cohere/api/CohereEmbeddingBedrockApi.java index 13752cc4486..1ae42422750 100644 --- a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/cohere/api/CohereEmbeddingBedrockApi.java +++ b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/cohere/api/CohereEmbeddingBedrockApi.java @@ -32,7 +32,8 @@ /** * Cohere Embedding API. - * https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-cohere.html#model-parameters-embed + * AWS Bedrock Cohere Embedding API + * Based on the Cohere Embedding API * * @author Christian Tzolov * @author Wei Jiang @@ -151,22 +152,21 @@ public enum InputType { } /** - * Specifies how the API handles inputs longer than the maximum token length. If you specify LEFT or RIGHT, the - * model discards the input until the remaining input is exactly the maximum input token length for the model. + * Specifies how the API handles inputs longer than the maximum token length. Passing START will discard the start of the input. END will discard the end of the input. In both cases, input is discarded until the remaining input is exactly the maximum input token length for the model. */ public enum Truncate { /** - * (Default) Returns an error when the input exceeds the maximum input token length. + * Returns an error when the input exceeds the maximum input token length. */ NONE, /** - * Discard the start of the input. + * Discards the start of the input. */ - LEFT, + START, /** - * Discards the end of the input. + * (default) Discards the end of the input. */ - RIGHT + END } } diff --git a/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/cohere/api/CohereEmbeddingBedrockApiIT.java b/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/cohere/api/CohereEmbeddingBedrockApiIT.java index f96269fed11..83afec90d62 100644 --- a/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/cohere/api/CohereEmbeddingBedrockApiIT.java +++ b/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/cohere/api/CohereEmbeddingBedrockApiIT.java @@ -32,6 +32,7 @@ /** * @author Christian Tzolov + * @author Wei Jiang */ @EnabledIfEnvironmentVariable(named = "AWS_ACCESS_KEY_ID", matches = ".*") @EnabledIfEnvironmentVariable(named = "AWS_SECRET_ACCESS_KEY", matches = ".*") @@ -56,4 +57,29 @@ public void embedText() { assertThat(response.embeddings().get(0)).hasSize(1024); } + @Test + public void embedTextWithTruncate() { + + CohereEmbeddingRequest request = new CohereEmbeddingRequest( + List.of("I like to eat apples", "I like to eat oranges"), + CohereEmbeddingRequest.InputType.SEARCH_DOCUMENT, CohereEmbeddingRequest.Truncate.START); + + CohereEmbeddingResponse response = api.embedding(request); + + assertThat(response).isNotNull(); + assertThat(response.texts()).isEqualTo(request.texts()); + assertThat(response.embeddings()).hasSize(2); + assertThat(response.embeddings().get(0)).hasSize(1024); + + request = new CohereEmbeddingRequest(List.of("I like to eat apples", "I like to eat oranges"), + CohereEmbeddingRequest.InputType.SEARCH_DOCUMENT, CohereEmbeddingRequest.Truncate.END); + + response = api.embedding(request); + + assertThat(response).isNotNull(); + assertThat(response.texts()).isEqualTo(request.texts()); + assertThat(response.embeddings()).hasSize(2); + assertThat(response.embeddings().get(0)).hasSize(1024); + } + } diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereEmbeddingAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereEmbeddingAutoConfigurationIT.java index 040dc25b17a..49498f7c5d5 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereEmbeddingAutoConfigurationIT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/bedrock/cohere/BedrockCohereEmbeddingAutoConfigurationIT.java @@ -90,7 +90,7 @@ public void propertiesTest() { "spring.ai.bedrock.aws.region=" + Region.EU_CENTRAL_1.id(), "spring.ai.bedrock.cohere.embedding.model=MODEL_XYZ", "spring.ai.bedrock.cohere.embedding.options.inputType=CLASSIFICATION", - "spring.ai.bedrock.cohere.embedding.options.truncate=RIGHT") + "spring.ai.bedrock.cohere.embedding.options.truncate=START") .withConfiguration(AutoConfigurations.of(BedrockCohereEmbeddingAutoConfiguration.class)) .run(context -> { var properties = context.getBean(BedrockCohereEmbeddingProperties.class); @@ -101,7 +101,7 @@ public void propertiesTest() { assertThat(properties.getModel()).isEqualTo("MODEL_XYZ"); assertThat(properties.getOptions().getInputType()).isEqualTo(InputType.CLASSIFICATION); - assertThat(properties.getOptions().getTruncate()).isEqualTo(CohereEmbeddingRequest.Truncate.RIGHT); + assertThat(properties.getOptions().getTruncate()).isEqualTo(CohereEmbeddingRequest.Truncate.START); assertThat(awsProperties.getAccessKey()).isEqualTo("ACCESS_KEY"); assertThat(awsProperties.getSecretKey()).isEqualTo("SECRET_KEY"); From 25f91c3297c3d62da007ad9d9f3687377780d6aa Mon Sep 17 00:00:00 2001 From: wmz7year Date: Wed, 8 May 2024 08:52:30 +0800 Subject: [PATCH 02/39] Add AWS Bedrock Amazon Titan Text Premier model support --- .../ai/bedrock/titan/api/TitanChatBedrockApi.java | 7 ++++++- .../ai/bedrock/titan/BedrockTitanChatClientIT.java | 2 +- .../modules/ROOT/pages/api/chat/bedrock/bedrock-titan.adoc | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanChatBedrockApi.java b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanChatBedrockApi.java index f4a219a8d99..78c7cd93127 100644 --- a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanChatBedrockApi.java +++ b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanChatBedrockApi.java @@ -275,7 +275,12 @@ public enum TitanChatModel { /** * amazon.titan-text-express-v1 */ - TITAN_TEXT_EXPRESS_V1("amazon.titan-text-express-v1"); + TITAN_TEXT_EXPRESS_V1("amazon.titan-text-express-v1"), + + /** + * amazon.titan-text-premier-v1:0 + */ + TITAN_TEXT_PREMIER_V1("amazon.titan-text-premier-v1:0"); private final String id; diff --git a/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/titan/BedrockTitanChatClientIT.java b/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/titan/BedrockTitanChatClientIT.java index 54af175be68..3f6b611c95f 100644 --- a/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/titan/BedrockTitanChatClientIT.java +++ b/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/titan/BedrockTitanChatClientIT.java @@ -205,7 +205,7 @@ public static class TestConfiguration { @Bean public TitanChatBedrockApi titanApi() { - return new TitanChatBedrockApi(TitanChatModel.TITAN_TEXT_EXPRESS_V1.id(), + return new TitanChatBedrockApi(TitanChatModel.TITAN_TEXT_PREMIER_V1.id(), EnvironmentVariableCredentialsProvider.create(), Region.US_EAST_1.id(), new ObjectMapper(), Duration.ofMinutes(2)); } diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-titan.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-titan.adoc index 728d3b1e117..7ba332e67a9 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-titan.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-titan.adoc @@ -80,7 +80,7 @@ The prefix `spring.ai.bedrock.titan.chat` is the property prefix that configures |==== Look at the https://github.com/spring-projects/spring-ai/blob/4839a6175cd1ec89498b97d3efb6647022c3c7cb/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanChatBedrockApi.java#L220[TitanChatBedrockApi#TitanChatModel] for other model IDs. -Supported values are: `amazon.titan-text-lite-v1` and `amazon.titan-text-express-v1`. +Supported values are: `amazon.titan-text-lite-v1`, `amazon.titan-text-express-v1` and `amazon.titan-text-premier-v1:0`. Model ID values can also be found in the https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids-arns.html[AWS Bedrock documentation for base model IDs]. TIP: All properties prefixed with `spring.ai.bedrock.titan.chat.options` can be overridden at runtime by adding a request specific <> to the `Prompt` call. From 5beef21a4eca4fff54a4a8395419fbf36acf60cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edd=C3=BA=20Mel=C3=A9ndez?= Date: Mon, 6 May 2024 00:21:03 +0100 Subject: [PATCH 03/39] Expose `QdrantClient` and `WeaviateClient` as beans Currently, `QdrantClient` and `WeaviateClient` are not exposed as beans. Having access to those would benefit to perform operations with an already configured client. - Deprecate QdrantVectorStoreConfig. - Update Qdrant manual config adoc. - Improve Qdrant adoc. - Update Weaviate docs. --- .../ROOT/pages/api/vectordbs/qdrant.adoc | 116 +++++------ .../ROOT/pages/api/vectordbs/weaviate.adoc | 186 +++++++++++------- .../QdrantVectorStoreAutoConfiguration.java | 27 +-- .../WeaviateVectorStoreAutoConfiguration.java | 28 ++- .../vectorstore/qdrant/QdrantVectorStore.java | 66 +------ .../ai/vectorstore/WeaviateVectorStore.java | 72 +------ .../ai/vectorstore/WeaviateVectorStoreIT.java | 9 +- 7 files changed, 231 insertions(+), 273 deletions(-) diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc index a5d4b871a16..5ebaf926e48 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc @@ -14,7 +14,7 @@ To set up `QdrantVectorStore`, you'll need the following information from your Q NOTE: It is recommended that the Qdrant collection is link:https://qdrant.tech/documentation/concepts/collections/#create-a-collection[created] in advance with the appropriate dimensions and configurations. If the collection is not created, the `QdrantVectorStore` will attempt to create one using the `Cosine` similarity and the dimension of the configured `EmbeddingClient`. -== Dependencies +== Auto-configuration Then add the Qdrant boot starter dependency to your project: @@ -96,53 +96,21 @@ vectorStore.add(documents); List results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(5)); ---- -=== Manual Configuration - -Instead of using the Spring Boot auto-configuration, you can manually configure the `QdrantVectorStore`. For this you need to add the `spring-ai-qdrant` dependency to your project: - -[source,xml] ----- - - org.springframework.ai - spring-ai-qdrant - ----- - -or to your Gradle `build.gradle` build file. - -[source,groovy] ----- -dependencies { - implementation 'org.springframework.ai:spring-ai-qdrant' -} ----- - -To configure Qdrant in your application, you can use the following setup: +[[qdrant-vectorstore-properties]] +=== Configuration properties -[source,java] ----- -@Bean -public QdrantVectorStoreConfig qdrantVectorStoreConfig() { - - return QdrantVectorStoreConfig.builder() - .withHost("") - .withPort() - .withCollectionName("") - .withApiKey("") - .build(); -} ----- +You can use the following properties in your Spring Boot configuration to customize the Qdrant vector store. -Integrate with OpenAI's embeddings by adding the Spring Boot OpenAI starter to your project. -This provides you with an implementation of the Embeddings client: +[cols="3,5,1"] +|=== +|Property| Description | Default value -[source,java] ----- -@Bean -public VectorStore vectorStore(QdrantVectorStoreConfig config, EmbeddingClient embeddingClient) { - return new QdrantVectorStore(config, embeddingClient); -} ----- +|`spring.ai.vectorstore.qdrant.host`| The host of the Qdrant server. | localhost +|`spring.ai.vectorstore.qdrant.port`| The gRPC port of the Qdrant server. | 6334 +|`spring.ai.vectorstore.qdrant.api-key`| The API key to use for authentication with the Qdrant server. | - +|`spring.ai.vectorstore.qdrant.collection-name`| The name of the collection to use in Qdrant. | - +|`spring.ai.vectorstore.qdrant.use-tls`| Whether to use TLS(HTTPS). | false +|=== == Metadata filtering @@ -177,18 +145,52 @@ vectorStore.similaritySearch(SearchRequest.defaults() NOTE: These filter expressions are converted into the equivalent Qdrant link:https://qdrant.tech/documentation/concepts/filtering/[filters]. -[[qdrant-vectorstore-properties]] -== Configuration properties +== Manual Configuration -You can use the following properties in your Spring Boot configuration to customize the Qdrant vector store. +Instead of using the Spring Boot auto-configuration, you can manually configure the `QdrantVectorStore`. For this you need to add the `spring-ai-qdrant` dependency to your project: -[cols="3,5,1"] -|=== -|Property| Description | Default value +[source,xml] +---- + + org.springframework.ai + spring-ai-qdrant + +---- -|`spring.ai.vectorstore.qdrant.host`| The host of the Qdrant server. | localhost -|`spring.ai.vectorstore.qdrant.port`| The gRPC port of the Qdrant server. | 6334 -|`spring.ai.vectorstore.qdrant.api-key`| The API key to use for authentication with the Qdrant server. | - -|`spring.ai.vectorstore.qdrant.collection-name`| The name of the collection to use in Qdrant. | - -|`spring.ai.vectorstore.qdrant.use-tls`| Whether to use TLS(HTTPS). | false -|=== +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-qdrant' +} +---- + +To configure Qdrant in your application, you can create a QdrantClient: + +[source,java] +---- +@Bean +public QdrantClient qdrantClient() { + + QdrantGrpcClient.Builder grpcClientBuilder = + QdrantGrpcClient.newBuilder( + "", + , + ); + grpcClientBuilder.withApiKey(""); + + return new QdrantClient(grpcClientBuilder.build()); +} +---- + +Integrate with OpenAI's embeddings by adding the Spring Boot OpenAI starter to your project. +This provides you with an implementation of the Embeddings client: + +[source,java] +---- +@Bean +public QdrantVectorStore vectorStore(EmbeddingClient embeddingClient, QdrantClient qdrantClient) { + return new QdrantVectorStore(qdrantClient, "", embeddingClient); +} +---- diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/weaviate.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/weaviate.adoc index c1ec141fa52..060bb322e1c 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/weaviate.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/weaviate.adoc @@ -19,115 +19,106 @@ It provides tools to store document embeddings, content, and metadata and to sea On startup, the `WeaviateVectorStore` creates the required `SpringAiWeaviate` object schema if it's not already provisioned. -== Dependencies +== Auto-configuration -Add these dependencies to your project: - -* Embedding Client boot starter, required for calculating embeddings. - -* Transformers Embedding (Local) and follow the ONNX Transformers Embedding instructions. +Then add the WeaviateVectorStore boot starter dependency to your project: [source,xml] ---- - org.springframework.ai - spring-ai-transformers-spring-boot-starter + org.springframework.ai + spring-ai-weaviate-store-spring-boot-starter ---- -or use OpenAI (Cloud) +or to your Gradle `build.gradle` build file. -[source,xml] +[source,groovy] ---- - - org.springframework.ai - spring-ai-openai-spring-boot-starter - +dependencies { + implementation 'org.springframework.ai:spring-ai-weaviate-store-spring-boot-starter' +} ---- -You'll need to provide your OpenAI API Key. Set it as an environment variable like so: +The Vector Store, also requires an `EmbeddingClient` instance to calculate embeddings for the documents. +You can pick one of the available xref:api/embeddings.adoc#available-implementations[EmbeddingClient Implementations]. -[source,bash] ----- -export SPRING_AI_OPENAI_API_KEY='Your_OpenAI_API_Key' ----- - -* Add the Weaviate VectorStore dependency +For example to use the xref:api/embeddings/openai-embeddings.adoc[OpenAI EmbeddingClient] add the following dependency to your project: [source,xml] ---- - org.springframework.ai - spring-ai-weaviate-store + 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. +or to your Gradle `build.gradle` build file. -== Usage - -Create a WeaviateVectorStore instance connected to the local Weaviate cluster: - -[source,java] +[source,groovy] ---- -@Bean -public VectorStore vectorStore(EmbeddingClient embeddingClient) { - WeaviateVectorStoreConfig config = WeaviateVectorStoreConfig.builder() - .withScheme("http") - .withHost("localhost:8080") - // Define the metadata fields to be used - // in the similarity search filters. - .withFilterableMetadataFields(List.of( - MetadataField.text("country"), - MetadataField.number("year"), - MetadataField.bool("active"))) - // Consistency level can be: ONE, QUORUM, or ALL. - .withConsistencyLevel(ConsistentLevel.ONE) - .build(); - - return new WeaviateVectorStore(config, embeddingClient); +dependencies { + implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter' } ---- -> [NOTE] -> You must list explicitly all metadata field names and types (`BOOLEAN`, `TEXT`, or `NUMBER`) for any metadata key used in filter expression. -> The `withFilterableMetadataKeys` above registers filterable metadata fields: `country` of type `TEXT`, `year` of type `NUMBER`, and `active` of type `BOOLEAN`. -> -> If the filterable metadata fields are expanded with new entries, you have to (re)upload/update the documents with this metadata. -> -> You can use the following Weaviate link:https://weaviate.io/developers/weaviate/api/graphql/filters#special-cases[system metadata] fields without explicit definition: `id`, `_creationTimeUnix`, and `_lastUpdateTimeUnix`. +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. +Refer to the xref:getting-started.adoc#repositories[Repositories] section to add Milestone and/or Snapshot Repositories to your build file. -Then in your main code, create some documents: +To connect to Weaviate and use the `WeaviateVectorStore`, you need to provide access details for your instance. +A simple configuration can either be provided via Spring Boot's _application.properties_, -[source,java] +[source,properties] ---- -List documents = List.of( - new Document("Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!!", Map.of("country", "UK", "active", true, "year", 2020)), - new Document("The World is Big and Salvation Lurks Around the Corner", Map.of()), - new Document("You walk forward facing the past and you turn back toward the future.", Map.of("country", "NL", "active", false, "year", 2023))); +spring.ai.vectorstore.weaviate.host= +spring.ai.vectorstore.weaviate.api-key= +spring.ai.vectorstore.weaviate.scheme=http + +# API key if needed, e.g. OpenAI +spring.ai.openai.api.key= ---- -Now add the documents to your vector store: +TIP: Check the list of xref:#weaviate-vectorstore-properties[configuration parameters] to learn about the default values and configuration options. +Now you can Auto-wire the Weaviate Vector Store in your application and use it [source,java] ---- -vectorStore.add(List.of(document)); ----- +@Autowired VectorStore vectorStore; -And finally, retrieve documents similar to a query: +// ... -[source,java] ----- -List results = vectorStore.similaritySearch( - SearchRequest - .query("Spring") - .withTopK(5)); +List documents = List.of( + new Document("Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!!", Map.of("meta1", "meta1")), + new Document("The World is Big and Salvation Lurks Around the Corner"), + new Document("You walk forward facing the past and you turn back toward the future.", Map.of("meta2", "meta2"))); + +// Add the documents +vectorStore.add(documents); + +// Retrieve documents similar to a query +List results = vectorStore.similaritySearch(SearchRequest.query("Spring").withTopK(5)); ---- -If all goes well, you should retrieve the document containing the text "Spring AI rocks!!". +[[weaviate-vectorstore-properties]] +=== Configuration properties + +You can use the following properties in your Spring Boot configuration to customize the weaviate vector store. -=== Metadata filtering +[cols="3,5,1"] +|=== +|Property| Description | Default value + +|`spring.ai.vectorstore.weaviate.host`| The host of the Weaviate server. | localhost:8080 +|`spring.ai.vectorstore.weaviate.scheme`| Connection schema. | http +|`spring.ai.vectorstore.weaviate.api-key`| The API key to use for authentication with the Weaviate server. | - +|`spring.ai.vectorstore.weaviate.object-class`| | "SpringAiWeaviate" +|`spring.ai.vectorstore.weaviate.consistency-level`| Desired tradeoff between consistency and speed | ConsistentLevel.ONE +|`spring.ai.vectorstore.weaviate.filter-field`| spring.ai.vectorstore.weaviate.filter-field.= | - +|`spring.ai.vectorstore.weaviate.headers`| | - +|=== + +== Metadata filtering You can leverage the generic, portable link:https://docs.spring.io/spring-ai/reference/api/vectordbs.html#_metadata_filters[metadata filters] with WeaviateVectorStore as well. @@ -194,6 +185,61 @@ operator:And }] ---- +== Manual Configuration + +Instead of using the Spring Boot auto-configuration, you can manually configure the `WeaviateVectorStore`. +For this you need to add the `spring-ai-weaviate-store` dependency to your project: + +[source,xml] +---- + + org.springframework.ai + spring-ai-weaviate-store + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-weaviate-store' +} +---- + +To configure Weaviate in your application, you can create a WeaviateClient: + +[source,java] +---- +@Bean +public WeaviateClient weaviateClient() { + try { + return WeaviateAuthClient.apiKey( + new Config(, , ), + ); + } + catch (AuthException e) { + throw new IllegalArgumentException("WeaviateClient could not be created.", e); + } +} +---- + +Integrate with OpenAI's embeddings by adding the Spring Boot OpenAI starter to your project. +This provides you with an implementation of the Embeddings client: + +[source,java] +---- +@Bean +public WeaviateVectorStore vectorStore(EmbeddingClient embeddingClient, WeaviateClient weaviateClient) { + + WeaviateVectorStoreConfig.Builder configBuilder = WeaviateVectorStore.WeaviateVectorStoreConfig.builder() + .withObjectClass() + .withConsistencyLevel(); + + return new WeaviateVectorStore(configBuilder.build(), embeddingClient, weaviateClient); +} +---- + == Run Weaviate cluster in docker container Start Weaviate in a docker container: diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreAutoConfiguration.java index 5540f297760..c0dd23640a7 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreAutoConfiguration.java @@ -15,9 +15,10 @@ */ package org.springframework.ai.autoconfigure.vectorstore.qdrant; +import io.qdrant.client.QdrantClient; +import io.qdrant.client.QdrantGrpcClient; import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.vectorstore.qdrant.QdrantVectorStore; -import org.springframework.ai.vectorstore.qdrant.QdrantVectorStore.QdrantVectorStoreConfig; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -42,21 +43,25 @@ PropertiesQdrantConnectionDetails qdrantConnectionDetails(QdrantVectorStorePrope @Bean @ConditionalOnMissingBean - public QdrantVectorStore vectorStore(EmbeddingClient embeddingClient, QdrantVectorStoreProperties properties, + public QdrantClient qdrantClient(QdrantVectorStoreProperties properties, QdrantConnectionDetails connectionDetails) { + QdrantGrpcClient.Builder grpcClientBuilder = QdrantGrpcClient.newBuilder(connectionDetails.getHost(), + connectionDetails.getPort(), properties.isUseTls()); - var config = QdrantVectorStoreConfig.builder() - .withCollectionName(properties.getCollectionName()) - .withHost(connectionDetails.getHost()) - .withPort(connectionDetails.getPort()) - .withTls(properties.isUseTls()) - .withApiKey(properties.getApiKey()) - .build(); + if (properties.getApiKey() != null) { + grpcClientBuilder.withApiKey(properties.getApiKey()); + } + return new QdrantClient(grpcClientBuilder.build()); + } - return new QdrantVectorStore(config, embeddingClient); + @Bean + @ConditionalOnMissingBean + public QdrantVectorStore vectorStore(EmbeddingClient embeddingClient, QdrantVectorStoreProperties properties, + QdrantClient qdrantClient) { + return new QdrantVectorStore(qdrantClient, properties.getCollectionName(), embeddingClient); } - private static class PropertiesQdrantConnectionDetails implements QdrantConnectionDetails { + static class PropertiesQdrantConnectionDetails implements QdrantConnectionDetails { private final QdrantVectorStoreProperties properties; diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/weaviate/WeaviateVectorStoreAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/weaviate/WeaviateVectorStoreAutoConfiguration.java index 3d5c84534fe..431b7072323 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/weaviate/WeaviateVectorStoreAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/weaviate/WeaviateVectorStoreAutoConfiguration.java @@ -15,6 +15,10 @@ */ package org.springframework.ai.autoconfigure.vectorstore.weaviate; +import io.weaviate.client.Config; +import io.weaviate.client.WeaviateAuthClient; +import io.weaviate.client.WeaviateClient; +import io.weaviate.client.v1.auth.exception.AuthException; import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.vectorstore.WeaviateVectorStore; import org.springframework.ai.vectorstore.WeaviateVectorStore.WeaviateVectorStoreConfig; @@ -42,14 +46,24 @@ public PropertiesWeaviateConnectionDetails weaviateConnectionDetails(WeaviateVec @Bean @ConditionalOnMissingBean - public WeaviateVectorStore vectorStore(EmbeddingClient embeddingClient, WeaviateVectorStoreProperties properties, + public WeaviateClient weaviateClient(WeaviateVectorStoreProperties properties, WeaviateConnectionDetails connectionDetails) { + try { + return WeaviateAuthClient.apiKey( + new Config(properties.getScheme(), connectionDetails.getHost(), properties.getHeaders()), + properties.getApiKey()); + } + catch (AuthException e) { + throw new IllegalArgumentException("WeaviateClient could not be created.", e); + } + } + + @Bean + @ConditionalOnMissingBean + public WeaviateVectorStore vectorStore(EmbeddingClient embeddingClient, WeaviateClient weaviateClient, + WeaviateVectorStoreProperties properties) { WeaviateVectorStoreConfig.Builder configBuilder = WeaviateVectorStore.WeaviateVectorStoreConfig.builder() - .withScheme(properties.getScheme()) - .withApiKey(properties.getApiKey()) - .withHost(connectionDetails.getHost()) - .withHeaders(properties.getHeaders()) .withObjectClass(properties.getObjectClass()) .withFilterableMetadataFields(properties.getFilterField() .entrySet() @@ -58,10 +72,10 @@ public WeaviateVectorStore vectorStore(EmbeddingClient embeddingClient, Weaviate .toList()) .withConsistencyLevel(properties.getConsistencyLevel()); - return new WeaviateVectorStore(configBuilder.build(), embeddingClient); + return new WeaviateVectorStore(configBuilder.build(), embeddingClient, weaviateClient); } - private static class PropertiesWeaviateConnectionDetails implements WeaviateConnectionDetails { + static class PropertiesWeaviateConnectionDetails implements WeaviateConnectionDetails { private final WeaviateVectorStoreProperties properties; diff --git a/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java b/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java index afe5f638982..67a651bad7e 100644 --- a/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java +++ b/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java @@ -34,7 +34,6 @@ import org.springframework.util.Assert; import io.qdrant.client.QdrantClient; -import io.qdrant.client.QdrantGrpcClient; import io.qdrant.client.grpc.Collections.Distance; import io.qdrant.client.grpc.Collections.VectorParams; import io.qdrant.client.grpc.JsonWithInt.Value; @@ -51,6 +50,7 @@ * * @author Anush Shetty * @author Christian Tzolov + * @author Eddú Meléndez * @since 0.8.1 */ public class QdrantVectorStore implements VectorStore, InitializingBean { @@ -69,13 +69,14 @@ public class QdrantVectorStore implements VectorStore, InitializingBean { /** * Configuration class for the QdrantVectorStore. + * + * @deprecated since 1.0.0 in favor of {@link QdrantVectorStore}. */ + @Deprecated(since = "1.0.0", forRemoval = true) public static final class QdrantVectorStoreConfig { private final String collectionName; - private QdrantClient qdrantClient; - /* * Constructor using the builder. * @@ -83,15 +84,6 @@ public static final class QdrantVectorStoreConfig { */ private QdrantVectorStoreConfig(Builder builder) { this.collectionName = builder.collectionName; - - QdrantGrpcClient.Builder grpcClientBuilder = QdrantGrpcClient.newBuilder(builder.host, builder.port, - builder.useTls); - - if (builder.apiKey != null) { - grpcClientBuilder.withApiKey(builder.apiKey); - } - - this.qdrantClient = new QdrantClient(grpcClientBuilder.build()); } /** @@ -113,26 +105,9 @@ public static class Builder { private String collectionName; - private String host = "localhost"; - - private int port = 6334; - - private boolean useTls = false; - - private String apiKey = null; - private Builder() { } - /** - * @param host The host of the Qdrant instance. Defaults to "localhost". - */ - public Builder withHost(String host) { - Assert.notNull(host, "host cannot be null"); - this.host = host; - return this; - } - /** * @param collectionName REQUIRED. The name of the collection. */ @@ -141,32 +116,6 @@ public Builder withCollectionName(String collectionName) { return this; } - /** - * @param port The GRPC port of the Qdrant instance. Defaults to 6334. - * @return - */ - public Builder withPort(int port) { - this.port = port; - return this; - } - - /** - * @param useTls Whether to use TLS(HTTPS). Defaults to false. - * @return - */ - public Builder withTls(boolean useTls) { - this.useTls = useTls; - return this; - } - - /** - * @param apiKey The Qdrant API key to authenticate with. Defaults to null. - */ - public Builder withApiKey(String apiKey) { - this.apiKey = apiKey; - return this; - } - /** * {@return the immutable configuration} */ @@ -183,9 +132,12 @@ public QdrantVectorStoreConfig build() { * Constructs a new QdrantVectorStore. * @param config The configuration for the store. * @param embeddingClient The client for embedding operations. + * @deprecated since 1.0.0 in favor of {@link QdrantVectorStore}. */ - public QdrantVectorStore(QdrantVectorStoreConfig config, EmbeddingClient embeddingClient) { - this(config.qdrantClient, config.collectionName, embeddingClient); + @Deprecated(since = "1.0.0", forRemoval = true) + public QdrantVectorStore(QdrantClient qdrantClient, QdrantVectorStoreConfig config, + EmbeddingClient embeddingClient) { + this(qdrantClient, config.collectionName, embeddingClient); } /** diff --git a/vector-stores/spring-ai-weaviate/src/main/java/org/springframework/ai/vectorstore/WeaviateVectorStore.java b/vector-stores/spring-ai-weaviate/src/main/java/org/springframework/ai/vectorstore/WeaviateVectorStore.java index d686db95868..6e56e8e95f1 100644 --- a/vector-stores/spring-ai-weaviate/src/main/java/org/springframework/ai/vectorstore/WeaviateVectorStore.java +++ b/vector-stores/spring-ai-weaviate/src/main/java/org/springframework/ai/vectorstore/WeaviateVectorStore.java @@ -25,12 +25,9 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import io.weaviate.client.Config; -import io.weaviate.client.WeaviateAuthClient; import io.weaviate.client.WeaviateClient; import io.weaviate.client.base.Result; import io.weaviate.client.base.WeaviateErrorMessage; -import io.weaviate.client.v1.auth.exception.AuthException; import io.weaviate.client.v1.batch.model.BatchDeleteResponse; import io.weaviate.client.v1.batch.model.ObjectGetResponse; import io.weaviate.client.v1.data.model.WeaviateObject; @@ -63,6 +60,7 @@ * expression filters. * * @author Christian Tzolov + * @author Eddú Meléndez */ public class WeaviateVectorStore implements VectorStore, InitializingBean { @@ -169,18 +167,6 @@ public enum ConsistentLevel { } - /** - * The server api key. - */ - private final String apiKey; - - /** - * The URL scheme, such as 'http' or 'https'. - */ - private final String scheme; - - private final String host; - private final String weaviateObjectClass; private final ConsistentLevel consistencyLevel; @@ -199,9 +185,6 @@ public enum ConsistentLevel { * @param builder The configuration builder. */ public WeaviateVectorStoreConfig(Builder builder) { - this.apiKey = builder.apiKey; - this.scheme = builder.scheme; - this.host = builder.host; this.weaviateObjectClass = builder.objectClass; this.consistencyLevel = builder.consistencyLevel; this.filterMetadataFields = builder.filterMetadataFields; @@ -225,12 +208,6 @@ public static WeaviateVectorStoreConfig defaultConfig() { public static class Builder { - private String apiKey = ""; - - private String scheme = "http"; - - private String host = "localhost:8080"; - private String objectClass = "SpringAiWeaviate"; private ConsistentLevel consistencyLevel = WeaviateVectorStoreConfig.ConsistentLevel.ONE; @@ -242,39 +219,6 @@ public static class Builder { private Builder() { } - /** - * Weaviate api key. - * @param apiKey key to use. - * @return this builder. - */ - public Builder withApiKey(String apiKey) { - Assert.notNull(apiKey, "The apiKey can not be null."); - this.apiKey = apiKey; - return this; - } - - /** - * Weaviate scheme. - * @param scheme scheme to use. - * @return this builder. - */ - public Builder withScheme(String scheme) { - Assert.hasText(scheme, "The scheme can not be empty."); - this.scheme = scheme; - return this; - } - - /** - * Weaviate host. - * @param host host to use. - * @return this builder. - */ - public Builder withHost(String host) { - Assert.hasText(host, "The host can not be empty."); - this.host = host; - return this; - } - /** * Weaviate known, filterable metadata fields. * @param filterMetadataFields known metadata fields to use. @@ -335,7 +279,8 @@ public WeaviateVectorStoreConfig build() { * @param vectorStoreConfig The configuration for the store. * @param embeddingClient The client for embedding operations. */ - public WeaviateVectorStore(WeaviateVectorStoreConfig vectorStoreConfig, EmbeddingClient embeddingClient) { + public WeaviateVectorStore(WeaviateVectorStoreConfig vectorStoreConfig, EmbeddingClient embeddingClient, + WeaviateClient weaviateClient) { Assert.notNull(vectorStoreConfig, "WeaviateVectorStoreConfig must not be null"); Assert.notNull(embeddingClient, "EmbeddingClient must not be null"); @@ -345,16 +290,7 @@ public WeaviateVectorStore(WeaviateVectorStoreConfig vectorStoreConfig, Embeddin this.filterMetadataFields = vectorStoreConfig.filterMetadataFields; this.filterExpressionConverter = new WeaviateFilterExpressionConverter( this.filterMetadataFields.stream().map(MetadataField::name).toList()); - - try { - this.weaviateClient = WeaviateAuthClient.apiKey( - new Config(vectorStoreConfig.scheme, vectorStoreConfig.host, vectorStoreConfig.headers), - vectorStoreConfig.apiKey); - } - catch (AuthException e) { - throw new IllegalArgumentException(e); - } - + this.weaviateClient = weaviateClient; this.weaviateSimilaritySearchFields = buildWeaviateSimilaritySearchFields(); } diff --git a/vector-stores/spring-ai-weaviate/src/test/java/org/springframework/ai/vectorstore/WeaviateVectorStoreIT.java b/vector-stores/spring-ai-weaviate/src/test/java/org/springframework/ai/vectorstore/WeaviateVectorStoreIT.java index 9b90532573f..013ebfaa478 100644 --- a/vector-stores/spring-ai-weaviate/src/test/java/org/springframework/ai/vectorstore/WeaviateVectorStoreIT.java +++ b/vector-stores/spring-ai-weaviate/src/test/java/org/springframework/ai/vectorstore/WeaviateVectorStoreIT.java @@ -22,6 +22,8 @@ import java.util.Map; import java.util.UUID; +import io.weaviate.client.Config; +import io.weaviate.client.WeaviateClient; import org.junit.jupiter.api.Test; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; @@ -242,14 +244,15 @@ public static class TestApplication { @Bean public VectorStore vectorStore(EmbeddingClient embeddingClient) { + WeaviateClient weaviateClient = new WeaviateClient( + new Config("http", weaviateContainer.getHttpHostAddress())); + WeaviateVectorStoreConfig config = WeaviateVectorStore.WeaviateVectorStoreConfig.builder() - .withScheme("http") - .withHost(weaviateContainer.getHttpHostAddress()) .withFilterableMetadataFields(List.of(MetadataField.text("country"), MetadataField.number("year"))) .withConsistencyLevel(WeaviateVectorStoreConfig.ConsistentLevel.ONE) .build(); - WeaviateVectorStore vectorStore = new WeaviateVectorStore(config, embeddingClient); + WeaviateVectorStore vectorStore = new WeaviateVectorStore(config, embeddingClient, weaviateClient); return vectorStore; } From cae045d52bd9d0005273e297763f25c65077ea38 Mon Sep 17 00:00:00 2001 From: mck Date: Sun, 5 May 2024 19:53:22 +0200 Subject: [PATCH 04/39] Add the autoconfigure property for the CassandraVectorStore option to return embeddings in documents from similarity searches ref: https://github.com/spring-projects/spring-ai/pull/673 --- .../CassandraVectorStoreAutoConfiguration.java | 3 +++ .../cassandra/CassandraVectorStoreProperties.java | 12 +++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreAutoConfiguration.java index 23a077ee340..f9e760169c9 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreAutoConfiguration.java @@ -57,6 +57,9 @@ public CassandraVectorStore vectorStore(EmbeddingClient embeddingClient, Cassand if (properties.getDisallowSchemaCreation()) { builder = builder.disallowSchemaChanges(); } + if (properties.getReturnEmbeddings()) { + builder = builder.returnEmbeddings(); + } return new CassandraVectorStore(builder.build(), embeddingClient); } diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreProperties.java index 27af7605e38..9725e75592f 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreProperties.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/cassandra/CassandraVectorStoreProperties.java @@ -41,6 +41,8 @@ public class CassandraVectorStoreProperties { private boolean disallowSchemaChanges = false; + private boolean returnEmbeddings = false; + private int fixedThreadPoolExecutorSize = CassandraVectorStoreConfig.DEFAULT_ADD_CONCURRENCY; public String getKeyspace() { @@ -83,7 +85,7 @@ public void setEmbeddingColumnName(String embeddingColumnName) { this.embeddingColumnName = embeddingColumnName; } - public Boolean getDisallowSchemaCreation() { + public boolean getDisallowSchemaCreation() { return this.disallowSchemaChanges; } @@ -91,6 +93,14 @@ public void setDisallowSchemaCreation(boolean disallowSchemaCreation) { this.disallowSchemaChanges = disallowSchemaCreation; } + public boolean getReturnEmbeddings() { + return this.returnEmbeddings; + } + + public void setReturnEmbeddings(boolean returnEmbeddings) { + this.returnEmbeddings = returnEmbeddings; + } + public int getFixedThreadPoolExecutorSize() { return this.fixedThreadPoolExecutorSize; } From 220ec7f63197ee08d4815c0acbf90c1272c01d1f Mon Sep 17 00:00:00 2001 From: wmz7year Date: Wed, 1 May 2024 13:03:23 +0800 Subject: [PATCH 05/39] AWS Bedrock Titan embedding model add amazon.titan-embed-text-v2:0 support. --- .../titan/api/TitanEmbeddingBedrockApi.java | 6 +++- .../titan/api/TitanEmbeddingBedrockApiIT.java | 29 +++++++++++++++++-- .../embeddings/bedrock-titan-embedding.adoc | 2 +- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanEmbeddingBedrockApi.java b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanEmbeddingBedrockApi.java index 5901799c32f..016ec4306b0 100644 --- a/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanEmbeddingBedrockApi.java +++ b/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/api/TitanEmbeddingBedrockApi.java @@ -153,7 +153,11 @@ public enum TitanEmbeddingModel { /** * amazon.titan-embed-text-v1 */ - TITAN_EMBED_TEXT_V1("amazon.titan-embed-text-v1"); + TITAN_EMBED_TEXT_V1("amazon.titan-embed-text-v1"), + /** + * amazon.titan-embed-text-v2 + */ + TITAN_EMBED_TEXT_V2("amazon.titan-embed-text-v2:0");; private final String id; diff --git a/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/titan/api/TitanEmbeddingBedrockApiIT.java b/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/titan/api/TitanEmbeddingBedrockApiIT.java index a666793e0c7..cffc0056990 100644 --- a/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/titan/api/TitanEmbeddingBedrockApiIT.java +++ b/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/titan/api/TitanEmbeddingBedrockApiIT.java @@ -21,6 +21,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider; import software.amazon.awssdk.regions.Region; import org.springframework.ai.bedrock.titan.api.TitanEmbeddingBedrockApi.TitanEmbeddingModel; @@ -28,20 +30,24 @@ import org.springframework.ai.bedrock.titan.api.TitanEmbeddingBedrockApi.TitanEmbeddingResponse; import org.springframework.core.io.DefaultResourceLoader; +import com.fasterxml.jackson.databind.ObjectMapper; + import static org.assertj.core.api.Assertions.assertThat; /** * @author Christian Tzolov + * @author Wei Jiang */ @EnabledIfEnvironmentVariable(named = "AWS_ACCESS_KEY_ID", matches = ".*") @EnabledIfEnvironmentVariable(named = "AWS_SECRET_ACCESS_KEY", matches = ".*") public class TitanEmbeddingBedrockApiIT { @Test - public void embedText() { + public void embedTextV1() { TitanEmbeddingBedrockApi titanEmbedApi = new TitanEmbeddingBedrockApi( - TitanEmbeddingModel.TITAN_EMBED_TEXT_V1.id(), Region.US_EAST_1.id(), Duration.ofMinutes(2)); + TitanEmbeddingModel.TITAN_EMBED_TEXT_V1.id(), EnvironmentVariableCredentialsProvider.create(), + Region.US_EAST_1.id(), new ObjectMapper(), Duration.ofMinutes(2)); TitanEmbeddingRequest request = TitanEmbeddingRequest.builder().withInputText("I like to eat apples.").build(); @@ -52,11 +58,28 @@ public void embedText() { assertThat(response.embedding()).hasSize(1536); } + @Test + public void embedTextV2() { + + TitanEmbeddingBedrockApi titanEmbedApi = new TitanEmbeddingBedrockApi( + TitanEmbeddingModel.TITAN_EMBED_TEXT_V2.id(), EnvironmentVariableCredentialsProvider.create(), + Region.US_WEST_2.id(), new ObjectMapper(), Duration.ofMinutes(2)); + + TitanEmbeddingRequest request = TitanEmbeddingRequest.builder().withInputText("I like to eat apples.").build(); + + TitanEmbeddingResponse response = titanEmbedApi.embedding(request); + + assertThat(response).isNotNull(); + assertThat(response.inputTextTokenCount()).isEqualTo(7); + assertThat(response.embedding()).hasSize(1024); + } + @Test public void embedImage() throws IOException { TitanEmbeddingBedrockApi titanEmbedApi = new TitanEmbeddingBedrockApi( - TitanEmbeddingModel.TITAN_EMBED_IMAGE_V1.id(), Region.US_EAST_1.id(), Duration.ofMinutes(2)); + TitanEmbeddingModel.TITAN_EMBED_IMAGE_V1.id(), EnvironmentVariableCredentialsProvider.create(), + Region.US_EAST_1.id(), new ObjectMapper(), Duration.ofMinutes(2)); byte[] image = new DefaultResourceLoader().getResource("classpath:/spring_framework.png") .getContentAsByteArray(); diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/bedrock-titan-embedding.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/bedrock-titan-embedding.adoc index b7bc8a74eb7..e1a5a6600f3 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/bedrock-titan-embedding.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/bedrock-titan-embedding.adoc @@ -78,7 +78,7 @@ The prefix `spring.ai.bedrock.titan.embedding` (defined in `BedrockTitanEmbeddin | spring.ai.bedrock.titan.embedding.model | The model id to use. See the `TitanEmbeddingModel` for the supported models. | amazon.titan-embed-image-v1 |==== -Supported values are: `amazon.titan-embed-image-v1` and `amazon.titan-embed-text-v1`. +Supported values are: `amazon.titan-embed-image-v1`, `amazon.titan-embed-text-v1` and `amazon.titan-embed-text-v2:0`. Model ID values can also be found in the https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids-arns.html[AWS Bedrock documentation for base model IDs]. == Runtime Options [[embedding-options]] From 93bd5021a1b79e9e657b487645e1591d4e8bf7fb Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Sat, 11 May 2024 13:22:35 +0300 Subject: [PATCH 06/39] Improve Azure OpenAI options merging logic --- .../azure/openai/AzureOpenAiChatClient.java | 181 ++++++++++++------ .../AzureChatCompletionsOptionsTests.java | 51 ++++- 2 files changed, 167 insertions(+), 65 deletions(-) diff --git a/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiChatClient.java b/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiChatClient.java index 1ddc732fc60..236a902d84a 100644 --- a/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiChatClient.java +++ b/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiChatClient.java @@ -277,120 +277,135 @@ private List nullSafeList(List list) { return list != null ? list : Collections.emptyList(); } - // JSON merge doesn't due to Azure OpenAI service bug: - // https://github.com/Azure/azure-sdk-for-java/issues/38183 - private ChatCompletionsOptions merge(ChatCompletionsOptions azureOptions, AzureOpenAiChatOptions springAiOptions) { + /** + * Merges the Azure's {@link ChatCompletionsOptions} (fromAzureOptions) into the + * Spring AI's {@link AzureOpenAiChatOptions} (toSpringAiOptions) and return a new + * {@link ChatCompletionsOptions} instance. + */ + private ChatCompletionsOptions merge(ChatCompletionsOptions fromAzureOptions, + AzureOpenAiChatOptions toSpringAiOptions) { - if (springAiOptions == null) { - return azureOptions; + if (toSpringAiOptions == null) { + return fromAzureOptions; } - ChatCompletionsOptions mergedAzureOptions = new ChatCompletionsOptions(azureOptions.getMessages()); - mergedAzureOptions.setStream(azureOptions.isStream()); + ChatCompletionsOptions mergedAzureOptions = new ChatCompletionsOptions(fromAzureOptions.getMessages()); + mergedAzureOptions.setStream(fromAzureOptions.isStream()); - mergedAzureOptions.setMaxTokens( - (azureOptions.getMaxTokens() != null) ? azureOptions.getMaxTokens() : springAiOptions.getMaxTokens()); + mergedAzureOptions.setMaxTokens((fromAzureOptions.getMaxTokens() != null) ? fromAzureOptions.getMaxTokens() + : toSpringAiOptions.getMaxTokens()); - mergedAzureOptions.setLogitBias( - azureOptions.getLogitBias() != null ? azureOptions.getLogitBias() : springAiOptions.getLogitBias()); + mergedAzureOptions.setLogitBias(fromAzureOptions.getLogitBias() != null ? fromAzureOptions.getLogitBias() + : toSpringAiOptions.getLogitBias()); - mergedAzureOptions.setStop(azureOptions.getStop() != null ? azureOptions.getStop() : springAiOptions.getStop()); + mergedAzureOptions + .setStop(fromAzureOptions.getStop() != null ? fromAzureOptions.getStop() : toSpringAiOptions.getStop()); - mergedAzureOptions.setTemperature(azureOptions.getTemperature()); - if (mergedAzureOptions.getTemperature() == null && springAiOptions.getTemperature() != null) { - mergedAzureOptions.setTemperature(springAiOptions.getTemperature().doubleValue()); + mergedAzureOptions.setTemperature(fromAzureOptions.getTemperature()); + if (mergedAzureOptions.getTemperature() == null && toSpringAiOptions.getTemperature() != null) { + mergedAzureOptions.setTemperature(toSpringAiOptions.getTemperature().doubleValue()); } - mergedAzureOptions.setTopP(azureOptions.getTopP()); - if (mergedAzureOptions.getTopP() == null && springAiOptions.getTopP() != null) { - mergedAzureOptions.setTopP(springAiOptions.getTopP().doubleValue()); + mergedAzureOptions.setTopP(fromAzureOptions.getTopP()); + if (mergedAzureOptions.getTopP() == null && toSpringAiOptions.getTopP() != null) { + mergedAzureOptions.setTopP(toSpringAiOptions.getTopP().doubleValue()); } - mergedAzureOptions.setFrequencyPenalty(azureOptions.getFrequencyPenalty()); - if (mergedAzureOptions.getFrequencyPenalty() == null && springAiOptions.getFrequencyPenalty() != null) { - mergedAzureOptions.setFrequencyPenalty(springAiOptions.getFrequencyPenalty().doubleValue()); + mergedAzureOptions.setFrequencyPenalty(fromAzureOptions.getFrequencyPenalty()); + if (mergedAzureOptions.getFrequencyPenalty() == null && toSpringAiOptions.getFrequencyPenalty() != null) { + mergedAzureOptions.setFrequencyPenalty(toSpringAiOptions.getFrequencyPenalty().doubleValue()); } - mergedAzureOptions.setPresencePenalty(azureOptions.getPresencePenalty()); - if (mergedAzureOptions.getPresencePenalty() == null && springAiOptions.getPresencePenalty() != null) { - mergedAzureOptions.setPresencePenalty(springAiOptions.getPresencePenalty().doubleValue()); + mergedAzureOptions.setPresencePenalty(fromAzureOptions.getPresencePenalty()); + if (mergedAzureOptions.getPresencePenalty() == null && toSpringAiOptions.getPresencePenalty() != null) { + mergedAzureOptions.setPresencePenalty(toSpringAiOptions.getPresencePenalty().doubleValue()); } - mergedAzureOptions.setN(azureOptions.getN() != null ? azureOptions.getN() : springAiOptions.getN()); - - mergedAzureOptions.setUser(azureOptions.getUser() != null ? azureOptions.getUser() : springAiOptions.getUser()); + mergedAzureOptions.setN(fromAzureOptions.getN() != null ? fromAzureOptions.getN() : toSpringAiOptions.getN()); mergedAzureOptions - .setModel(azureOptions.getModel() != null ? azureOptions.getModel() : springAiOptions.getDeploymentName()); + .setUser(fromAzureOptions.getUser() != null ? fromAzureOptions.getUser() : toSpringAiOptions.getUser()); + + mergedAzureOptions.setModel(fromAzureOptions.getModel() != null ? fromAzureOptions.getModel() + : toSpringAiOptions.getDeploymentName()); return mergedAzureOptions; } - // JSON merge doesn't due to Azure OpenAI service bug: - // https://github.com/Azure/azure-sdk-for-java/issues/38183 - private ChatCompletionsOptions merge(AzureOpenAiChatOptions springAiOptions, ChatCompletionsOptions azureOptions) { - if (springAiOptions == null) { - return azureOptions; - } + /** + * Merges the {@link AzureOpenAiChatOptions}, fromSpringAiOptions, into the + * {@link ChatCompletionsOptions}, toAzureOptions, and returns a new + * {@link ChatCompletionsOptions} instance. + * @param fromSpringAiOptions the {@link AzureOpenAiChatOptions} to merge from. + * @param toAzureOptions the {@link ChatCompletionsOptions} to merge to. + * @return a new {@link ChatCompletionsOptions} instance. + */ + private ChatCompletionsOptions merge(AzureOpenAiChatOptions fromSpringAiOptions, + ChatCompletionsOptions toAzureOptions) { - ChatCompletionsOptions mergedAzureOptions = new ChatCompletionsOptions(azureOptions.getMessages()); - mergedAzureOptions = merge(azureOptions, mergedAzureOptions); + if (fromSpringAiOptions == null) { + return toAzureOptions; + } - mergedAzureOptions.setStream(azureOptions.isStream()); + ChatCompletionsOptions mergedAzureOptions = this.copy(toAzureOptions); - if (springAiOptions.getMaxTokens() != null) { - mergedAzureOptions.setMaxTokens(springAiOptions.getMaxTokens()); + if (fromSpringAiOptions.getMaxTokens() != null) { + mergedAzureOptions.setMaxTokens(fromSpringAiOptions.getMaxTokens()); } - if (springAiOptions.getLogitBias() != null) { - mergedAzureOptions.setLogitBias(springAiOptions.getLogitBias()); + if (fromSpringAiOptions.getLogitBias() != null) { + mergedAzureOptions.setLogitBias(fromSpringAiOptions.getLogitBias()); } - if (springAiOptions.getStop() != null) { - mergedAzureOptions.setStop(springAiOptions.getStop()); + if (fromSpringAiOptions.getStop() != null) { + mergedAzureOptions.setStop(fromSpringAiOptions.getStop()); } - if (springAiOptions.getTemperature() != null && springAiOptions.getTemperature() != null) { - mergedAzureOptions.setTemperature(springAiOptions.getTemperature().doubleValue()); + if (fromSpringAiOptions.getTemperature() != null) { + mergedAzureOptions.setTemperature(fromSpringAiOptions.getTemperature().doubleValue()); } - if (springAiOptions.getTopP() != null && springAiOptions.getTopP() != null) { - mergedAzureOptions.setTopP(springAiOptions.getTopP().doubleValue()); + if (fromSpringAiOptions.getTopP() != null) { + mergedAzureOptions.setTopP(fromSpringAiOptions.getTopP().doubleValue()); } - if (springAiOptions.getFrequencyPenalty() != null && springAiOptions.getFrequencyPenalty() != null) { - mergedAzureOptions.setFrequencyPenalty(springAiOptions.getFrequencyPenalty().doubleValue()); + if (fromSpringAiOptions.getFrequencyPenalty() != null) { + mergedAzureOptions.setFrequencyPenalty(fromSpringAiOptions.getFrequencyPenalty().doubleValue()); } - if (springAiOptions.getPresencePenalty() != null && springAiOptions.getPresencePenalty() != null) { - mergedAzureOptions.setPresencePenalty(springAiOptions.getPresencePenalty().doubleValue()); + if (fromSpringAiOptions.getPresencePenalty() != null) { + mergedAzureOptions.setPresencePenalty(fromSpringAiOptions.getPresencePenalty().doubleValue()); } - if (springAiOptions.getN() != null) { - mergedAzureOptions.setN(springAiOptions.getN()); + if (fromSpringAiOptions.getN() != null) { + mergedAzureOptions.setN(fromSpringAiOptions.getN()); } - if (springAiOptions.getUser() != null) { - mergedAzureOptions.setUser(springAiOptions.getUser()); + if (fromSpringAiOptions.getUser() != null) { + mergedAzureOptions.setUser(fromSpringAiOptions.getUser()); } - if (springAiOptions.getDeploymentName() != null) { - mergedAzureOptions.setModel(springAiOptions.getDeploymentName()); + if (fromSpringAiOptions.getDeploymentName() != null) { + mergedAzureOptions.setModel(fromSpringAiOptions.getDeploymentName()); } return mergedAzureOptions; } - // https://github.com/Azure/azure-sdk-for-java/blob/azure-ai-openai_1.0.0-beta.6/sdk/openai/azure-ai-openai/src/samples/java/com/azure/ai/openai/usage/GetChatCompletionsToolCallSample.java - + /** + * Merges the fromOptions into the toOptions and returns a new ChatCompletionsOptions + * instance. + * @param fromOptions the ChatCompletionsOptions to merge from. + * @param toOptions the ChatCompletionsOptions to merge to. + * @return a new ChatCompletionsOptions instance. + */ private ChatCompletionsOptions merge(ChatCompletionsOptions fromOptions, ChatCompletionsOptions toOptions) { if (fromOptions == null) { return toOptions; } - ChatCompletionsOptions mergedOptions = new ChatCompletionsOptions(toOptions.getMessages()); - mergedOptions.setStream(toOptions.isStream()); + ChatCompletionsOptions mergedOptions = this.copy(toOptions); if (fromOptions.getMaxTokens() != null) { mergedOptions.setMaxTokens(fromOptions.getMaxTokens()); @@ -426,6 +441,50 @@ private ChatCompletionsOptions merge(ChatCompletionsOptions fromOptions, ChatCom return mergedOptions; } + /** + * Copy the fromOptions into a new ChatCompletionsOptions instance. + * @param fromOptions the ChatCompletionsOptions to copy from. + * @return a new ChatCompletionsOptions instance. + */ + private ChatCompletionsOptions copy(ChatCompletionsOptions fromOptions) { + + ChatCompletionsOptions copyOptions = new ChatCompletionsOptions(fromOptions.getMessages()); + copyOptions.setStream(fromOptions.isStream()); + + if (fromOptions.getMaxTokens() != null) { + copyOptions.setMaxTokens(fromOptions.getMaxTokens()); + } + if (fromOptions.getLogitBias() != null) { + copyOptions.setLogitBias(fromOptions.getLogitBias()); + } + if (fromOptions.getStop() != null) { + copyOptions.setStop(fromOptions.getStop()); + } + if (fromOptions.getTemperature() != null) { + copyOptions.setTemperature(fromOptions.getTemperature()); + } + if (fromOptions.getTopP() != null) { + copyOptions.setTopP(fromOptions.getTopP()); + } + if (fromOptions.getFrequencyPenalty() != null) { + copyOptions.setFrequencyPenalty(fromOptions.getFrequencyPenalty()); + } + if (fromOptions.getPresencePenalty() != null) { + copyOptions.setPresencePenalty(fromOptions.getPresencePenalty()); + } + if (fromOptions.getN() != null) { + copyOptions.setN(fromOptions.getN()); + } + if (fromOptions.getUser() != null) { + copyOptions.setUser(fromOptions.getUser()); + } + if (fromOptions.getModel() != null) { + copyOptions.setModel(fromOptions.getModel()); + } + + return copyOptions; + } + @Override protected ChatCompletionsOptions doCreateToolResponseRequest(ChatCompletionsOptions previousRequest, ChatRequestMessage responseMessage, List conversationHistory) { diff --git a/models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/AzureChatCompletionsOptionsTests.java b/models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/AzureChatCompletionsOptionsTests.java index 606b06ff652..1e0ba29393e 100644 --- a/models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/AzureChatCompletionsOptionsTests.java +++ b/models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/AzureChatCompletionsOptionsTests.java @@ -24,6 +24,8 @@ import org.springframework.ai.chat.prompt.Prompt; +import java.util.List; +import java.util.Map; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; @@ -37,8 +39,21 @@ public class AzureChatCompletionsOptionsTests { public void createRequestWithChatOptions() { OpenAIClient mockClient = Mockito.mock(OpenAIClient.class); - var client = new AzureOpenAiChatClient(mockClient, - AzureOpenAiChatOptions.builder().withDeploymentName("DEFAULT_MODEL").withTemperature(66.6f).build()); + + var defaultOptions = AzureOpenAiChatOptions.builder() + .withDeploymentName("DEFAULT_MODEL") + .withTemperature(66.6f) + .withFrequencyPenalty(696.9f) + .withPresencePenalty(969.6f) + .withLogitBias(Map.of("foo", 1)) + .withMaxTokens(969) + .withN(69) + .withStop(List.of("foo", "bar")) + .withTopP(0.69f) + .withUser("user") + .build(); + + var client = new AzureOpenAiChatClient(mockClient, defaultOptions); var requestOptions = client.toAzureChatCompletionsOptions(new Prompt("Test message content")); @@ -46,14 +61,42 @@ public void createRequestWithChatOptions() { assertThat(requestOptions.getModel()).isEqualTo("DEFAULT_MODEL"); assertThat(requestOptions.getTemperature()).isEqualTo(66.6f); + assertThat(requestOptions.getFrequencyPenalty()).isEqualTo(696.9f); + assertThat(requestOptions.getPresencePenalty()).isEqualTo(969.6f); + assertThat(requestOptions.getLogitBias()).isEqualTo(Map.of("foo", 1)); + assertThat(requestOptions.getMaxTokens()).isEqualTo(969); + assertThat(requestOptions.getN()).isEqualTo(69); + assertThat(requestOptions.getStop()).isEqualTo(List.of("foo", "bar")); + assertThat(requestOptions.getTopP()).isEqualTo(0.69f); + assertThat(requestOptions.getUser()).isEqualTo("user"); + + var runtimeOptions = AzureOpenAiChatOptions.builder() + .withDeploymentName("PROMPT_MODEL") + .withTemperature(99.9f) + .withFrequencyPenalty(100f) + .withPresencePenalty(100f) + .withLogitBias(Map.of("foo", 2)) + .withMaxTokens(100) + .withN(100) + .withStop(List.of("foo", "bar")) + .withTopP(0.111f) + .withUser("user2") + .build(); - requestOptions = client.toAzureChatCompletionsOptions(new Prompt("Test message content", - AzureOpenAiChatOptions.builder().withDeploymentName("PROMPT_MODEL").withTemperature(99.9f).build())); + requestOptions = client.toAzureChatCompletionsOptions(new Prompt("Test message content", runtimeOptions)); assertThat(requestOptions.getMessages()).hasSize(1); assertThat(requestOptions.getModel()).isEqualTo("PROMPT_MODEL"); assertThat(requestOptions.getTemperature()).isEqualTo(99.9f); + assertThat(requestOptions.getFrequencyPenalty()).isEqualTo(100f); + assertThat(requestOptions.getPresencePenalty()).isEqualTo(100f); + assertThat(requestOptions.getLogitBias()).isEqualTo(Map.of("foo", 2)); + assertThat(requestOptions.getMaxTokens()).isEqualTo(100); + assertThat(requestOptions.getN()).isEqualTo(100); + assertThat(requestOptions.getStop()).isEqualTo(List.of("foo", "bar")); + assertThat(requestOptions.getTopP()).isEqualTo(0.111f); + assertThat(requestOptions.getUser()).isEqualTo("user2"); } private static Stream providePresencePenaltyAndFrequencyPenaltyTest() { From cdb80e83e95651e53a974c28def1b9cafdaf7c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edd=C3=BA=20Mel=C3=A9ndez?= Date: Sat, 11 May 2024 13:47:02 +0200 Subject: [PATCH 07/39] Fix ChromaDB basic authentication Currently, `username` is added as a password in `BasicAuthenticationInterceptor`. This commit fixes the issue and also make sure the integration test setup is correct. --- .../java/org/springframework/ai/chroma/ChromaApi.java | 2 +- .../ai/vectorstore/BasicAuthChromaWhereIT.java | 9 ++++----- .../ai/vectorstore/TokenSecuredChromaWhereIT.java | 1 - .../spring-ai-chroma/src/test/resources/server.htpasswd | 2 +- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/vector-stores/spring-ai-chroma/src/main/java/org/springframework/ai/chroma/ChromaApi.java b/vector-stores/spring-ai-chroma/src/main/java/org/springframework/ai/chroma/ChromaApi.java index 7917d8550bb..04474cd532d 100644 --- a/vector-stores/spring-ai-chroma/src/main/java/org/springframework/ai/chroma/ChromaApi.java +++ b/vector-stores/spring-ai-chroma/src/main/java/org/springframework/ai/chroma/ChromaApi.java @@ -82,7 +82,7 @@ public ChromaApi withKeyToken(String keyToken) { * @param password Credentials password. */ public ChromaApi withBasicAuthCredentials(String username, String password) { - this.restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(username, username)); + this.restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(username, password)); return this; } diff --git a/vector-stores/spring-ai-chroma/src/test/java/org/springframework/ai/vectorstore/BasicAuthChromaWhereIT.java b/vector-stores/spring-ai-chroma/src/test/java/org/springframework/ai/vectorstore/BasicAuthChromaWhereIT.java index a15eb9bfffd..a7b9ebf12c0 100644 --- a/vector-stores/spring-ai-chroma/src/test/java/org/springframework/ai/vectorstore/BasicAuthChromaWhereIT.java +++ b/vector-stores/spring-ai-chroma/src/test/java/org/springframework/ai/vectorstore/BasicAuthChromaWhereIT.java @@ -20,7 +20,6 @@ import org.junit.jupiter.api.Test; import org.testcontainers.chromadb.ChromaDBContainer; -import org.testcontainers.images.builder.Transferable; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; @@ -28,12 +27,12 @@ import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.openai.api.OpenAiApi; -import org.springframework.ai.vectorstore.ChromaVectorStore; import org.springframework.ai.openai.OpenAiEmbeddingClient; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.web.client.RestTemplate; +import org.testcontainers.utility.MountableFile; import static org.assertj.core.api.Assertions.assertThat; @@ -56,11 +55,11 @@ public class BasicAuthChromaWhereIT { */ @Container static ChromaDBContainer chromaContainer = new ChromaDBContainer("ghcr.io/chroma-core/chroma:0.4.22") - .withEnv("CHROMA_SERVER_AUTH_CREDENTIALS_FILE", "server.htpasswd") + .withEnv("CHROMA_SERVER_AUTH_CREDENTIALS_FILE", "/chroma/server.htpasswd") .withEnv("CHROMA_SERVER_AUTH_CREDENTIALS_PROVIDER", "chromadb.auth.providers.HtpasswdFileServerAuthCredentialsProvider") .withEnv("CHROMA_SERVER_AUTH_PROVIDER", "chromadb.auth.basic.BasicAuthServerProvider") - .withCopyToContainer(Transferable.of("src/test/resources/server.htpasswd"), "server.htpasswd"); + .withCopyToContainer(MountableFile.forClasspathResource("server.htpasswd"), "/chroma/server.htpasswd"); private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withUserConfiguration(TestApplication.class) @@ -103,7 +102,7 @@ public RestTemplate restTemplate() { @Bean public ChromaApi chromaApi(RestTemplate restTemplate) { return new ChromaApi(chromaContainer.getEndpoint(), restTemplate).withBasicAuthCredentials("admin", - "admin"); + "password"); } @Bean diff --git a/vector-stores/spring-ai-chroma/src/test/java/org/springframework/ai/vectorstore/TokenSecuredChromaWhereIT.java b/vector-stores/spring-ai-chroma/src/test/java/org/springframework/ai/vectorstore/TokenSecuredChromaWhereIT.java index 22c7b0e19e8..4953732c1bf 100644 --- a/vector-stores/spring-ai-chroma/src/test/java/org/springframework/ai/vectorstore/TokenSecuredChromaWhereIT.java +++ b/vector-stores/spring-ai-chroma/src/test/java/org/springframework/ai/vectorstore/TokenSecuredChromaWhereIT.java @@ -27,7 +27,6 @@ import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.openai.api.OpenAiApi; -import org.springframework.ai.vectorstore.ChromaVectorStore; import org.springframework.ai.openai.OpenAiEmbeddingClient; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; diff --git a/vector-stores/spring-ai-chroma/src/test/resources/server.htpasswd b/vector-stores/spring-ai-chroma/src/test/resources/server.htpasswd index ea052f5fcfb..947a05d060d 100644 --- a/vector-stores/spring-ai-chroma/src/test/resources/server.htpasswd +++ b/vector-stores/spring-ai-chroma/src/test/resources/server.htpasswd @@ -1,2 +1,2 @@ -admin:$2y$05$fM.6b629s3L6L8RcA.kqg.BmxEzwB9t4MpGux62MEXNMJ9M7w8CY2 +admin:$2y$05$qSmQb0YJmaLRIhbT7MRBRu6bPK267dxkzLikr6WA/7JfGERc7dKkW From d7dad6ef6162a057034297222575e13401233492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edd=C3=BA=20Mel=C3=A9ndez?= Date: Sat, 11 May 2024 16:02:57 +0200 Subject: [PATCH 08/39] Add GH workflow to check source code format on PRs --- .../workflows/source-code-format-check.yml | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/source-code-format-check.yml diff --git a/.github/workflows/source-code-format-check.yml b/.github/workflows/source-code-format-check.yml new file mode 100644 index 00000000000..2143224c357 --- /dev/null +++ b/.github/workflows/source-code-format-check.yml @@ -0,0 +1,25 @@ +name: Source Code Format + +on: + pull_request: + branches: + - main + +jobs: + build: + name: Build branch + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Source code formatting check + run: | + ./mvnw spring-javaformat:validate From b3cfa2b900ea785e055e4ff71086eeb52f6578a3 Mon Sep 17 00:00:00 2001 From: Thomas Vitale Date: Tue, 14 May 2024 07:38:58 +0200 Subject: [PATCH 09/39] OpenAI: Add gpt-4o to chat model enum Signed-off-by: Thomas Vitale --- .../org/springframework/ai/openai/api/OpenAiApi.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java index 605697906d3..cc42562679d 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java @@ -109,10 +109,17 @@ public OpenAiApi(String baseUrl, String openAiToken, RestClient.Builder restClie /** * OpenAI Chat Completion Models: - * GPT-4 and GPT-4 Turbo and - * GPT-3.5 Turbo. + * - GPT-4o + * - GPT-4 and GPT-4 Turbo + * - GPT-3.5 Turbo. */ public enum ChatModel { + /** + * Multimodal flagship model that’s cheaper and faster than GPT-4 Turbo. + * Currently points to gpt-4o-2024-05-13. + */ + GPT_4_O("gpt-4o"), + /** * (New) GPT-4 Turbo - latest GPT-4 model intended to reduce cases * of “laziness” where the model doesn’t complete a task. From db4f0b0aaf4afcec95dc61cbfe47bf13fe87bd2e Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Tue, 14 May 2024 21:12:39 +0200 Subject: [PATCH 10/39] Add GPT-4o ITs and documentation updates --- .../ai/openai/chat/OpenAiChatClientIT.java | 26 ++++++++++++------- .../ROOT/pages/api/chat/openai-chat.adoc | 9 ++++--- .../modules/ROOT/pages/api/multimodality.adoc | 2 +- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java index e82395ff038..099a7394b96 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java @@ -24,6 +24,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; @@ -243,33 +245,37 @@ void streamFunctionCallTest() { assertThat(content).containsAnyOf("15.0", "15"); } - @Test - void multiModalityEmbeddedImage() throws IOException { + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "gpt-4-vision-preview", "gpt-4o" }) + void multiModalityEmbeddedImage(String modelName) throws IOException { byte[] imageData = new ClassPathResource("/test.png").getContentAsByteArray(); var userMessage = new UserMessage("Explain what do you see on this picture?", List.of(new Media(MimeTypeUtils.IMAGE_PNG, imageData))); - ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), - OpenAiChatOptions.builder().withModel(OpenAiApi.ChatModel.GPT_4_VISION_PREVIEW.getValue()).build())); + ChatResponse response = chatClient + .call(new Prompt(List.of(userMessage), OpenAiChatOptions.builder().withModel(modelName).build())); logger.info(response.getResult().getOutput().getContent()); - assertThat(response.getResult().getOutput().getContent()).contains("bananas", "apple", "bowl"); + assertThat(response.getResult().getOutput().getContent()).contains("bananas", "apple"); + assertThat(response.getResult().getOutput().getContent()).containsAnyOf("bowl", "basket"); } - @Test - void multiModalityImageUrl() throws IOException { + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "gpt-4-vision-preview", "gpt-4o" }) + void multiModalityImageUrl(String modelName) throws IOException { var userMessage = new UserMessage("Explain what do you see on this picture?", List.of(new Media(MimeTypeUtils.IMAGE_PNG, "https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/_images/multimodal.test.png"))); - ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), - OpenAiChatOptions.builder().withModel(OpenAiApi.ChatModel.GPT_4_VISION_PREVIEW.getValue()).build())); + ChatResponse response = chatClient + .call(new Prompt(List.of(userMessage), OpenAiChatOptions.builder().withModel(modelName).build())); logger.info(response.getResult().getOutput().getContent()); - assertThat(response.getResult().getOutput().getContent()).contains("bananas", "apple", "bowl"); + assertThat(response.getResult().getOutput().getContent()).contains("bananas", "apple"); + assertThat(response.getResult().getOutput().getContent()).containsAnyOf("bowl", "basket"); } @Test diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc index 09a6d610871..99fd646346d 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc @@ -145,13 +145,14 @@ Read more about xref:api/chat/functions/openai-chat-functions.adoc[OpenAI Functi == Multimodal Multimodality refers to a model's ability to simultaneously understand and process information from various sources, including text, images, audio, and other data formats. -Presently, the OpenAI `gpt-4-visual-preview` model offers multimodal support. Refer to the link:https://platform.openai.com/docs/guides/vision[Vision] guide for more information. +Presently, the OpenAI `gpt-4-visual-preview` and `gpt-4o` models offers multimodal support. +Refer to the link:https://platform.openai.com/docs/guides/vision[Vision] guide for more information. The OpenAI link:https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages[User Message API] can incorporate a list of base64-encoded images or image urls with the message. Spring AI’s link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/Message.java[Message] interface facilitates multimodal AI models by introducing the link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/Media.java[Media] type. This type encompasses data and details regarding media attachments in messages, utilizing Spring’s `org.springframework.util.MimeType` and a `java.lang.Object` for the raw media data. -Below is a code example excerpted from link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiChatClientIT.java[OpenAiChatClientIT.java], illustrating the fusion of user text with an image. +Below is a code example excerpted from link:https://github.com/spring-projects/spring-ai/blob/b3cfa2b900ea785e055e4ff71086eeb52f6578a3/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java[OpenAiChatClientIT.java], illustrating the fusion of user text with an image using the the `GPT_4_VISION_PREVIEW` model. [source,java] ---- @@ -164,7 +165,7 @@ ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), OpenAiChatOptions.builder().withModel(OpenAiApi.ChatModel.GPT_4_VISION_PREVIEW.getValue()).build())); ---- -or the image URL equivalent: +or the image URL equivalent using the `GPT_4_O` model : [source,java] ---- @@ -173,7 +174,7 @@ var userMessage = new UserMessage("Explain what do you see on this picture?", "https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/_images/multimodal.test.png"))); ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), - OpenAiChatOptions.builder().withModel(OpenAiApi.ChatModel.GPT_4_VISION_PREVIEW.getValue()).build())); + OpenAiChatOptions.builder().withModel(OpenAiApi.ChatModel.GPT_4_O.getValue()).build())); ---- TIP: you can pass multiple images as well. diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/multimodality.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/multimodality.adoc index 37a43ca9182..320dd8910d8 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/multimodality.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/multimodality.adoc @@ -57,7 +57,7 @@ and produce a response like: Latest version of Spring AI provides multimodal support for the following Chat Clients: -* xref:api/chat/openai-chat.adoc#_multimodal[Open AI - (GPT-4-Vision model)] +* xref:api/chat/openai-chat.adoc#_multimodal[Open AI - (GPT-4-Vision and GPT-4o models)] * xref:api/chat/openai-chat.adoc#_multimodal[Ollama - (LlaVa and Baklava models)] * xref:api/chat/vertexai-gemini-chat.adoc#_multimodal[Vertex AI Gemini - (gemini-pro-vision model)] * xref:api/chat/anthropic-chat.adoc#_multimodal[Anthropic Claude 3] From b0799babf423b4c136e77a4841db26bf042a64d1 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Wed, 15 May 2024 06:19:42 +0200 Subject: [PATCH 11/39] Update Qdrant client version from 1.7.1 to 1.9.1 - Also update the qdrant/qdrant docker image version from v1.7.4 to v1.9.2 --- .../openai/chat/chatbot/ChatMemoryLongTermSystemPromptIT.java | 2 +- .../openai/chat/chatbot/LongShortTermChatMemoryWithRagIT.java | 2 +- .../ai/openai/chat/chatbot/OpenAiDefaultChatBotIT.java | 2 +- pom.xml | 2 +- .../qdrant/QdrantVectorStoreAutoConfigurationIT.java | 2 +- .../qdrant/QdrantContainerConnectionDetailsFactoryTest.java | 2 +- .../ai/vectorstore/qdrant/QdrantVectorStoreIT.java | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/ChatMemoryLongTermSystemPromptIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/ChatMemoryLongTermSystemPromptIT.java index 546fc072676..237c1d56ecc 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/ChatMemoryLongTermSystemPromptIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/ChatMemoryLongTermSystemPromptIT.java @@ -58,7 +58,7 @@ public class ChatMemoryLongTermSystemPromptIT extends BaseMemoryTest { private static final int QDRANT_GRPC_PORT = 6334; @Container - static QdrantContainer qdrantContainer = new QdrantContainer("qdrant/qdrant:v1.7.4"); + static QdrantContainer qdrantContainer = new QdrantContainer("qdrant/qdrant:v1.9.2"); @Autowired public ChatMemoryLongTermSystemPromptIT(RelevancyEvaluator relevancyEvaluator, ChatBot chatBot, diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/LongShortTermChatMemoryWithRagIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/LongShortTermChatMemoryWithRagIT.java index 8578be6680e..9d89014e688 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/LongShortTermChatMemoryWithRagIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/LongShortTermChatMemoryWithRagIT.java @@ -86,7 +86,7 @@ public class LongShortTermChatMemoryWithRagIT { private static final int QDRANT_GRPC_PORT = 6334; @Container - static QdrantContainer qdrantContainer = new QdrantContainer("qdrant/qdrant:v1.7.4"); + static QdrantContainer qdrantContainer = new QdrantContainer("qdrant/qdrant:v1.9.2"); @Autowired ChatBot chatBot; diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/OpenAiDefaultChatBotIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/OpenAiDefaultChatBotIT.java index 4c5aa313f56..f6ae162bd90 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/OpenAiDefaultChatBotIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/OpenAiDefaultChatBotIT.java @@ -69,7 +69,7 @@ public class OpenAiDefaultChatBotIT { private static final int QDRANT_GRPC_PORT = 6334; @Container - static QdrantContainer qdrantContainer = new QdrantContainer("qdrant/qdrant:v1.7.4"); + static QdrantContainer qdrantContainer = new QdrantContainer("qdrant/qdrant:v1.9.2"); private final ChatClient chatClient; diff --git a/pom.xml b/pom.xml index 5401c165683..873b77ce210 100644 --- a/pom.xml +++ b/pom.xml @@ -130,7 +130,7 @@ 0.26.0 1.17.0 26.37.0 - 1.7.1 + 1.9.1 2.0.5 9.20.0 4.35.0 diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreAutoConfigurationIT.java index d04c3c72ccf..127dac087df 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreAutoConfigurationIT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreAutoConfigurationIT.java @@ -50,7 +50,7 @@ public class QdrantVectorStoreAutoConfigurationIT { private static final int QDRANT_GRPC_PORT = 6334; @Container - static QdrantContainer qdrantContainer = new QdrantContainer("qdrant/qdrant:v1.7.4"); + static QdrantContainer qdrantContainer = new QdrantContainer("qdrant/qdrant:v1.9.2"); List documents = List.of( new Document(getText("classpath:/test/data/spring.ai.txt"), Map.of("spring", "great")), diff --git a/spring-ai-spring-boot-testcontainers/src/test/java/org/springframework/ai/testcontainers/service/connection/qdrant/QdrantContainerConnectionDetailsFactoryTest.java b/spring-ai-spring-boot-testcontainers/src/test/java/org/springframework/ai/testcontainers/service/connection/qdrant/QdrantContainerConnectionDetailsFactoryTest.java index 09f3c1498da..c1c461a3e91 100644 --- a/spring-ai-spring-boot-testcontainers/src/test/java/org/springframework/ai/testcontainers/service/connection/qdrant/QdrantContainerConnectionDetailsFactoryTest.java +++ b/spring-ai-spring-boot-testcontainers/src/test/java/org/springframework/ai/testcontainers/service/connection/qdrant/QdrantContainerConnectionDetailsFactoryTest.java @@ -48,7 +48,7 @@ public class QdrantContainerConnectionDetailsFactoryTest { @Container @ServiceConnection - static QdrantContainer qdrantContainer = new QdrantContainer("qdrant/qdrant:v1.7.4"); + static QdrantContainer qdrantContainer = new QdrantContainer("qdrant/qdrant:v1.9.2"); List documents = List.of( new Document(getText("classpath:/test/data/spring.ai.txt"), Map.of("spring", "great")), diff --git a/vector-stores/spring-ai-qdrant/src/test/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStoreIT.java b/vector-stores/spring-ai-qdrant/src/test/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStoreIT.java index b7a8eada1db..73e6e93274e 100644 --- a/vector-stores/spring-ai-qdrant/src/test/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStoreIT.java +++ b/vector-stores/spring-ai-qdrant/src/test/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStoreIT.java @@ -62,7 +62,7 @@ public class QdrantVectorStoreIT { private static final int QDRANT_GRPC_PORT = 6334; @Container - static QdrantContainer qdrantContainer = new QdrantContainer("qdrant/qdrant:v1.7.4"); + static QdrantContainer qdrantContainer = new QdrantContainer("qdrant/qdrant:v1.9.2"); List documents = List.of( new Document("Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!!", From a7eb28ac17debbaaaf227802eb0385f3daea4fd8 Mon Sep 17 00:00:00 2001 From: Lorenzo Caenazzo Date: Thu, 21 Mar 2024 09:43:29 +0100 Subject: [PATCH 12/39] Add real Function Calling Streaming support - Add Java reflection merge utilities that can access Azure private constructors and fields. - Azure merging, creation of flux windows. - Function call grouping for function processing. - Do not perform greedy operation on Flux. - Use "real" streaming on all client on function response. - Gerimi: fix missing method impl. - Mistral AI, OpenAI: fix missing stream flag in doCreateToolResponseRequest. - Fix code formatting. No wildcard imports. - Add Grogdunn to the javadoc authors. - Anthropic 3 API does not support streaming funciton calling yet. --- .../ai/anthropic/AnthropicChatClient.java | 7 + .../ai/anthropic/api/AnthropicApi.java | 9 +- .../ai/anthropic/AnthropicChatClientIT.java | 4 +- .../azure/openai/AzureOpenAiChatClient.java | 71 +++- .../ai/azure/openai/MergeUtils.java | 323 ++++++++++++++++++ .../AzureOpenAiChatClientFunctionCallIT.java | 57 +++- .../openai/function/MockWeatherService.java | 6 +- .../ai/mistralai/MistralAiChatClient.java | 71 ++-- .../ai/openai/OpenAiChatClient.java | 95 +++--- .../gemini/VertexAiGeminiChatClient.java | 54 +-- .../function/AbstractFunctionCallSupport.java | 39 ++- 11 files changed, 610 insertions(+), 126 deletions(-) create mode 100644 models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/MergeUtils.java diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatClient.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatClient.java index 73fddf17949..0f9bcbf4430 100644 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatClient.java +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatClient.java @@ -450,4 +450,11 @@ protected boolean isToolFunctionCall(ResponseEntity response) { return response.getBody().content().stream().anyMatch(content -> content.type() == MediaContent.Type.TOOL_USE); } + @Override + protected Flux> doChatCompletionStream(ChatCompletionRequest request) { + // https://docs.anthropic.com/en/docs/tool-use + throw new UnsupportedOperationException( + "Streaming (stream=true) is not yet supported. We plan to add streaming support in a future beta version."); + } + } diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java index 46e6119d637..eb5a9628938 100644 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java @@ -29,6 +29,7 @@ import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.retry.RetryUtils; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.util.Assert; @@ -100,7 +101,13 @@ public AnthropicApi(String baseUrl, String anthropicApiKey, String anthropicVers .defaultStatusHandler(responseErrorHandler) .build(); - this.webClient = WebClient.builder().baseUrl(baseUrl).defaultHeaders(jsonContentHeaders).build(); + this.webClient = WebClient.builder() + .baseUrl(baseUrl) + .defaultHeaders(jsonContentHeaders) + .defaultStatusHandler(HttpStatusCode::isError, + resp -> Mono.just(new RuntimeException("Response exception, Status: [" + resp.statusCode() + + "], Body:[" + resp.bodyToMono(java.lang.String.class) + "]"))) + .build(); } /** diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatClientIT.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatClientIT.java index 7bc7ef2af3d..92ba0a6eb47 100644 --- a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatClientIT.java +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatClientIT.java @@ -205,7 +205,7 @@ void functionCallTest() { .withModel(AnthropicApi.ChatModel.CLAUDE_3_OPUS.getValue()) .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) .withName("getCurrentWeather") - .withDescription("Get the weather in location") + .withDescription("Get the weather in location. Return temperature in 36°F or 36°C format.") .build())) .build(); @@ -213,7 +213,7 @@ void functionCallTest() { logger.info("Response: {}", response); - Generation generation = response.getResults().get(0); + Generation generation = response.getResult(); assertThat(generation.getOutput().getContent()).containsAnyOf("30.0", "30"); assertThat(generation.getOutput().getContent()).containsAnyOf("10.0", "10"); assertThat(generation.getOutput().getContent()).containsAnyOf("15.0", "15"); diff --git a/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiChatClient.java b/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiChatClient.java index 236a902d84a..a49a42ff59f 100644 --- a/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiChatClient.java +++ b/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiChatClient.java @@ -15,11 +15,6 @@ */ package org.springframework.ai.azure.openai; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - import com.azure.ai.openai.OpenAIClient; import com.azure.ai.openai.models.ChatChoice; import com.azure.ai.openai.models.ChatCompletions; @@ -33,15 +28,14 @@ import com.azure.ai.openai.models.ChatRequestSystemMessage; import com.azure.ai.openai.models.ChatRequestToolMessage; import com.azure.ai.openai.models.ChatRequestUserMessage; -import com.azure.ai.openai.models.ChatResponseMessage; import com.azure.ai.openai.models.CompletionsFinishReason; import com.azure.ai.openai.models.ContentFilterResultsForPrompt; +import com.azure.ai.openai.models.FunctionCall; import com.azure.ai.openai.models.FunctionDefinition; import com.azure.core.util.BinaryData; import com.azure.core.util.IterableStream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import reactor.core.publisher.Flux; import org.springframework.ai.azure.openai.metadata.AzureOpenAiChatResponseMetadata; import org.springframework.ai.chat.ChatClient; @@ -59,6 +53,14 @@ import org.springframework.ai.model.function.FunctionCallbackContext; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; +import reactor.core.publisher.Flux; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; /** * {@link ChatClient} implementation for {@literal Microsoft Azure AI} backed by @@ -68,6 +70,7 @@ * @author Ueibin Kim * @author John Blum * @author Christian Tzolov + * @author Grogdunn * @see ChatClient * @see com.azure.ai.openai.OpenAIClient */ @@ -158,17 +161,42 @@ public Flux stream(Prompt prompt) { IterableStream chatCompletionsStream = this.openAIClient .getChatCompletionsStream(options.getModel(), options); - return Flux.fromStream(chatCompletionsStream.stream() + Flux chatCompletionsFlux = Flux.fromIterable(chatCompletionsStream); + + final var isFunctionCall = new AtomicBoolean(false); + final var accessibleChatCompletionsFlux = chatCompletionsFlux // Note: the first chat completions can be ignored when using Azure OpenAI // service which is a known service bug. .skip(1) - .map(ChatCompletions::getChoices) - .flatMap(List::stream) + .map(chatCompletions -> { + final var toolCalls = chatCompletions.getChoices().get(0).getDelta().getToolCalls(); + isFunctionCall.set(toolCalls != null && !toolCalls.isEmpty()); + return chatCompletions; + }) + .windowUntil(chatCompletions -> { + if (isFunctionCall.get() && chatCompletions.getChoices() + .get(0) + .getFinishReason() == CompletionsFinishReason.TOOL_CALLS) { + isFunctionCall.set(false); + return true; + } + return false; + }, false) + .concatMapIterable(window -> { + final var reduce = window.reduce(MergeUtils.emptyChatCompletions(), MergeUtils::mergeChatCompletions); + return List.of(reduce); + }) + .flatMap(mono -> mono); + return accessibleChatCompletionsFlux + .switchMap(accessibleChatCompletions -> handleFunctionCallOrReturnStream(options, + Flux.just(accessibleChatCompletions))) + .flatMapIterable(ChatCompletions::getChoices) .map(choice -> { - var content = (choice.getDelta() != null) ? choice.getDelta().getContent() : null; + var content = Optional.ofNullable(choice.getMessage()).orElse(choice.getDelta()).getContent(); var generation = new Generation(content).withGenerationMetadata(generateChoiceMetadata(choice)); return new ChatResponse(List.of(generation)); - })); + }); + } /** @@ -522,9 +550,17 @@ protected List doGetUserMessages(ChatCompletionsOptions requ @Override protected ChatRequestMessage doGetToolResponseMessage(ChatCompletions response) { - ChatResponseMessage responseMessage = response.getChoices().get(0).getMessage(); + final var accessibleChatChoice = response.getChoices().get(0); + var responseMessage = Optional.ofNullable(accessibleChatChoice.getMessage()) + .orElse(accessibleChatChoice.getDelta()); ChatRequestAssistantMessage assistantMessage = new ChatRequestAssistantMessage(""); - assistantMessage.setToolCalls(responseMessage.getToolCalls()); + final var toolCalls = responseMessage.getToolCalls(); + assistantMessage.setToolCalls(toolCalls.stream().map(tc -> { + final var tc1 = (ChatCompletionsFunctionToolCall) tc; + var toDowncast = new ChatCompletionsFunctionToolCall(tc.getId(), + new FunctionCall(tc1.getFunction().getName(), tc1.getFunction().getArguments())); + return ((ChatCompletionsToolCall) toDowncast); + }).toList()); return assistantMessage; } @@ -533,6 +569,11 @@ protected ChatCompletions doChatCompletion(ChatCompletionsOptions request) { return this.openAIClient.getChatCompletions(request.getModel(), request); } + @Override + protected Flux doChatCompletionStream(ChatCompletionsOptions request) { + return Flux.fromIterable(this.openAIClient.getChatCompletionsStream(request.getModel(), request)); + } + @Override protected boolean isToolFunctionCall(ChatCompletions chatCompletions) { @@ -549,4 +590,4 @@ protected boolean isToolFunctionCall(ChatCompletions chatCompletions) { return choice.getFinishReason() == CompletionsFinishReason.TOOL_CALLS; } -} \ No newline at end of file +} diff --git a/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/MergeUtils.java b/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/MergeUtils.java new file mode 100644 index 00000000000..a4e995937c8 --- /dev/null +++ b/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/MergeUtils.java @@ -0,0 +1,323 @@ +/* + * Copyright 2023 - 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.azure.openai; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import com.azure.ai.openai.models.AzureChatExtensionsMessageContext; +import com.azure.ai.openai.models.ChatChoice; +import com.azure.ai.openai.models.ChatCompletions; +import com.azure.ai.openai.models.ChatCompletionsFunctionToolCall; +import com.azure.ai.openai.models.ChatCompletionsToolCall; +import com.azure.ai.openai.models.ChatResponseMessage; +import com.azure.ai.openai.models.CompletionsFinishReason; +import com.azure.ai.openai.models.CompletionsUsage; +import com.azure.ai.openai.models.ContentFilterResultsForChoice; +import com.azure.ai.openai.models.ContentFilterResultsForPrompt; +import com.azure.ai.openai.models.FunctionCall; + +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * Utility class for merging ChatCompletions instances and their associated objects. Uses + * reflection to create instances with private constructors and set private fields. + * + * @author Grogdunn + * @author Christian Tzolov + * @since 1.0.0 + */ +public class MergeUtils { + + /** + * Create a new instance of the given class. Can be used to create instances with + * private constructors. + * @param the type of the class to be created. + * @param clazz the class to create an instance of. + * @param args the arguments to pass to the constructor. + * @return a new instance of the given class. + */ + private static T newInstance(Class clazz, Object... args) { + return newInstance(0, clazz, args); + } + + /** + * Create a new instance of the given class using the constructor at the given index. + * Can be used to create instances with private constructors. + * @param the type of the class to be created. + * @param index the index of the constructor to use. + * @param clazz the class to create an instance of. + * @param args the arguments to pass to the constructor. + * @return a new instance of the given class. + */ + private static T newInstance(int index, Class clazz, Object... args) { + try { + @SuppressWarnings("unchecked") + Constructor constructor = (Constructor) clazz.getDeclaredConstructors()[index]; + constructor.setAccessible(true); + return constructor.newInstance(args); + } + catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * Set the value of a private field in the given class instance. + * @param classInstance the class instance to set the field on. + * @param fieldName the name of the field to set. + * @param fieldValue the value to set the field to. + */ + private static void setField(Object classInstance, String fieldName, Object fieldValue) { + try { + Field field = classInstance.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(classInstance, fieldValue); + } + catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * @return an empty ChatCompletions instance. + */ + public static ChatCompletions emptyChatCompletions() { + String id = null; + List choices = new ArrayList<>(); + CompletionsUsage usage = null; + long createdAt = 0; + ChatCompletions chatCompletionsInstance = newInstance(ChatCompletions.class, id, createdAt, choices, usage); + List promptFilterResults = new ArrayList<>(); + setField(chatCompletionsInstance, "promptFilterResults", promptFilterResults); + String systemFingerprint = null; + setField(chatCompletionsInstance, "systemFingerprint", systemFingerprint); + + return chatCompletionsInstance; + } + + /** + * Merge two ChatCompletions instances into a single ChatCompletions instance. + * @param left the left ChatCompletions instance. + * @param right the right ChatCompletions instance. + * @return a merged ChatCompletions instance. + */ + public static ChatCompletions mergeChatCompletions(ChatCompletions left, ChatCompletions right) { + + Assert.isTrue(left != null, ""); + if (right == null) { + Assert.isTrue(left.getId() != null, ""); + return left; + } + Assert.isTrue(left.getId() != null || right.getId() != null, ""); + + String id = left.getId() != null ? left.getId() : right.getId(); + + List choices = null; + if (right.getChoices() == null) { + choices = left.getChoices(); + } + else { + if (CollectionUtils.isEmpty(left.getChoices())) { + choices = right.getChoices(); + } + else { + choices = List.of(mergeChatChoice(left.getChoices().get(0), right.getChoices().get(0))); + } + } + + // For these properties if right contains that use it! + CompletionsUsage usage = right.getUsage() == null ? left.getUsage() : right.getUsage(); + + OffsetDateTime createdAt = left.getCreatedAt().isAfter(right.getCreatedAt()) ? left.getCreatedAt() + : right.getCreatedAt(); + + ChatCompletions instance = newInstance(1, ChatCompletions.class, id, createdAt, choices, usage); + + List promptFilterResults = right.getPromptFilterResults() == null + ? left.getPromptFilterResults() : right.getPromptFilterResults(); + setField(instance, "promptFilterResults", promptFilterResults); + + String systemFingerprint = right.getSystemFingerprint() == null ? left.getSystemFingerprint() + : right.getSystemFingerprint(); + setField(instance, "systemFingerprint", systemFingerprint); + return instance; + } + + /** + * Merge two ChatChoice instances into a single ChatChoice instance. + * @param left the left ChatChoice instance to merge. + * @param right the right ChatChoice instance to merge. + * @return a merged ChatChoice instance. + */ + private static ChatChoice mergeChatChoice(ChatChoice left, ChatChoice right) { + + int index = Math.max(left.getIndex(), right.getIndex()); + + CompletionsFinishReason finishReason = left.getFinishReason() != null ? left.getFinishReason() + : right.getFinishReason(); + + var logprobs = left.getLogprobs() != null ? left.getLogprobs() : right.getLogprobs(); + + final ChatChoice instance = newInstance(ChatChoice.class, logprobs, index, finishReason); + + ChatResponseMessage message = null; + if (left.getMessage() == null) { + message = right.getMessage(); + } + else { + message = mergeChatResponseMessage(left.getMessage(), right.getMessage()); + } + + setField(instance, "message", message); + + ChatResponseMessage delta = null; + if (left.getDelta() == null) { + delta = right.getDelta(); + } + else { + delta = mergeChatResponseMessage(left.getDelta(), right.getDelta()); + } + setField(instance, "delta", delta); + + ContentFilterResultsForChoice contentFilterResults = left.getContentFilterResults() != null + ? left.getContentFilterResults() : right.getContentFilterResults(); + setField(instance, "contentFilterResults", contentFilterResults); + + var finishDetails = left.getFinishDetails() != null ? left.getFinishDetails() : right.getFinishDetails(); + setField(instance, "finishDetails", finishDetails); + + var enhancements = left.getEnhancements() != null ? left.getEnhancements() : right.getEnhancements(); + setField(instance, "enhancements", enhancements); + + return instance; + } + + /** + * Merge two ChatResponseMessage instances into a single ChatResponseMessage instance. + * @param left the left ChatResponseMessage instance to merge. + * @param right the right ChatResponseMessage instance to merge. + * @return a merged ChatResponseMessage instance. + */ + private static ChatResponseMessage mergeChatResponseMessage(ChatResponseMessage left, ChatResponseMessage right) { + + var role = left.getRole() != null ? left.getRole() : right.getRole(); + String content = null; + if (left.getContent() != null && right.getContent() != null) { + content = left.getContent().concat(right.getContent()); + } + else if (left.getContent() == null) { + content = right.getContent(); + } + else { + content = left.getContent(); + } + + ChatResponseMessage instance = newInstance(ChatResponseMessage.class, role, content); + + List toolCalls = new ArrayList<>(); + if (left.getToolCalls() == null) { + if (right.getToolCalls() != null) { + toolCalls.addAll(right.getToolCalls()); + } + } + else if (right.getToolCalls() == null) { + toolCalls.addAll(left.getToolCalls()); + } + else { + toolCalls.addAll(left.getToolCalls()); + final var lastToolIndex = toolCalls.size() - 1; + ChatCompletionsToolCall lastTool = toolCalls.get(lastToolIndex); + if (right.getToolCalls().get(0).getId() == null) { + + lastTool = mergeChatCompletionsToolCall(lastTool, right.getToolCalls().get(0)); + + toolCalls.remove(lastToolIndex); + toolCalls.add(lastTool); + } + else { + toolCalls.add(right.getToolCalls().get(0)); + } + } + + setField(instance, "toolCalls", toolCalls); + + FunctionCall functionCall = null; + + if (left.getFunctionCall() == null) { + functionCall = right.getFunctionCall(); + } + else { + functionCall = MergeUtils.mergeFunctionCall(left.getFunctionCall(), right.getFunctionCall()); + } + + setField(instance, "functionCall", functionCall); + + AzureChatExtensionsMessageContext context = left.getContext() != null ? left.getContext() : right.getContext(); + setField(instance, "context", context); + + return instance; + } + + /** + * Merge two ChatCompletionsToolCall instances into a single ChatCompletionsToolCall + * instance. + * @param left the left ChatCompletionsToolCall instance to merge. + * @param right the right ChatCompletionsToolCall instance to merge. + * @return a merged ChatCompletionsToolCall instance. + */ + private static ChatCompletionsToolCall mergeChatCompletionsToolCall(ChatCompletionsToolCall left, + ChatCompletionsToolCall right) { + Assert.isTrue(Objects.equals(left.getType(), right.getType()), + "Cannot merge different type of AccessibleChatCompletionsToolCall"); + if (!"function".equals(left.getType())) { + throw new UnsupportedOperationException("Only function chat completion tool is supported"); + } + + String id = left.getId() != null ? left.getId() : right.getId(); + var mergedFunction = mergeFunctionCall(((ChatCompletionsFunctionToolCall) left).getFunction(), + ((ChatCompletionsFunctionToolCall) right).getFunction()); + + return new ChatCompletionsFunctionToolCall(id, mergedFunction); + } + + /** + * Merge two FunctionCall instances into a single FunctionCall instance. + * @param left the left, input FunctionCall instance. + * @param right the right, input FunctionCall instance. + * @return a merged FunctionCall instance. + */ + private static FunctionCall mergeFunctionCall(FunctionCall left, FunctionCall right) { + var name = left.getName() != null ? left.getName() : right.getName(); + String arguments = null; + if (left.getArguments() != null && right.getArguments() != null) { + arguments = left.getArguments() + right.getArguments(); + } + else if (left.getArguments() == null) { + arguments = right.getArguments(); + } + else { + arguments = left.getArguments(); + } + return new FunctionCall(name, arguments); + } + +} diff --git a/models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/function/AzureOpenAiChatClientFunctionCallIT.java b/models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/function/AzureOpenAiChatClientFunctionCallIT.java index 3e67dc3c694..08c81ebd136 100644 --- a/models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/function/AzureOpenAiChatClientFunctionCallIT.java +++ b/models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/function/AzureOpenAiChatClientFunctionCallIT.java @@ -17,6 +17,9 @@ import java.util.ArrayList; import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; import com.azure.ai.openai.OpenAIClient; import com.azure.ai.openai.OpenAIClientBuilder; @@ -29,6 +32,8 @@ import org.springframework.ai.azure.openai.AzureOpenAiChatClient; import org.springframework.ai.azure.openai.AzureOpenAiChatOptions; import org.springframework.ai.chat.ChatResponse; +import org.springframework.ai.chat.Generation; +import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.prompt.Prompt; @@ -37,6 +42,7 @@ import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Bean; +import reactor.core.publisher.Flux; import static org.assertj.core.api.Assertions.assertThat; @@ -47,6 +53,9 @@ class AzureOpenAiChatClientFunctionCallIT { private static final Logger logger = LoggerFactory.getLogger(AzureOpenAiChatClientFunctionCallIT.class); + @Autowired + private String selectedModel; + @Autowired private AzureOpenAiChatClient chatClient; @@ -58,7 +67,7 @@ void functionCallTest() { List messages = new ArrayList<>(List.of(userMessage)); var promptOptions = AzureOpenAiChatOptions.builder() - .withDeploymentName("gpt-4-0125-preview") + .withDeploymentName(selectedModel) .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) .withName("getCurrentWeather") .withDescription("Get the current weather in a given location") @@ -75,6 +84,40 @@ void functionCallTest() { assertThat(response.getResult().getOutput().getContent()).containsAnyOf("15.0", "15"); } + @Test + void streamFunctionCallTest() { + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + + List messages = new ArrayList<>(List.of(userMessage)); + + var promptOptions = AzureOpenAiChatOptions.builder() + .withDeploymentName(selectedModel) + .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) + .withName("getCurrentWeather") + .withDescription("Get the current weather in a given location") + .withResponseConverter((response) -> "" + response.temp() + response.unit()) + .build())) + .build(); + + Flux response = chatClient.stream(new Prompt(messages, promptOptions)); + + final var counter = new AtomicInteger(); + String content = response.doOnEach(listSignal -> counter.getAndIncrement()) + .collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getContent) + .collect(Collectors.joining()); + logger.info("Response: {}", content); + assertThat(counter.get()).isGreaterThan(2); + assertThat(content).containsAnyOf("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + } + @SpringBootConfiguration public static class TestConfiguration { @@ -86,12 +129,14 @@ public OpenAIClient openAIClient() { } @Bean - public AzureOpenAiChatClient azureOpenAiChatClient(OpenAIClient openAIClient) { + public AzureOpenAiChatClient azureOpenAiChatClient(OpenAIClient openAIClient, String selectedModel) { return new AzureOpenAiChatClient(openAIClient, - AzureOpenAiChatOptions.builder() - .withDeploymentName("gpt-4-0125-preview") - .withMaxTokens(500) - .build()); + AzureOpenAiChatOptions.builder().withDeploymentName(selectedModel).withMaxTokens(500).build()); + } + + @Bean + public String selectedModel() { + return Optional.ofNullable(System.getenv("AZURE_OPENAI_MODEL")).orElse("gpt-4-0125-preview"); } } diff --git a/models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/function/MockWeatherService.java b/models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/function/MockWeatherService.java index 898a1c61b6f..92747ed3023 100644 --- a/models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/function/MockWeatherService.java +++ b/models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/function/MockWeatherService.java @@ -15,14 +15,14 @@ */ package org.springframework.ai.azure.openai.function; -import java.util.function.Function; - import com.fasterxml.jackson.annotation.JsonClassDescription; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import java.util.function.Function; + /** * @author Christian Tzolov */ @@ -87,4 +87,4 @@ else if (request.location().contains("San Francisco")) { return new Response(temperature, 15, 20, 2, 53, 45, Unit.C); } -} \ No newline at end of file +} diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java index f5c1f4fd5bf..98a25025d67 100644 --- a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java @@ -15,18 +15,8 @@ */ package org.springframework.ai.mistralai; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import reactor.core.publisher.Flux; - import org.springframework.ai.chat.ChatClient; import org.springframework.ai.chat.ChatResponse; import org.springframework.ai.chat.Generation; @@ -49,10 +39,15 @@ import org.springframework.retry.support.RetryTemplate; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; +import reactor.core.publisher.Flux; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; /** * @author Ricken Bazolo * @author Christian Tzolov + * @author Grogdunn * @since 0.8.1 */ public class MistralAiChatClient extends @@ -148,29 +143,29 @@ public Flux stream(Prompt prompt) { // The rest of the chunks with same ID share the same role. ConcurrentHashMap roleMap = new ConcurrentHashMap<>(); - return completionChunks.map(chunk -> toChatCompletion(chunk)).map(chatCompletion -> { - - chatCompletion = handleFunctionCallOrReturn(request, ResponseEntity.of(Optional.of(chatCompletion))) - .getBody(); - - @SuppressWarnings("null") - String id = chatCompletion.id(); - - List generations = chatCompletion.choices().stream().map(choice -> { - if (choice.message().role() != null) { - roleMap.putIfAbsent(id, choice.message().role().name()); - } - String finish = (choice.finishReason() != null ? choice.finishReason().name() : ""); - var generation = new Generation(choice.message().content(), - Map.of("id", id, "role", roleMap.get(id), "finishReason", finish)); - if (choice.finishReason() != null) { - generation = generation - .withGenerationMetadata(ChatGenerationMetadata.from(choice.finishReason().name(), null)); - } - return generation; - }).toList(); - return new ChatResponse(generations); - }); + return completionChunks.map(chunk -> toChatCompletion(chunk)) + .switchMap( + cc -> handleFunctionCallOrReturnStream(request, Flux.just(ResponseEntity.of(Optional.of(cc))))) + .map(ResponseEntity::getBody) + .map(chatCompletion -> { + @SuppressWarnings("null") + String id = chatCompletion.id(); + + List generations = chatCompletion.choices().stream().map(choice -> { + if (choice.message().role() != null) { + roleMap.putIfAbsent(id, choice.message().role().name()); + } + String finish = (choice.finishReason() != null ? choice.finishReason().name() : ""); + var generation = new Generation(choice.message().content(), + Map.of("id", id, "role", roleMap.get(id), "finishReason", finish)); + if (choice.finishReason() != null) { + generation = generation.withGenerationMetadata( + ChatGenerationMetadata.from(choice.finishReason().name(), null)); + } + return generation; + }).toList(); + return new ChatResponse(generations); + }); }); } @@ -271,7 +266,7 @@ protected ChatCompletionRequest doCreateToolResponseRequest(ChatCompletionReques // Recursively call chatCompletionWithTools until the model doesn't call a // functions anymore. - ChatCompletionRequest newRequest = new ChatCompletionRequest(conversationHistory, false); + ChatCompletionRequest newRequest = new ChatCompletionRequest(conversationHistory, previousRequest.stream()); newRequest = ModelOptionsUtils.merge(newRequest, previousRequest, ChatCompletionRequest.class); return newRequest; @@ -299,6 +294,14 @@ protected ResponseEntity doChatCompletion(ChatCompletionRequest return this.mistralAiApi.chatCompletionEntity(request); } + @Override + protected Flux> doChatCompletionStream(ChatCompletionRequest request) { + return this.mistralAiApi.chatCompletionStream(request) + .map(this::toChatCompletion) + .map(Optional::ofNullable) + .map(ResponseEntity::of); + } + @Override protected boolean isToolFunctionCall(ResponseEntity chatCompletion) { diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatClient.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatClient.java index 8e86ecdd19a..6ec6904dbf1 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatClient.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatClient.java @@ -15,20 +15,8 @@ */ package org.springframework.ai.openai; -import java.util.ArrayList; -import java.util.Base64; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import reactor.core.publisher.Flux; - import org.springframework.ai.chat.ChatClient; import org.springframework.ai.chat.ChatResponse; import org.springframework.ai.chat.Generation; @@ -57,6 +45,17 @@ import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.MimeType; +import reactor.core.publisher.Flux; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; /** * {@link ChatClient} and {@link StreamingChatClient} implementation for {@literal OpenAI} @@ -68,6 +67,7 @@ * @author John Blum * @author Josh Long * @author Jemin Huh + * @author Grogdunn * @see ChatClient * @see StreamingChatClient * @see OpenAiApi @@ -189,36 +189,37 @@ public Flux stream(Prompt prompt) { // Convert the ChatCompletionChunk into a ChatCompletion to be able to reuse // the function call handling logic. - return completionChunks.map(chunk -> chunkToChatCompletion(chunk)).map(chatCompletion -> { - try { - chatCompletion = handleFunctionCallOrReturn(request, ResponseEntity.of(Optional.of(chatCompletion))) - .getBody(); - - @SuppressWarnings("null") - String id = chatCompletion.id(); - - List generations = chatCompletion.choices().stream().map(choice -> { - if (choice.message().role() != null) { - roleMap.putIfAbsent(id, choice.message().role().name()); - } - String finish = (choice.finishReason() != null ? choice.finishReason().name() : ""); - var generation = new Generation(choice.message().content(), - Map.of("id", id, "role", roleMap.get(id), "finishReason", finish)); - if (choice.finishReason() != null) { - generation = generation.withGenerationMetadata( - ChatGenerationMetadata.from(choice.finishReason().name(), null)); - } - return generation; - }).toList(); - - return new ChatResponse(generations); - } - catch (Exception e) { - logger.error("Error processing chat completion", e); - return new ChatResponse(List.of()); - } - - }); + return completionChunks.map(chunk -> chunkToChatCompletion(chunk)) + .switchMap( + cc -> handleFunctionCallOrReturnStream(request, Flux.just(ResponseEntity.of(Optional.of(cc))))) + .map(ResponseEntity::getBody) + .map(chatCompletion -> { + try { + @SuppressWarnings("null") + String id = chatCompletion.id(); + + List generations = chatCompletion.choices().stream().map(choice -> { + if (choice.message().role() != null) { + roleMap.putIfAbsent(id, choice.message().role().name()); + } + String finish = (choice.finishReason() != null ? choice.finishReason().name() : ""); + var generation = new Generation(choice.message().content(), + Map.of("id", id, "role", roleMap.get(id), "finishReason", finish)); + if (choice.finishReason() != null) { + generation = generation.withGenerationMetadata( + ChatGenerationMetadata.from(choice.finishReason().name(), null)); + } + return generation; + }).toList(); + + return new ChatResponse(generations); + } + catch (Exception e) { + logger.error("Error processing chat completion", e); + return new ChatResponse(List.of()); + } + + }); }); } @@ -347,7 +348,7 @@ protected ChatCompletionRequest doCreateToolResponseRequest(ChatCompletionReques // Recursively call chatCompletionWithTools until the model doesn't call a // functions anymore. - ChatCompletionRequest newRequest = new ChatCompletionRequest(conversationHistory, false); + ChatCompletionRequest newRequest = new ChatCompletionRequest(conversationHistory, previousRequest.stream()); newRequest = ModelOptionsUtils.merge(newRequest, previousRequest, ChatCompletionRequest.class); return newRequest; @@ -368,6 +369,14 @@ protected ResponseEntity doChatCompletion(ChatCompletionRequest return this.openAiApi.chatCompletionEntity(request); } + @Override + protected Flux> doChatCompletionStream(ChatCompletionRequest request) { + return this.openAiApi.chatCompletionStream(request) + .map(this::chunkToChatCompletion) + .map(Optional::ofNullable) + .map(ResponseEntity::of); + } + @Override protected boolean isToolFunctionCall(ResponseEntity chatCompletion) { var body = chatCompletion.getBody(); diff --git a/models/spring-ai-vertex-ai-gemini/src/main/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatClient.java b/models/spring-ai-vertex-ai-gemini/src/main/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatClient.java index ad74073fa7c..dfb2a14bc54 100644 --- a/models/spring-ai-vertex-ai-gemini/src/main/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatClient.java +++ b/models/spring-ai-vertex-ai-gemini/src/main/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatClient.java @@ -15,12 +15,6 @@ */ package org.springframework.ai.vertexai.gemini; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.google.cloud.vertexai.VertexAI; @@ -38,8 +32,6 @@ import com.google.cloud.vertexai.generativeai.ResponseStream; import com.google.protobuf.Struct; import com.google.protobuf.util.JsonFormat; -import reactor.core.publisher.Flux; - import org.springframework.ai.chat.ChatClient; import org.springframework.ai.chat.ChatResponse; import org.springframework.ai.chat.Generation; @@ -60,9 +52,17 @@ import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; +import reactor.core.publisher.Flux; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; /** * @author Christian Tzolov + * @author Grogdunn * @since 0.8.1 */ public class VertexAiGeminiChatClient @@ -167,18 +167,19 @@ public Flux stream(Prompt prompt) { ResponseStream responseStream = request.model .generateContentStream(request.contents); - return Flux.fromStream(responseStream.stream()).map(response -> { - response = handleFunctionCallOrReturn(request, response); - List generations = response.getCandidatesList() - .stream() - .map(candidate -> candidate.getContent().getPartsList()) - .flatMap(List::stream) - .map(Part::getText) - .map(t -> new Generation(t.toString())) - .toList(); - - return new ChatResponse(generations, toChatResponseMetadata(response)); - }); + return Flux.fromStream(responseStream.stream()) + .switchMap(r -> handleFunctionCallOrReturnStream(request, Flux.just(r))) + .map(response -> { + List generations = response.getCandidatesList() + .stream() + .map(candidate -> candidate.getContent().getPartsList()) + .flatMap(List::stream) + .map(Part::getText) + .map(t -> new Generation(t.toString())) + .toList(); + + return new ChatResponse(generations, toChatResponseMetadata(response)); + }); } catch (Exception e) { throw new RuntimeException("Failed to generate content", e); @@ -450,6 +451,19 @@ protected GenerateContentResponse doChatCompletion(GeminiRequest request) { } } + @Override + protected Flux doChatCompletionStream(GeminiRequest request) { + try { + ResponseStream responseStream = request.model + .generateContentStream(request.contents); + + return Flux.fromStream(responseStream.stream()); + } + catch (Exception e) { + throw new RuntimeException("Failed to generate content", e); + } + } + @Override protected boolean isToolFunctionCall(GenerateContentResponse response) { if (response == null || CollectionUtils.isEmpty(response.getCandidatesList()) diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/function/AbstractFunctionCallSupport.java b/spring-ai-core/src/main/java/org/springframework/ai/model/function/AbstractFunctionCallSupport.java index d1c49862570..d5be8ef6cad 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/model/function/AbstractFunctionCallSupport.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/function/AbstractFunctionCallSupport.java @@ -15,6 +15,10 @@ */ package org.springframework.ai.model.function; +import org.springframework.util.CollectionUtils; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -22,10 +26,9 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import org.springframework.util.CollectionUtils; - /** * @author Christian Tzolov + * @author Grogdunn */ public abstract class AbstractFunctionCallSupport { @@ -147,6 +150,36 @@ protected Resp handleFunctionCallOrReturn(Req request, Resp response) { return this.callWithFunctionSupport(newRequest); } + protected Flux callWithFunctionSupportStream(Req request) { + final Flux response = this.doChatCompletionStream(request); + return this.handleFunctionCallOrReturnStream(request, response); + } + + protected Flux handleFunctionCallOrReturnStream(Req request, Flux response) { + + return response.switchMap(resp -> { + if (!this.isToolFunctionCall(resp)) { + return Mono.just(resp); + } + + // The chat completion tool call requires the complete conversation + // history. Including the initial user message. + List conversationHistory = new ArrayList<>(); + + conversationHistory.addAll(this.doGetUserMessages(request)); + + Msg responseMessage = this.doGetToolResponseMessage(resp); + + // Add the assistant response to the message conversation history. + conversationHistory.add(responseMessage); + + Req newRequest = this.doCreateToolResponseRequest(request, responseMessage, conversationHistory); + + return this.callWithFunctionSupportStream(newRequest); + }); + + } + abstract protected Req doCreateToolResponseRequest(Req previousRequest, Msg responseMessage, List conversationHistory); @@ -156,6 +189,8 @@ abstract protected Req doCreateToolResponseRequest(Req previousRequest, Msg resp abstract protected Resp doChatCompletion(Req request); + abstract protected Flux doChatCompletionStream(Req request); + abstract protected boolean isToolFunctionCall(Resp response); } From 368f7bdfba935aa83c079da851c14b3ea409ca68 Mon Sep 17 00:00:00 2001 From: Mark Pollack Date: Wed, 15 May 2024 09:55:39 +0200 Subject: [PATCH 13/39] Remove filter on external knowledge. * Update question so that evaulator passes fix formatting --- .../ai/openai/chat/chatbot/OpenAiDefaultChatBotIT.java | 3 ++- .../ai/chat/prompt/transformer/QuestionContextAugmentor.java | 5 +---- .../ai/chat/prompt/transformer/VectorStoreRetriever.java | 4 ++-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/OpenAiDefaultChatBotIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/OpenAiDefaultChatBotIT.java index f6ae162bd90..c22085a2160 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/OpenAiDefaultChatBotIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/OpenAiDefaultChatBotIT.java @@ -26,6 +26,7 @@ import org.springframework.ai.chat.prompt.transformer.TransformerContentType; import org.springframework.ai.document.Document; import org.springframework.ai.openai.OpenAiChatOptions; +import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.qdrant.QdrantContainer; @@ -91,7 +92,7 @@ public OpenAiDefaultChatBotIT(ChatClient chatClient, ChatBot chatBot, VectorStor void simpleChat() { loadData(); - var prompt = new Prompt(new UserMessage("What bike is good for city commuting?")); + var prompt = new Prompt(new UserMessage("What reliable road bike?")); var chatBotResponse = this.chatBot.call(new PromptContext(prompt)); String answer = chatBotResponse.getChatResponse().getResult().getOutput().getContent(); assertTrue(answer.contains("Celerity"), "Response does not include 'Celerity'"); diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/QuestionContextAugmentor.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/QuestionContextAugmentor.java index 7a99d9eb85d..12e4aada2aa 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/QuestionContextAugmentor.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/QuestionContextAugmentor.java @@ -61,10 +61,7 @@ public PromptContext transform(PromptContext promptContext) { } protected String doCreateContext(List data) { - return data.stream() - .filter(content -> content.getMetadata().containsKey(TransformerContentType.EXTERNAL_KNOWLEDGE)) - .map(Content::getContent) - .collect(Collectors.joining(System.lineSeparator())); + return data.stream().map(Content::getContent).collect(Collectors.joining(System.lineSeparator())); } private Map doCreateContextMap(Prompt prompt, String context) { diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/VectorStoreRetriever.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/VectorStoreRetriever.java index 92608b3d99e..8a9e58b1cca 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/VectorStoreRetriever.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/VectorStoreRetriever.java @@ -23,6 +23,7 @@ import org.springframework.ai.document.Document; import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.ai.vectorstore.filter.Filter; import java.util.List; import java.util.Objects; @@ -60,8 +61,7 @@ public PromptContext transform(PromptContext promptContext) { .map(m -> m.getContent()) .collect(Collectors.joining(System.lineSeparator())); - List documents = vectorStore.similaritySearch(searchRequest.withQuery(userMessage) - .withFilterExpression(TransformerContentType.EXTERNAL_KNOWLEDGE + "=='true'")); + List documents = vectorStore.similaritySearch(searchRequest.withQuery(userMessage)); logger.info("Retrieved {} documents for user message {}", documents.size(), userMessage); for (Document document : documents) { From d610dd6f1dfc9d3b97c6634425c5861c78978f99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edd=C3=BA=20Mel=C3=A9ndez?= Date: Sat, 11 May 2024 20:19:42 +0200 Subject: [PATCH 14/39] Add default collection name to Qdrant Add a default collection name similar to other vector store implementations. Currently, when using starters, qdrant requires a collection name. Otherwise, it fails. --- .../vectorstore/qdrant/QdrantVectorStoreProperties.java | 3 ++- .../qdrant/QdrantVectorStoreAutoConfigurationIT.java | 6 ++---- .../qdrant/QdrantVectorStorePropertiesTests.java | 4 +++- .../ai/vectorstore/qdrant/QdrantVectorStore.java | 2 ++ 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreProperties.java index c843f769305..585c8f0ab34 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreProperties.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreProperties.java @@ -15,6 +15,7 @@ */ package org.springframework.ai.autoconfigure.vectorstore.qdrant; +import org.springframework.ai.vectorstore.qdrant.QdrantVectorStore; import org.springframework.boot.context.properties.ConfigurationProperties; /** @@ -29,7 +30,7 @@ public class QdrantVectorStoreProperties { /** * The name of the collection to use in Qdrant. */ - private String collectionName; + private String collectionName = QdrantVectorStore.DEFAULT_COLLECTION_NAME; /** * The host of the Qdrant server. diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreAutoConfigurationIT.java index 127dac087df..52dda7e9683 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreAutoConfigurationIT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStoreAutoConfigurationIT.java @@ -40,13 +40,12 @@ /** * @author Christian Tzolov + * @author Eddú Meléndez * @since 0.8.1 */ @Testcontainers public class QdrantVectorStoreAutoConfigurationIT { - private static final String COLLECTION_NAME = "test_collection"; - private static final int QDRANT_GRPC_PORT = 6334; @Container @@ -61,8 +60,7 @@ public class QdrantVectorStoreAutoConfigurationIT { .withConfiguration(AutoConfigurations.of(QdrantVectorStoreAutoConfiguration.class)) .withUserConfiguration(Config.class) .withPropertyValues("spring.ai.vectorstore.qdrant.port=" + qdrantContainer.getMappedPort(QDRANT_GRPC_PORT), - "spring.ai.vectorstore.qdrant.host=" + qdrantContainer.getHost(), - "spring.ai.vectorstore.qdrant.collectionName=" + COLLECTION_NAME); + "spring.ai.vectorstore.qdrant.host=" + qdrantContainer.getHost()); @Test public void addAndSearch() { diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStorePropertiesTests.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStorePropertiesTests.java index 324d73b88bf..878f298ab3b 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStorePropertiesTests.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vectorstore/qdrant/QdrantVectorStorePropertiesTests.java @@ -16,11 +16,13 @@ package org.springframework.ai.autoconfigure.vectorstore.qdrant; import org.junit.jupiter.api.Test; +import org.springframework.ai.vectorstore.qdrant.QdrantVectorStore; import static org.assertj.core.api.Assertions.assertThat; /** * @author Christian Tzolov + * @author Eddú Meléndez */ public class QdrantVectorStorePropertiesTests { @@ -28,7 +30,7 @@ public class QdrantVectorStorePropertiesTests { public void defaultValues() { var props = new QdrantVectorStoreProperties(); - assertThat(props.getCollectionName()).isNull(); + assertThat(props.getCollectionName()).isEqualTo(QdrantVectorStore.DEFAULT_COLLECTION_NAME); assertThat(props.getHost()).isEqualTo("localhost"); assertThat(props.getPort()).isEqualTo(6334); assertThat(props.isUseTls()).isFalse(); diff --git a/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java b/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java index 67a651bad7e..fcbe464fbc2 100644 --- a/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java +++ b/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java @@ -59,6 +59,8 @@ public class QdrantVectorStore implements VectorStore, InitializingBean { private static final String DISTANCE_FIELD_NAME = "distance"; + public static final String DEFAULT_COLLECTION_NAME = "vector_store"; + private final EmbeddingClient embeddingClient; private final QdrantClient qdrantClient; From 867154c08251c661fbf3525bd58718a8810c7d84 Mon Sep 17 00:00:00 2001 From: Mark Pollack Date: Wed, 15 May 2024 13:57:34 +0200 Subject: [PATCH 15/39] Changed ChatBot to ChatService with other related name changes * PromptContext -> ChatServiceContext * Added PromptChange to ChatServiceContext to capture PromptTransformer changes * DefaultChatBot -> PromptTransformingChatService * DefaultStreamingChatBot -> StreamingPromptTransformingChatService * package name changes, chatbot->service and history->memory * Added fluent builders to a few PromptTransformer implementations * Add license headers --- .../ChatMemoryLongTermSystemPromptIT.java | 38 ++-- .../ChatMemoryShortTermMessageListIT.java | 42 ++-- .../ChatMemoryShortTermSystemPromptIT.java | 42 ++-- .../LongShortTermChatMemoryWithRagIT.java | 63 +++--- ...penAiPromptTransformingChatServiceIT.java} | 30 +-- .../chatbot/StreamingChatBotResponse.java | 72 ------- .../chat/{history => memory}/ChatMemory.java | 2 +- .../ChatMemoryChatServiceListener.java} | 24 +-- .../ChatMemoryRetriever.java | 81 ++++++-- .../InMemoryChatMemory.java | 2 +- .../LastMaxTokenSizeContentTransformer.java | 26 +-- .../MessageChatMemoryAugmentor.java | 19 +- .../SystemPromptChatMemoryAugmentor.java | 20 +- ...orStoreChatMemoryChatServiceListener.java} | 30 +-- .../VectorStoreChatMemoryRetriever.java | 18 +- .../AbstractPromptTransformer.java | 39 ++++ .../transformer/ChatServiceContext.java | 195 ++++++++++++++++++ .../chat/prompt/transformer/PromptChange.java | 31 +++ .../prompt/transformer/PromptContext.java | 189 ----------------- .../prompt/transformer/PromptTransformer.java | 19 +- .../transformer/QuestionContextAugmentor.java | 67 ++++-- .../transformer/TransformerContentType.java | 3 + .../transformer/VectorStoreRetriever.java | 32 ++- .../ChatBot.java => service/ChatService.java} | 21 +- .../ChatServiceListener.java} | 14 +- .../ChatServiceResponse.java} | 30 +-- .../PromptTransformingChatService.java} | 68 +++--- .../StreamingChatService.java} | 23 +-- .../service/StreamingChatServiceResponse.java | 73 +++++++ ...reamingPromptTransformingChatService.java} | 64 +++--- .../ai/evaluation/EvaluationRequest.java | 8 +- .../org/springframework/ai/model/Content.java | 2 +- .../{history => memory}/ChatMemoryTests.java | 32 +-- .../ai/evaluation/BaseMemoryTest.java | 59 +++--- 34 files changed, 836 insertions(+), 642 deletions(-) rename models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/{chatbot => service}/ChatMemoryLongTermSystemPromptIT.java (73%) rename models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/{chatbot => service}/ChatMemoryShortTermMessageListIT.java (65%) rename models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/{chatbot => service}/ChatMemoryShortTermSystemPromptIT.java (65%) rename models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/{chatbot => service}/LongShortTermChatMemoryWithRagIT.java (77%) rename models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/{chatbot/OpenAiDefaultChatBotIT.java => service/OpenAiPromptTransformingChatServiceIT.java} (85%) delete mode 100644 spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/StreamingChatBotResponse.java rename spring-ai-core/src/main/java/org/springframework/ai/chat/{history => memory}/ChatMemory.java (95%) rename spring-ai-core/src/main/java/org/springframework/ai/chat/{history/ChatMemoryChatBotListener.java => memory/ChatMemoryChatServiceListener.java} (61%) rename spring-ai-core/src/main/java/org/springframework/ai/chat/{history => memory}/ChatMemoryRetriever.java (52%) rename spring-ai-core/src/main/java/org/springframework/ai/chat/{history => memory}/InMemoryChatMemory.java (97%) rename spring-ai-core/src/main/java/org/springframework/ai/chat/{history => memory}/LastMaxTokenSizeContentTransformer.java (77%) rename spring-ai-core/src/main/java/org/springframework/ai/chat/{history => memory}/MessageChatMemoryAugmentor.java (72%) rename spring-ai-core/src/main/java/org/springframework/ai/chat/{history => memory}/SystemPromptChatMemoryAugmentor.java (81%) rename spring-ai-core/src/main/java/org/springframework/ai/chat/{history/VectorStoreChatMemoryChatBotListener.java => memory/VectorStoreChatMemoryChatServiceListener.java} (68%) rename spring-ai-core/src/main/java/org/springframework/ai/chat/{history => memory}/VectorStoreChatMemoryRetriever.java (77%) create mode 100644 spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/AbstractPromptTransformer.java create mode 100644 spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/ChatServiceContext.java create mode 100644 spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/PromptChange.java delete mode 100644 spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/PromptContext.java rename spring-ai-core/src/main/java/org/springframework/ai/chat/{chatbot/ChatBot.java => service/ChatService.java} (54%) rename spring-ai-core/src/main/java/org/springframework/ai/chat/{chatbot/ChatBotListener.java => service/ChatServiceListener.java} (61%) rename spring-ai-core/src/main/java/org/springframework/ai/chat/{chatbot/ChatBotResponse.java => service/ChatServiceResponse.java} (54%) rename spring-ai-core/src/main/java/org/springframework/ai/chat/{chatbot/DefaultChatBot.java => service/PromptTransformingChatService.java} (50%) rename spring-ai-core/src/main/java/org/springframework/ai/chat/{chatbot/StreamingChatBot.java => service/StreamingChatService.java} (51%) create mode 100644 spring-ai-core/src/main/java/org/springframework/ai/chat/service/StreamingChatServiceResponse.java rename spring-ai-core/src/main/java/org/springframework/ai/chat/{chatbot/DefaultStreamingChatBot.java => service/StreamingPromptTransformingChatService.java} (54%) rename spring-ai-core/src/test/java/org/springframework/ai/chat/{history => memory}/ChatMemoryTests.java (78%) diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/ChatMemoryLongTermSystemPromptIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/ChatMemoryLongTermSystemPromptIT.java similarity index 73% rename from models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/ChatMemoryLongTermSystemPromptIT.java rename to models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/ChatMemoryLongTermSystemPromptIT.java index 237c1d56ecc..4516e08aefa 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/ChatMemoryLongTermSystemPromptIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/ChatMemoryLongTermSystemPromptIT.java @@ -14,25 +14,25 @@ * limitations under the License. */ -package org.springframework.ai.openai.chat.chatbot; +package org.springframework.ai.openai.chat.service; import java.util.List; import io.qdrant.client.QdrantClient; import io.qdrant.client.QdrantGrpcClient; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; -import org.springframework.ai.chat.chatbot.ChatBot; +import org.springframework.ai.chat.service.ChatService; +import org.springframework.ai.chat.service.StreamingChatService; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.qdrant.QdrantContainer; -import org.springframework.ai.chat.chatbot.DefaultChatBot; -import org.springframework.ai.chat.chatbot.DefaultStreamingChatBot; -import org.springframework.ai.chat.chatbot.StreamingChatBot; -import org.springframework.ai.chat.history.VectorStoreChatMemoryChatBotListener; -import org.springframework.ai.chat.history.VectorStoreChatMemoryRetriever; -import org.springframework.ai.chat.history.LastMaxTokenSizeContentTransformer; -import org.springframework.ai.chat.history.SystemPromptChatMemoryAugmentor; +import org.springframework.ai.chat.service.PromptTransformingChatService; +import org.springframework.ai.chat.service.StreamingPromptTransformingChatService; +import org.springframework.ai.chat.memory.VectorStoreChatMemoryChatServiceListener; +import org.springframework.ai.chat.memory.VectorStoreChatMemoryRetriever; +import org.springframework.ai.chat.memory.LastMaxTokenSizeContentTransformer; +import org.springframework.ai.chat.memory.SystemPromptChatMemoryAugmentor; import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.evaluation.BaseMemoryTest; import org.springframework.ai.evaluation.RelevancyEvaluator; @@ -61,9 +61,9 @@ public class ChatMemoryLongTermSystemPromptIT extends BaseMemoryTest { static QdrantContainer qdrantContainer = new QdrantContainer("qdrant/qdrant:v1.9.2"); @Autowired - public ChatMemoryLongTermSystemPromptIT(RelevancyEvaluator relevancyEvaluator, ChatBot chatBot, - StreamingChatBot streamingChatBot) { - super(relevancyEvaluator, chatBot, streamingChatBot); + public ChatMemoryLongTermSystemPromptIT(RelevancyEvaluator relevancyEvaluator, ChatService chatService, + StreamingChatService streamingChatService) { + super(relevancyEvaluator, chatService, streamingChatService); } @SpringBootConfiguration @@ -98,26 +98,26 @@ public TokenCountEstimator tokenCountEstimator() { } @Bean - public ChatBot memoryChatBot(OpenAiChatClient chatClient, VectorStore vectorStore, + public ChatService memoryChatService(OpenAiChatClient chatClient, VectorStore vectorStore, TokenCountEstimator tokenCountEstimator) { - return DefaultChatBot.builder(chatClient) + return PromptTransformingChatService.builder(chatClient) .withRetrievers(List.of(new VectorStoreChatMemoryRetriever(vectorStore, 10))) .withContentPostProcessors(List.of(new LastMaxTokenSizeContentTransformer(tokenCountEstimator, 1000))) .withAugmentors(List.of(new SystemPromptChatMemoryAugmentor())) - .withChatBotListeners(List.of(new VectorStoreChatMemoryChatBotListener(vectorStore))) + .withChatServiceListeners(List.of(new VectorStoreChatMemoryChatServiceListener(vectorStore))) .build(); } @Bean - public StreamingChatBot memoryStreamingChatBot(OpenAiChatClient streamingChatClient, VectorStore vectorStore, - TokenCountEstimator tokenCountEstimator) { + public StreamingChatService memoryStreamingChatService(OpenAiChatClient streamingChatClient, + VectorStore vectorStore, TokenCountEstimator tokenCountEstimator) { - return DefaultStreamingChatBot.builder(streamingChatClient) + return StreamingPromptTransformingChatService.builder(streamingChatClient) .withRetrievers(List.of(new VectorStoreChatMemoryRetriever(vectorStore, 10))) .withDocumentPostProcessors(List.of(new LastMaxTokenSizeContentTransformer(tokenCountEstimator, 1000))) .withAugmentors(List.of(new SystemPromptChatMemoryAugmentor())) - .withChatBotListeners(List.of(new VectorStoreChatMemoryChatBotListener(vectorStore))) + .withChatServiceListeners(List.of(new VectorStoreChatMemoryChatServiceListener(vectorStore))) .build(); } diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/ChatMemoryShortTermMessageListIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/ChatMemoryShortTermMessageListIT.java similarity index 65% rename from models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/ChatMemoryShortTermMessageListIT.java rename to models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/ChatMemoryShortTermMessageListIT.java index 5d88ce34b99..d26f6c5630f 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/ChatMemoryShortTermMessageListIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/ChatMemoryShortTermMessageListIT.java @@ -13,22 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.ai.openai.chat.chatbot; +package org.springframework.ai.openai.chat.service; import java.util.List; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; -import org.springframework.ai.chat.chatbot.ChatBot; -import org.springframework.ai.chat.chatbot.DefaultChatBot; -import org.springframework.ai.chat.chatbot.DefaultStreamingChatBot; -import org.springframework.ai.chat.chatbot.StreamingChatBot; -import org.springframework.ai.chat.history.ChatMemory; -import org.springframework.ai.chat.history.ChatMemoryChatBotListener; -import org.springframework.ai.chat.history.ChatMemoryRetriever; -import org.springframework.ai.chat.history.InMemoryChatMemory; -import org.springframework.ai.chat.history.LastMaxTokenSizeContentTransformer; -import org.springframework.ai.chat.history.MessageChatMemoryAugmentor; +import org.springframework.ai.chat.service.ChatService; +import org.springframework.ai.chat.service.PromptTransformingChatService; +import org.springframework.ai.chat.service.StreamingPromptTransformingChatService; +import org.springframework.ai.chat.service.StreamingChatService; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.memory.ChatMemoryChatServiceListener; +import org.springframework.ai.chat.memory.ChatMemoryRetriever; +import org.springframework.ai.chat.memory.InMemoryChatMemory; +import org.springframework.ai.chat.memory.LastMaxTokenSizeContentTransformer; +import org.springframework.ai.chat.memory.MessageChatMemoryAugmentor; import org.springframework.ai.evaluation.BaseMemoryTest; import org.springframework.ai.evaluation.RelevancyEvaluator; import org.springframework.ai.openai.OpenAiChatClient; @@ -45,9 +45,9 @@ public class ChatMemoryShortTermMessageListIT extends BaseMemoryTest { @Autowired - public ChatMemoryShortTermMessageListIT(RelevancyEvaluator relevancyEvaluator, ChatBot chatBot, - StreamingChatBot streamingChatBot) { - super(relevancyEvaluator, chatBot, streamingChatBot); + public ChatMemoryShortTermMessageListIT(RelevancyEvaluator relevancyEvaluator, ChatService chatService, + StreamingChatService streamingChatService) { + super(relevancyEvaluator, chatService, streamingChatService); } @SpringBootConfiguration @@ -74,26 +74,26 @@ public TokenCountEstimator tokenCountEstimator() { } @Bean - public ChatBot memoryChatBot(OpenAiChatClient chatClient, ChatMemory chatHistory, + public ChatService memoryChatService(OpenAiChatClient chatClient, ChatMemory chatHistory, TokenCountEstimator tokenCountEstimator) { - return DefaultChatBot.builder(chatClient) + return PromptTransformingChatService.builder(chatClient) .withRetrievers(List.of(new ChatMemoryRetriever(chatHistory))) .withContentPostProcessors(List.of(new LastMaxTokenSizeContentTransformer(tokenCountEstimator, 1000))) .withAugmentors(List.of(new MessageChatMemoryAugmentor())) - .withChatBotListeners(List.of(new ChatMemoryChatBotListener(chatHistory))) + .withChatServiceListeners(List.of(new ChatMemoryChatServiceListener(chatHistory))) .build(); } @Bean - public StreamingChatBot memoryStreamingChatBot(OpenAiChatClient streamingChatClient, ChatMemory chatHistory, - TokenCountEstimator tokenCountEstimator) { + public StreamingChatService memoryStreamingChatService(OpenAiChatClient streamingChatClient, + ChatMemory chatHistory, TokenCountEstimator tokenCountEstimator) { - return DefaultStreamingChatBot.builder(streamingChatClient) + return StreamingPromptTransformingChatService.builder(streamingChatClient) .withRetrievers(List.of(new ChatMemoryRetriever(chatHistory))) .withDocumentPostProcessors(List.of(new LastMaxTokenSizeContentTransformer(tokenCountEstimator, 1000))) .withAugmentors(List.of(new MessageChatMemoryAugmentor())) - .withChatBotListeners(List.of(new ChatMemoryChatBotListener(chatHistory))) + .withChatServiceListeners(List.of(new ChatMemoryChatServiceListener(chatHistory))) .build(); } diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/ChatMemoryShortTermSystemPromptIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/ChatMemoryShortTermSystemPromptIT.java similarity index 65% rename from models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/ChatMemoryShortTermSystemPromptIT.java rename to models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/ChatMemoryShortTermSystemPromptIT.java index b3a9d43ed1a..7ca4c795bda 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/ChatMemoryShortTermSystemPromptIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/ChatMemoryShortTermSystemPromptIT.java @@ -14,22 +14,22 @@ * limitations under the License. */ -package org.springframework.ai.openai.chat.chatbot; +package org.springframework.ai.openai.chat.service; import java.util.List; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; -import org.springframework.ai.chat.chatbot.ChatBot; -import org.springframework.ai.chat.chatbot.DefaultChatBot; -import org.springframework.ai.chat.chatbot.DefaultStreamingChatBot; -import org.springframework.ai.chat.chatbot.StreamingChatBot; -import org.springframework.ai.chat.history.ChatMemory; -import org.springframework.ai.chat.history.ChatMemoryChatBotListener; -import org.springframework.ai.chat.history.ChatMemoryRetriever; -import org.springframework.ai.chat.history.InMemoryChatMemory; -import org.springframework.ai.chat.history.LastMaxTokenSizeContentTransformer; -import org.springframework.ai.chat.history.SystemPromptChatMemoryAugmentor; +import org.springframework.ai.chat.service.ChatService; +import org.springframework.ai.chat.service.PromptTransformingChatService; +import org.springframework.ai.chat.service.StreamingPromptTransformingChatService; +import org.springframework.ai.chat.service.StreamingChatService; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.memory.ChatMemoryChatServiceListener; +import org.springframework.ai.chat.memory.ChatMemoryRetriever; +import org.springframework.ai.chat.memory.InMemoryChatMemory; +import org.springframework.ai.chat.memory.LastMaxTokenSizeContentTransformer; +import org.springframework.ai.chat.memory.SystemPromptChatMemoryAugmentor; import org.springframework.ai.evaluation.BaseMemoryTest; import org.springframework.ai.evaluation.RelevancyEvaluator; import org.springframework.ai.openai.OpenAiChatClient; @@ -46,9 +46,9 @@ public class ChatMemoryShortTermSystemPromptIT extends BaseMemoryTest { @Autowired - public ChatMemoryShortTermSystemPromptIT(RelevancyEvaluator relevancyEvaluator, ChatBot chatBot, - StreamingChatBot streamingChatBot) { - super(relevancyEvaluator, chatBot, streamingChatBot); + public ChatMemoryShortTermSystemPromptIT(RelevancyEvaluator relevancyEvaluator, ChatService chatService, + StreamingChatService streamingChatService) { + super(relevancyEvaluator, chatService, streamingChatService); } @SpringBootConfiguration @@ -75,26 +75,26 @@ public TokenCountEstimator tokenCountEstimator() { } @Bean - public ChatBot memoryChatBot(OpenAiChatClient chatClient, ChatMemory chatHistory, + public ChatService memoryChatService(OpenAiChatClient chatClient, ChatMemory chatHistory, TokenCountEstimator tokenCountEstimator) { - return DefaultChatBot.builder(chatClient) + return PromptTransformingChatService.builder(chatClient) .withRetrievers(List.of(new ChatMemoryRetriever(chatHistory))) .withContentPostProcessors(List.of(new LastMaxTokenSizeContentTransformer(tokenCountEstimator, 1000))) .withAugmentors(List.of(new SystemPromptChatMemoryAugmentor())) - .withChatBotListeners(List.of(new ChatMemoryChatBotListener(chatHistory))) + .withChatServiceListeners(List.of(new ChatMemoryChatServiceListener(chatHistory))) .build(); } @Bean - public StreamingChatBot memoryStreamingChatBot(OpenAiChatClient streamingChatClient, ChatMemory chatHistory, - TokenCountEstimator tokenCountEstimator) { + public StreamingChatService memoryStreamingChatService(OpenAiChatClient streamingChatClient, + ChatMemory chatHistory, TokenCountEstimator tokenCountEstimator) { - return DefaultStreamingChatBot.builder(streamingChatClient) + return StreamingPromptTransformingChatService.builder(streamingChatClient) .withRetrievers(List.of(new ChatMemoryRetriever(chatHistory))) .withDocumentPostProcessors(List.of(new LastMaxTokenSizeContentTransformer(tokenCountEstimator, 1000))) .withAugmentors(List.of(new SystemPromptChatMemoryAugmentor())) - .withChatBotListeners(List.of(new ChatMemoryChatBotListener(chatHistory))) + .withChatServiceListeners(List.of(new ChatMemoryChatServiceListener(chatHistory))) .build(); } diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/LongShortTermChatMemoryWithRagIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/LongShortTermChatMemoryWithRagIT.java similarity index 77% rename from models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/LongShortTermChatMemoryWithRagIT.java rename to models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/LongShortTermChatMemoryWithRagIT.java index 9d89014e688..f8fc8f71f6f 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/LongShortTermChatMemoryWithRagIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/LongShortTermChatMemoryWithRagIT.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.openai.chat.chatbot; +package org.springframework.ai.openai.chat.service; import java.util.List; import java.util.Map; @@ -26,24 +26,24 @@ import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.ai.chat.chatbot.ChatBot; +import org.springframework.ai.chat.prompt.transformer.ChatServiceContext; +import org.springframework.ai.chat.service.ChatService; +import org.springframework.ai.chat.service.PromptTransformingChatService; import org.springframework.ai.openai.OpenAiChatOptions; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.qdrant.QdrantContainer; -import org.springframework.ai.chat.chatbot.DefaultChatBot; -import org.springframework.ai.chat.history.ChatMemory; -import org.springframework.ai.chat.history.ChatMemoryChatBotListener; -import org.springframework.ai.chat.history.ChatMemoryRetriever; -import org.springframework.ai.chat.history.InMemoryChatMemory; -import org.springframework.ai.chat.history.LastMaxTokenSizeContentTransformer; -import org.springframework.ai.chat.history.SystemPromptChatMemoryAugmentor; -import org.springframework.ai.chat.history.VectorStoreChatMemoryChatBotListener; -import org.springframework.ai.chat.history.VectorStoreChatMemoryRetriever; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.memory.ChatMemoryChatServiceListener; +import org.springframework.ai.chat.memory.ChatMemoryRetriever; +import org.springframework.ai.chat.memory.InMemoryChatMemory; +import org.springframework.ai.chat.memory.LastMaxTokenSizeContentTransformer; +import org.springframework.ai.chat.memory.SystemPromptChatMemoryAugmentor; +import org.springframework.ai.chat.memory.VectorStoreChatMemoryChatServiceListener; +import org.springframework.ai.chat.memory.VectorStoreChatMemoryRetriever; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.chat.prompt.transformer.PromptContext; import org.springframework.ai.chat.prompt.transformer.QuestionContextAugmentor; import org.springframework.ai.chat.prompt.transformer.TransformerContentType; import org.springframework.ai.chat.prompt.transformer.VectorStoreRetriever; @@ -89,7 +89,7 @@ public class LongShortTermChatMemoryWithRagIT { static QdrantContainer qdrantContainer = new QdrantContainer("qdrant/qdrant:v1.9.2"); @Autowired - ChatBot chatBot; + ChatService chatService; @Autowired RelevancyEvaluator relevancyEvaluator; @@ -122,29 +122,29 @@ public List apply(List documents) { } // @Autowired - // StreamingChatBot streamingChatBot; + // StreamingChatService streamingChatService; @Test - void memoryChatBot() { + void memoryChatService() { loadData(); var prompt = new Prompt(new UserMessage("My name is Christian and I like mountain bikes.")); - PromptContext promptContext = new PromptContext(prompt); + ChatServiceContext chatServiceContext = new ChatServiceContext(prompt); - var chatBotResponse1 = this.chatBot.call(promptContext); + var chatServiceResponse1 = this.chatService.call(chatServiceContext); - logger.info("Response1: " + chatBotResponse1.getChatResponse().getResult().getOutput().getContent()); + logger.info("Response1: " + chatServiceResponse1.getChatResponse().getResult().getOutput().getContent()); - var chatBotResponse2 = this.chatBot.call(new PromptContext( + var chatServiceResponse2 = this.chatService.call(new ChatServiceContext( new Prompt(new String("What is my name and what bike model would you suggest for me?")))); - logger.info("Response2: " + chatBotResponse2.getChatResponse().getResult().getOutput().getContent()); + logger.info("Response2: " + chatServiceResponse2.getChatResponse().getResult().getOutput().getContent()); - // logger.info(chatBotResponse2.getPromptContext().getContents().toString()); - assertThat(chatBotResponse2.getChatResponse().getResult().getOutput().getContent()).contains("Christian"); + // logger.info(chatServiceResponse2.getPromptContext().getContents().toString()); + assertThat(chatServiceResponse2.getChatResponse().getResult().getOutput().getContent()).contains("Christian"); EvaluationResponse evaluationResponse = this.relevancyEvaluator - .evaluate(new EvaluationRequest(chatBotResponse2)); + .evaluate(new EvaluationRequest(chatServiceResponse2)); assertTrue(evaluationResponse.isPass(), "Response is not relevant to the question"); @@ -187,12 +187,15 @@ public TokenCountEstimator tokenCountEstimator() { } @Bean - public ChatBot memoryChatBot(OpenAiChatClient chatClient, VectorStore vectorStore, + public ChatService memoryChatService(OpenAiChatClient chatClient, VectorStore vectorStore, TokenCountEstimator tokenCountEstimator, ChatMemory chatHistory) { - return DefaultChatBot.builder(chatClient) + return PromptTransformingChatService.builder(chatClient) .withRetrievers(List.of(new VectorStoreRetriever(vectorStore, SearchRequest.defaults()), - new ChatMemoryRetriever(chatHistory, Map.of(TransformerContentType.SHORT_TERM_MEMORY, "")), + ChatMemoryRetriever.builder() + .withChatHistory(chatHistory) + .withMetadata(Map.of(TransformerContentType.SHORT_TERM_MEMORY, "")) + .build(), new VectorStoreChatMemoryRetriever(vectorStore, 10, Map.of(TransformerContentType.LONG_TERM_MEMORY, "")))) @@ -214,19 +217,19 @@ public ChatBot memoryChatBot(OpenAiChatClient chatClient, VectorStore vectorStor Set.of(TransformerContentType.LONG_TERM_MEMORY)), new SystemPromptChatMemoryAugmentor(Set.of(TransformerContentType.SHORT_TERM_MEMORY)))) - .withChatBotListeners(List.of(new ChatMemoryChatBotListener(chatHistory), - new VectorStoreChatMemoryChatBotListener(vectorStore, + .withChatServiceListeners(List.of(new ChatMemoryChatServiceListener(chatHistory), + new VectorStoreChatMemoryChatServiceListener(vectorStore, Map.of(TransformerContentType.LONG_TERM_MEMORY, "")))) .build(); } // @Bean - // public StreamingChatBot memoryStreamingChatAgent(OpenAiChatClient + // public StreamingChatService memoryStreamingChatAgent(OpenAiChatClient // streamingChatClient, // VectorStore vectorStore, TokenCountEstimator tokenCountEstimator, ChatHistory // chatHistory) { - // return DefaultStreamingChatBot.builder(streamingChatClient) + // return StreamingPromptTransformingChatService.builder(streamingChatClient) // .withRetrievers(List.of(new ChatHistoryRetriever(chatHistory), new // DocumentChatHistoryRetriever(vectorStore, 10))) // .withDocumentPostProcessors(List.of(new diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/OpenAiDefaultChatBotIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/OpenAiPromptTransformingChatServiceIT.java similarity index 85% rename from models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/OpenAiDefaultChatBotIT.java rename to models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/OpenAiPromptTransformingChatServiceIT.java index c22085a2160..b164b8343d4 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/chatbot/OpenAiDefaultChatBotIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/OpenAiPromptTransformingChatServiceIT.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.openai.chat.chatbot; +package org.springframework.ai.openai.chat.service; import java.util.List; @@ -22,20 +22,19 @@ import io.qdrant.client.QdrantGrpcClient; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; -import org.springframework.ai.chat.chatbot.ChatBot; +import org.springframework.ai.chat.service.ChatService; import org.springframework.ai.chat.prompt.transformer.TransformerContentType; import org.springframework.ai.document.Document; import org.springframework.ai.openai.OpenAiChatOptions; -import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.qdrant.QdrantContainer; import org.springframework.ai.chat.ChatClient; -import org.springframework.ai.chat.chatbot.DefaultChatBot; +import org.springframework.ai.chat.service.PromptTransformingChatService; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.chat.prompt.transformer.PromptContext; +import org.springframework.ai.chat.prompt.transformer.ChatServiceContext; import org.springframework.ai.chat.prompt.transformer.QuestionContextAugmentor; import org.springframework.ai.chat.prompt.transformer.VectorStoreRetriever; import org.springframework.ai.embedding.EmbeddingClient; @@ -61,9 +60,9 @@ import static org.springframework.ai.openai.api.OpenAiApi.ChatModel.GPT_4_TURBO_PREVIEW; @Testcontainers -@SpringBootTest(classes = OpenAiDefaultChatBotIT.Config.class) +@SpringBootTest(classes = OpenAiPromptTransformingChatServiceIT.Config.class) @EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") -public class OpenAiDefaultChatBotIT { +public class OpenAiPromptTransformingChatServiceIT { private static final String COLLECTION_NAME = "test_collection"; @@ -79,12 +78,13 @@ public class OpenAiDefaultChatBotIT { @Value("classpath:/data/acme/bikes.json") private Resource bikesResource; - private ChatBot chatBot; + private ChatService chatService; @Autowired - public OpenAiDefaultChatBotIT(ChatClient chatClient, ChatBot chatBot, VectorStore vectorStore) { + public OpenAiPromptTransformingChatServiceIT(ChatClient chatClient, ChatService chatService, + VectorStore vectorStore) { this.chatClient = chatClient; - this.chatBot = chatBot; + this.chatService = chatService; this.vectorStore = vectorStore; } @@ -93,8 +93,8 @@ void simpleChat() { loadData(); var prompt = new Prompt(new UserMessage("What reliable road bike?")); - var chatBotResponse = this.chatBot.call(new PromptContext(prompt)); - String answer = chatBotResponse.getChatResponse().getResult().getOutput().getContent(); + var chatServiceResponse = this.chatService.call(new ChatServiceContext(prompt)); + String answer = chatServiceResponse.getChatResponse().getResult().getOutput().getContent(); assertTrue(answer.contains("Celerity"), "Response does not include 'Celerity'"); // Use GPT 4 as a better model for determining relevancy. gpt 3.5 makes basic @@ -103,7 +103,7 @@ void simpleChat() { .withModel(GPT_4_TURBO_PREVIEW.getValue()) .build(); var relevancyEvaluator = new RelevancyEvaluator(this.chatClient, openAiChatOptions); - EvaluationRequest evaluationRequest = new EvaluationRequest(chatBotResponse); + EvaluationRequest evaluationRequest = new EvaluationRequest(chatServiceResponse); EvaluationResponse evaluationResponse = relevancyEvaluator.evaluate(evaluationRequest); assertTrue(evaluationResponse.isPass(), "Response is not relevant to the question"); @@ -148,8 +148,8 @@ public VectorStore qdrantVectorStore(EmbeddingClient embeddingClient) { } @Bean - public ChatBot chatBot(ChatClient chatClient, VectorStore vectorStore) { - return DefaultChatBot.builder(chatClient) + public ChatService chatService(ChatClient chatClient, VectorStore vectorStore) { + return PromptTransformingChatService.builder(chatClient) .withRetrievers(List.of(new VectorStoreRetriever(vectorStore, SearchRequest.defaults()))) .withAugmentors(List.of(new QuestionContextAugmentor())) .build(); diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/StreamingChatBotResponse.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/StreamingChatBotResponse.java deleted file mode 100644 index 5e699278cb4..00000000000 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/StreamingChatBotResponse.java +++ /dev/null @@ -1,72 +0,0 @@ -package org.springframework.ai.chat.chatbot; - -import reactor.core.publisher.Flux; - -import org.springframework.ai.chat.ChatResponse; -import org.springframework.ai.chat.prompt.transformer.PromptContext; - -/** - * Encapsulates the response from the ChatBot. Contains the most up-to-date PromptContext - * and the final ChatResponse - * - * @author Mark Pollack - * @since 1.0 M1 - */ -public class StreamingChatBotResponse { - - private final PromptContext promptContext; - - private final Flux chatResponse; - - public StreamingChatBotResponse(PromptContext promptContext, Flux chatResponse) { - this.promptContext = promptContext; - this.chatResponse = chatResponse; - } - - public PromptContext getPromptContext() { - return promptContext; - } - - public Flux getChatResponse() { - return chatResponse; - } - - @Override - public String toString() { - return "ChatBotResponse{" + "promptContext=" + promptContext + ", chatResponse=" + chatResponse + '}'; - } - - @Override - public int hashCode() { - final int prime = 31; - int result = 1; - result = prime * result + ((promptContext == null) ? 0 : promptContext.hashCode()); - result = prime * result + ((chatResponse == null) ? 0 : chatResponse.hashCode()); - return result; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) - return true; - if (obj == null) - return false; - if (getClass() != obj.getClass()) - return false; - StreamingChatBotResponse other = (StreamingChatBotResponse) obj; - if (promptContext == null) { - if (other.promptContext != null) - return false; - } - else if (!promptContext.equals(other.promptContext)) - return false; - if (chatResponse == null) { - if (other.chatResponse != null) - return false; - } - else if (!chatResponse.equals(other.chatResponse)) - return false; - return true; - } - -} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/history/ChatMemory.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/memory/ChatMemory.java similarity index 95% rename from spring-ai-core/src/main/java/org/springframework/ai/chat/history/ChatMemory.java rename to spring-ai-core/src/main/java/org/springframework/ai/chat/memory/ChatMemory.java index 26d4f134285..52767072dd0 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/history/ChatMemory.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/memory/ChatMemory.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.chat.history; +package org.springframework.ai.chat.memory; import java.util.List; diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/history/ChatMemoryChatBotListener.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/memory/ChatMemoryChatServiceListener.java similarity index 61% rename from spring-ai-core/src/main/java/org/springframework/ai/chat/history/ChatMemoryChatBotListener.java rename to spring-ai-core/src/main/java/org/springframework/ai/chat/memory/ChatMemoryChatServiceListener.java index 2ddf3048946..934c3d43d71 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/history/ChatMemoryChatBotListener.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/memory/ChatMemoryChatServiceListener.java @@ -14,47 +14,47 @@ * limitations under the License. */ -package org.springframework.ai.chat.history; +package org.springframework.ai.chat.memory; import java.util.List; -import org.springframework.ai.chat.chatbot.ChatBotResponse; -import org.springframework.ai.chat.chatbot.ChatBotListener; +import org.springframework.ai.chat.service.ChatServiceResponse; +import org.springframework.ai.chat.service.ChatServiceListener; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.MessageType; -import org.springframework.ai.chat.prompt.transformer.PromptContext; +import org.springframework.ai.chat.prompt.transformer.ChatServiceContext; import org.springframework.ai.chat.prompt.transformer.TransformerContentType; /** * @author Christian Tzolov */ -public class ChatMemoryChatBotListener implements ChatBotListener { +public class ChatMemoryChatServiceListener implements ChatServiceListener { private final ChatMemory chatHistory; - public ChatMemoryChatBotListener(ChatMemory chatHistory) { + public ChatMemoryChatServiceListener(ChatMemory chatHistory) { this.chatHistory = chatHistory; } @Override - public void onStart(PromptContext promptContext) { - var messagesToAdd = promptContext.getPrompt() + public void onStart(ChatServiceContext chatServiceContext) { + var messagesToAdd = chatServiceContext.getPrompt() .getInstructions() .stream() .filter(m -> !m.getMetadata().containsKey(TransformerContentType.MEMORY)) .filter(m -> (m.getMessageType() == MessageType.ASSISTANT || m.getMessageType() == MessageType.USER)) .toList(); - this.chatHistory.add(promptContext.getConversationId(), messagesToAdd); + this.chatHistory.add(chatServiceContext.getConversationId(), messagesToAdd); } @Override - public void onComplete(ChatBotResponse chatBotResponse) { - List assistantMessages = chatBotResponse.getChatResponse() + public void onComplete(ChatServiceResponse chatServiceResponse) { + List assistantMessages = chatServiceResponse.getChatResponse() .getResults() .stream() .map(g -> (Message) g.getOutput()) .toList(); - this.chatHistory.add(chatBotResponse.getPromptContext().getConversationId(), assistantMessages); + this.chatHistory.add(chatServiceResponse.getPromptContext().getConversationId(), assistantMessages); } } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/history/ChatMemoryRetriever.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/memory/ChatMemoryRetriever.java similarity index 52% rename from spring-ai-core/src/main/java/org/springframework/ai/chat/history/ChatMemoryRetriever.java rename to spring-ai-core/src/main/java/org/springframework/ai/chat/memory/ChatMemoryRetriever.java index 31d08384678..23908cbaa10 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/history/ChatMemoryRetriever.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/memory/ChatMemoryRetriever.java @@ -14,68 +14,105 @@ * limitations under the License. */ -package org.springframework.ai.chat.history; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +package org.springframework.ai.chat.memory; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.MessageType; -import org.springframework.ai.chat.prompt.transformer.PromptContext; -import org.springframework.ai.chat.prompt.transformer.PromptTransformer; +import org.springframework.ai.chat.prompt.transformer.AbstractPromptTransformer; +import org.springframework.ai.chat.prompt.transformer.ChatServiceContext; import org.springframework.ai.chat.prompt.transformer.TransformerContentType; import org.springframework.ai.document.Document; import org.springframework.ai.model.Content; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + /** * @author Christian Tzolov */ -public class ChatMemoryRetriever implements PromptTransformer { +public class ChatMemoryRetriever extends AbstractPromptTransformer { private final ChatMemory chatHistory; /** * Additional metadata to be assigned to the retrieved history messages. */ - private final Map additionalMetadata; + private final Map metadata; private final int maxHistorySize; public ChatMemoryRetriever(ChatMemory chatHistory) { - this(chatHistory, Map.of()); + this(chatHistory, 1000, Map.of(), "ChatMemoryRetriever"); } - public ChatMemoryRetriever(ChatMemory chatHistory, Map additionalMetadata) { - this(chatHistory, 1000, additionalMetadata); - } - - public ChatMemoryRetriever(ChatMemory chatHistory, int maxHistorySize, Map additionalMetadata) { + public ChatMemoryRetriever(ChatMemory chatHistory, int maxHistorySize, Map metadata, String name) { this.chatHistory = chatHistory; - this.additionalMetadata = additionalMetadata; + this.metadata = metadata; this.maxHistorySize = maxHistorySize; + this.setName(name); } @Override - public PromptContext transform(PromptContext promptContext) { + public ChatServiceContext transform(ChatServiceContext chatServiceContext) { - List messageHistory = this.chatHistory.get(promptContext.getConversationId(), maxHistorySize); + List messageHistory = this.chatHistory.get(chatServiceContext.getConversationId(), maxHistorySize); List historyContent = (messageHistory != null) ? messageHistory.stream().filter(m -> m.getMessageType() != MessageType.SYSTEM).map(m -> { Content content = new Document(m.getContent(), new ArrayList<>(m.getMedia()), new HashMap<>(m.getMetadata())); - content.getMetadata().putAll(this.additionalMetadata); + content.getMetadata().putAll(this.metadata); content.getMetadata().put(TransformerContentType.MEMORY, true); return content; }).toList() : List.of(); List updatedContents = new ArrayList<>( - promptContext.getContents() != null ? promptContext.getContents() : List.of()); + chatServiceContext.getContents() != null ? chatServiceContext.getContents() : List.of()); updatedContents.addAll(historyContent); - return PromptContext.from(promptContext).withContents(updatedContents).build(); + return ChatServiceContext.from(chatServiceContext).withContents(updatedContents).build(); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private ChatMemory chatHistory; + + private Map metadata = Map.of(); + + private int maxHistorySize = 1000; + + private String name = "ChatMemoryRetriever"; + + public Builder withChatHistory(ChatMemory chatHistory) { + this.chatHistory = chatHistory; + return this; + } + + public Builder withMetadata(Map metadata) { + this.metadata = metadata; + return this; + } + + public Builder withMaxHistorySize(int maxHistorySize) { + this.maxHistorySize = maxHistorySize; + return this; + } + + public Builder withName(String name) { + this.name = name; + return this; + } + + public ChatMemoryRetriever build() { + return new ChatMemoryRetriever(this.chatHistory, this.maxHistorySize, this.metadata, this.name); + } + } } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/history/InMemoryChatMemory.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/memory/InMemoryChatMemory.java similarity index 97% rename from spring-ai-core/src/main/java/org/springframework/ai/chat/history/InMemoryChatMemory.java rename to spring-ai-core/src/main/java/org/springframework/ai/chat/memory/InMemoryChatMemory.java index 80bf6671c94..34b78963e8a 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/history/InMemoryChatMemory.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/memory/InMemoryChatMemory.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.chat.history; +package org.springframework.ai.chat.memory; import java.util.ArrayList; import java.util.List; diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/history/LastMaxTokenSizeContentTransformer.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/memory/LastMaxTokenSizeContentTransformer.java similarity index 77% rename from spring-ai-core/src/main/java/org/springframework/ai/chat/history/LastMaxTokenSizeContentTransformer.java rename to spring-ai-core/src/main/java/org/springframework/ai/chat/memory/LastMaxTokenSizeContentTransformer.java index 01b07ecd4d9..b2e1626e6bb 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/history/LastMaxTokenSizeContentTransformer.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/memory/LastMaxTokenSizeContentTransformer.java @@ -14,14 +14,14 @@ * limitations under the License. */ -package org.springframework.ai.chat.history; +package org.springframework.ai.chat.memory; import java.util.ArrayList; import java.util.List; import java.util.Set; -import org.springframework.ai.chat.prompt.transformer.PromptContext; -import org.springframework.ai.chat.prompt.transformer.PromptTransformer; +import org.springframework.ai.chat.prompt.transformer.AbstractPromptTransformer; +import org.springframework.ai.chat.prompt.transformer.ChatServiceContext; import org.springframework.ai.model.Content; import org.springframework.ai.tokenizer.TokenCountEstimator; @@ -33,7 +33,7 @@ * * @author Christian Tzolov */ -public class LastMaxTokenSizeContentTransformer implements PromptTransformer { +public class LastMaxTokenSizeContentTransformer extends AbstractPromptTransformer { protected final TokenCountEstimator tokenCountEstimator; @@ -56,15 +56,15 @@ public LastMaxTokenSizeContentTransformer(TokenCountEstimator tokenCountEstimato this.filterTags = filterTags; } - protected List doGetDatumToModify(PromptContext promptContext) { - return promptContext.getContents() + protected List doGetDatumToModify(ChatServiceContext chatServiceContext) { + return chatServiceContext.getContents() .stream() .filter(content -> this.filterTags.stream().allMatch(tag -> content.getMetadata().containsKey(tag))) .toList(); } - protected List doGetDatumNotToModify(PromptContext promptContext) { - return promptContext.getContents() + protected List doGetDatumNotToModify(ChatServiceContext chatServiceContext) { + return chatServiceContext.getContents() .stream() .filter(content -> !this.filterTags.stream().allMatch(tag -> content.getMetadata().containsKey(tag))) .toList(); @@ -79,22 +79,22 @@ protected int doEstimateTokenCount(List datum) { } @Override - public PromptContext transform(PromptContext promptContext) { + public ChatServiceContext transform(ChatServiceContext chatServiceContext) { - List datum = this.doGetDatumToModify(promptContext); + List datum = this.doGetDatumToModify(chatServiceContext); int totalSize = this.doEstimateTokenCount(datum); if (totalSize <= this.maxTokenSize) { - return promptContext; + return chatServiceContext; } List purgedContent = this.purgeExcess(datum, totalSize); - var updatedContent = new ArrayList<>(doGetDatumNotToModify(promptContext)); + var updatedContent = new ArrayList<>(doGetDatumNotToModify(chatServiceContext)); updatedContent.addAll(purgedContent); - return PromptContext.from(promptContext).withContents(updatedContent).build(); + return ChatServiceContext.from(chatServiceContext).withContents(updatedContent).build(); } protected List purgeExcess(List datum, int totalSize) { diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/history/MessageChatMemoryAugmentor.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/memory/MessageChatMemoryAugmentor.java similarity index 72% rename from spring-ai-core/src/main/java/org/springframework/ai/chat/history/MessageChatMemoryAugmentor.java rename to spring-ai-core/src/main/java/org/springframework/ai/chat/memory/MessageChatMemoryAugmentor.java index dcf3d6daa7c..e3b3ce847fd 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/history/MessageChatMemoryAugmentor.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/memory/MessageChatMemoryAugmentor.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.chat.history; +package org.springframework.ai.chat.memory; import java.util.ArrayList; import java.util.List; @@ -26,22 +26,23 @@ import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.prompt.transformer.AbstractPromptTransformer; +import org.springframework.ai.chat.prompt.transformer.ChatServiceContext; +import org.springframework.ai.chat.prompt.transformer.PromptChange; import org.springframework.ai.chat.prompt.transformer.TransformerContentType; -import org.springframework.ai.chat.prompt.transformer.PromptContext; -import org.springframework.ai.chat.prompt.transformer.PromptTransformer; /** * @author Christian Tzolov */ -public class MessageChatMemoryAugmentor implements PromptTransformer { +public class MessageChatMemoryAugmentor extends AbstractPromptTransformer { @Override - public PromptContext transform(PromptContext promptContext) { + public ChatServiceContext transform(ChatServiceContext chatServiceContext) { - var originalPrompt = promptContext.getPrompt(); + var originalPrompt = chatServiceContext.getPrompt(); // Convert the retrieved contents into a list of messages. - List historyMessages = promptContext.getContents() + List historyMessages = chatServiceContext.getContents() .stream() .filter(content -> content.getMetadata().containsKey(TransformerContentType.MEMORY)) .map(content -> { @@ -63,8 +64,10 @@ else if (messageType == MessageType.USER) { promptMessages.addAll(originalPrompt.getInstructions()); Prompt newPrompt = new Prompt(promptMessages, (ChatOptions) originalPrompt.getOptions()); + PromptChange promptChange = new PromptChange(originalPrompt, newPrompt, this.getName(), + "Added chat memory as individual messages in the prompt"); - return PromptContext.from(promptContext).withPrompt(newPrompt).addPromptHistory(originalPrompt).build(); + return ChatServiceContext.from(chatServiceContext).withPrompt(newPrompt).withPromptChange(promptChange).build(); } } \ No newline at end of file diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/history/SystemPromptChatMemoryAugmentor.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/memory/SystemPromptChatMemoryAugmentor.java similarity index 81% rename from spring-ai-core/src/main/java/org/springframework/ai/chat/history/SystemPromptChatMemoryAugmentor.java rename to spring-ai-core/src/main/java/org/springframework/ai/chat/memory/SystemPromptChatMemoryAugmentor.java index b507faf167c..2ed8306ee0f 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/history/SystemPromptChatMemoryAugmentor.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/memory/SystemPromptChatMemoryAugmentor.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.chat.history; +package org.springframework.ai.chat.memory; import java.util.ArrayList; import java.util.HashSet; @@ -28,15 +28,16 @@ import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.prompt.transformer.AbstractPromptTransformer; +import org.springframework.ai.chat.prompt.transformer.ChatServiceContext; +import org.springframework.ai.chat.prompt.transformer.PromptChange; import org.springframework.ai.chat.prompt.transformer.TransformerContentType; -import org.springframework.ai.chat.prompt.transformer.PromptContext; -import org.springframework.ai.chat.prompt.transformer.PromptTransformer; import org.springframework.util.Assert; /** * @author Christian Tzolov */ -public class SystemPromptChatMemoryAugmentor implements PromptTransformer { +public class SystemPromptChatMemoryAugmentor extends AbstractPromptTransformer { public static final String DEFAULT_HISTORY_PROMPT = """ Use the conversation history from the HISTORY section to provide accurate answers. @@ -72,9 +73,9 @@ public SystemPromptChatMemoryAugmentor(String historyPrompt, Set metadat } @Override - public PromptContext transform(PromptContext promptContext) { + public ChatServiceContext transform(ChatServiceContext chatServiceContext) { - var originalPrompt = promptContext.getPrompt(); + var originalPrompt = chatServiceContext.getPrompt(); List systemMessages = (originalPrompt.getInstructions() != null) ? originalPrompt.getInstructions() .stream() @@ -89,7 +90,7 @@ public PromptContext transform(PromptContext promptContext) { SystemMessage originalSystemMessage = (!systemMessages.isEmpty()) ? (SystemMessage) systemMessages.get(0) : new SystemMessage(""); - String historyContext = promptContext.getContents() + String historyContext = chatServiceContext.getContents() .stream() .filter(content -> this.filterTags.stream().allMatch(tag -> content.getMetadata().containsKey(tag))) .map(content -> content.getMetadata().get(AbstractMessage.MESSAGE_TYPE) + ": " + content.getContent()) @@ -103,8 +104,9 @@ public PromptContext transform(PromptContext promptContext) { newPromptMessages.addAll(nonSystemMessages); Prompt newPrompt = new Prompt(newPromptMessages, (ChatOptions) originalPrompt.getOptions()); - - return PromptContext.from(promptContext).withPrompt(newPrompt).addPromptHistory(originalPrompt).build(); + PromptChange promptChange = new PromptChange(originalPrompt, newPrompt, this.getName(), + "Added chat memory into the system prompt"); + return ChatServiceContext.from(chatServiceContext).withPrompt(newPrompt).withPromptChange(promptChange).build(); } } \ No newline at end of file diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/history/VectorStoreChatMemoryChatBotListener.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/memory/VectorStoreChatMemoryChatServiceListener.java similarity index 68% rename from spring-ai-core/src/main/java/org/springframework/ai/chat/history/VectorStoreChatMemoryChatBotListener.java rename to spring-ai-core/src/main/java/org/springframework/ai/chat/memory/VectorStoreChatMemoryChatServiceListener.java index cf42bfb6e76..0f5aa01009f 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/history/VectorStoreChatMemoryChatBotListener.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/memory/VectorStoreChatMemoryChatServiceListener.java @@ -14,18 +14,18 @@ * limitations under the License. */ -package org.springframework.ai.chat.history; +package org.springframework.ai.chat.memory; import java.util.HashMap; import java.util.List; import java.util.Map; -import org.springframework.ai.chat.chatbot.ChatBotListener; -import org.springframework.ai.chat.chatbot.ChatBotResponse; +import org.springframework.ai.chat.service.ChatServiceListener; +import org.springframework.ai.chat.service.ChatServiceResponse; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.MessageType; import org.springframework.ai.chat.prompt.transformer.TransformerContentType; -import org.springframework.ai.chat.prompt.transformer.PromptContext; +import org.springframework.ai.chat.prompt.transformer.ChatServiceContext; import org.springframework.ai.document.Document; import org.springframework.ai.vectorstore.VectorStore; import org.springframework.util.CollectionUtils; @@ -33,43 +33,43 @@ /** * @author Christian Tzolov */ -public class VectorStoreChatMemoryChatBotListener implements ChatBotListener { +public class VectorStoreChatMemoryChatServiceListener implements ChatServiceListener { private final VectorStore vectorStore; private final Map additionalMetadata; - public VectorStoreChatMemoryChatBotListener(VectorStore vectorStore) { + public VectorStoreChatMemoryChatServiceListener(VectorStore vectorStore) { this(vectorStore, new HashMap<>()); } - public VectorStoreChatMemoryChatBotListener(VectorStore vectorStore, Map additionalMetadata) { + public VectorStoreChatMemoryChatServiceListener(VectorStore vectorStore, Map additionalMetadata) { this.vectorStore = vectorStore; this.additionalMetadata = additionalMetadata; } @Override - public void onStart(PromptContext promptContext) { + public void onStart(ChatServiceContext chatServiceContext) { - if (!CollectionUtils.isEmpty(promptContext.getPrompt().getInstructions())) { - List docs = toDocuments(promptContext.getPrompt().getInstructions(), - promptContext.getConversationId()); + if (!CollectionUtils.isEmpty(chatServiceContext.getPrompt().getInstructions())) { + List docs = toDocuments(chatServiceContext.getPrompt().getInstructions(), + chatServiceContext.getConversationId()); this.vectorStore.add(docs); } } @Override - public void onComplete(ChatBotResponse chatBotResponse) { - if (!CollectionUtils.isEmpty(chatBotResponse.getChatResponse().getResults())) { - List assistantMessages = chatBotResponse.getChatResponse() + public void onComplete(ChatServiceResponse chatServiceResponse) { + if (!CollectionUtils.isEmpty(chatServiceResponse.getChatResponse().getResults())) { + List assistantMessages = chatServiceResponse.getChatResponse() .getResults() .stream() .map(g -> (org.springframework.ai.chat.messages.Message) g.getOutput()) .toList(); List docs = toDocuments(assistantMessages, - chatBotResponse.getPromptContext().getConversationId()); + chatServiceResponse.getPromptContext().getConversationId()); this.vectorStore.add(docs); } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/history/VectorStoreChatMemoryRetriever.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/memory/VectorStoreChatMemoryRetriever.java similarity index 77% rename from spring-ai-core/src/main/java/org/springframework/ai/chat/history/VectorStoreChatMemoryRetriever.java rename to spring-ai-core/src/main/java/org/springframework/ai/chat/memory/VectorStoreChatMemoryRetriever.java index aff050179e9..7b476e7049e 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/history/VectorStoreChatMemoryRetriever.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/memory/VectorStoreChatMemoryRetriever.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.chat.history; +package org.springframework.ai.chat.memory; import java.util.ArrayList; import java.util.List; @@ -22,9 +22,9 @@ import java.util.stream.Collectors; import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.chat.prompt.transformer.AbstractPromptTransformer; +import org.springframework.ai.chat.prompt.transformer.ChatServiceContext; import org.springframework.ai.chat.prompt.transformer.TransformerContentType; -import org.springframework.ai.chat.prompt.transformer.PromptContext; -import org.springframework.ai.chat.prompt.transformer.PromptTransformer; import org.springframework.ai.document.Document; import org.springframework.ai.model.Content; import org.springframework.ai.vectorstore.SearchRequest; @@ -34,7 +34,7 @@ /** * @author Christian Tzolov */ -public class VectorStoreChatMemoryRetriever implements PromptTransformer { +public class VectorStoreChatMemoryRetriever extends AbstractPromptTransformer { private final VectorStore vectorStore; @@ -56,11 +56,11 @@ public VectorStoreChatMemoryRetriever(VectorStore vectorStore, int topK, Map updatedContents = new ArrayList<>( - promptContext.getContents() != null ? promptContext.getContents() : List.of()); + chatServiceContext.getContents() != null ? chatServiceContext.getContents() : List.of()); - String query = promptContext.getPrompt() + String query = chatServiceContext.getPrompt() .getInstructions() .stream() .filter(m -> m.getMessageType() == MessageType.USER) @@ -70,7 +70,7 @@ public PromptContext transform(PromptContext promptContext) { var searchRequest = SearchRequest.query(query) .withTopK(this.topK) .withFilterExpression( - TransformerContentType.CONVERSATION_ID + "=='" + promptContext.getConversationId() + "'"); + TransformerContentType.CONVERSATION_ID + "=='" + chatServiceContext.getConversationId() + "'"); List documents = this.vectorStore.similaritySearch(searchRequest); @@ -82,7 +82,7 @@ public PromptContext transform(PromptContext promptContext) { updatedContents.addAll(documents); } - return PromptContext.from(promptContext).withContents(updatedContents).build(); + return ChatServiceContext.from(chatServiceContext).withContents(updatedContents).build(); } } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/AbstractPromptTransformer.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/AbstractPromptTransformer.java new file mode 100644 index 00000000000..0636c95437c --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/AbstractPromptTransformer.java @@ -0,0 +1,39 @@ +/* + * 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.chat.prompt.transformer; + +/** + * AbstractPromptTransformer is an abstract class that provides a base implementation of + * the PromptTransformer interface. It includes a name field and corresponding accessor + * methods, as well as a default implementation for the transform method. + * + * @author Mark Pollack + * @author Christian Tzolov + * @since 1.0.0 M1 + */ +public abstract class AbstractPromptTransformer implements PromptTransformer { + + private String name = getClass().getSimpleName(); + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/ChatServiceContext.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/ChatServiceContext.java new file mode 100644 index 00000000000..23191f8f337 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/ChatServiceContext.java @@ -0,0 +1,195 @@ +/* + * 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.chat.prompt.transformer; + +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.Content; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Represents the execution context for the {@link ChatService}. This context is used to + * pass initial parameters to the service and facilitate data sharing between different + * components within the service. + * + *

+ * The {@code ChatServiceContext} includes essential information such as the initial + * prompt and a conversation ID, which are crucial for the correct operation of the chat + * service. + *

+ * + * @author Mark Pollack + * @author Christian Tzolov + * @since 1.0.0 M1 + */ +public class ChatServiceContext { + + private Prompt prompt; // The most up-to-date prompt to use + + private List contents; // The most up-to-date data to use + + private List promptChanges; // The changes make due to transformations + + private String conversationId; + + /** + * Contextual data that can be shared between processing steps in a ChatService + * implementation. + */ + private Map context = new ConcurrentHashMap<>(); + + public ChatServiceContext(Prompt prompt) { + this(prompt, "default", new ArrayList<>()); + } + + public ChatServiceContext(Prompt prompt, String conversationId) { + this(prompt, conversationId, new ArrayList<>()); + } + + public ChatServiceContext(Prompt prompt, String conversationId, List contents) { + this.prompt = prompt; + this.conversationId = conversationId; + this.promptChanges = new ArrayList<>(); + this.promptChanges.add(new PromptChange(null, prompt, "none", "initial prompt")); + this.contents = contents; + } + + public Prompt getPrompt() { + return this.prompt; + } + + public void updatePrompt(Prompt prompt, String transformerName, String description) { + this.promptChanges.add(new PromptChange(this.prompt, prompt, transformerName, description)); + this.prompt = prompt; // set the new prompt as current + } + + public void addData(Content datum) { + this.contents.add(datum); + } + + public List getContents() { + return this.contents; + } + + public void setContents(List contents) { + this.contents = contents; + } + + public List getPromptChanges() { + return this.promptChanges; + } + + public String getConversationId() { + return this.conversationId; + } + + public Map getContext() { + return this.context; + } + + public static Builder from(ChatServiceContext chatServiceContext) { + return ChatServiceContext.builder() + .withContents(new ArrayList<>( + chatServiceContext.getContents() != null ? chatServiceContext.getContents() : List.of())) + .withPrompt(chatServiceContext.getPrompt().copy()) // deep copy + .withMetadata( + new HashMap<>(chatServiceContext.getContext() != null ? chatServiceContext.getContext() : Map.of())) + .withPromptChanges(new ArrayList<>( + chatServiceContext.getPromptChanges() != null ? chatServiceContext.getPromptChanges() : List.of())) + .withConversationId(chatServiceContext.getConversationId()); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private Prompt prompt; + + private List contents; + + private List promptChanges; + + private String conversationId; + + private Map context = new HashMap<>(); + + public Builder withPrompt(Prompt prompt) { + this.prompt = prompt; + return this; + } + + public Builder withContents(List contents) { + this.contents = new ArrayList<>(contents); + return this; + } + + public Builder withPromptChanges(List promptChanges) { + this.promptChanges = new ArrayList<>(promptChanges); + return this; + } + + public Builder withPromptChange(PromptChange promptChange) { + this.promptChanges.add(promptChange); + return this; + } + + public Builder withConversationId(String conversationId) { + this.conversationId = conversationId; + return this; + } + + public Builder withMetadata(Map context) { + this.context = new HashMap<>(context); + return this; + } + + public ChatServiceContext build() { + ChatServiceContext chatServiceContext = new ChatServiceContext(this.prompt, this.conversationId, + this.contents); + chatServiceContext.promptChanges = promptChanges; + chatServiceContext.context = context; + return chatServiceContext; + } + + } + + @Override + public String toString() { + return "ChatServiceContext{" + "prompt=" + prompt + ", contents=" + contents + ", promptHistory=" + + promptChanges + ", conversationId='" + conversationId + '\'' + ", metadata=" + context + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (!(o instanceof ChatServiceContext that)) + return false; + return Objects.equals(prompt, that.prompt) && Objects.equals(contents, that.contents) + && Objects.equals(promptChanges, that.promptChanges) + && Objects.equals(conversationId, that.conversationId) && Objects.equals(context, that.context); + } + + @Override + public int hashCode() { + return Objects.hash(prompt, contents, promptChanges, conversationId, context); + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/PromptChange.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/PromptChange.java new file mode 100644 index 00000000000..b8e8e1e7048 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/PromptChange.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.chat.prompt.transformer; + +import org.springframework.ai.chat.prompt.Prompt; + +/** + * The PromptChange class represents a change made to a Prompt object. It contains + * information about the original prompt, the revised prompt, the name of the transformer + * that made the change, and a description of the change. + * + * @author Mark Pollack + * @author Christian Tzolov + * @since 1.0.0 M1 + */ +public record PromptChange(Prompt original, Prompt revised, String transformerName, String description) { + +} \ No newline at end of file diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/PromptContext.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/PromptContext.java deleted file mode 100644 index 7cfce9f89a6..00000000000 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/PromptContext.java +++ /dev/null @@ -1,189 +0,0 @@ -/* - * 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.chat.prompt.transformer; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.model.Content; - -/** - * The shared, at the moment, mutable, data structure that can be used to implement - * ChatBot functionality. - * - * @author Mark Pollack - * @author Christian Tzolov - * @since 1.0.0 - */ -public class PromptContext { - - private Prompt prompt; // The most up-to-date prompt to use - - private List contents; // The most up-to-date data to use - - private List promptHistory; - - private String conversationId = "default"; - - private Map metadata = new HashMap<>(); - - public PromptContext(Prompt prompt) { - this(prompt, new ArrayList<>()); - } - - public PromptContext(Prompt prompt, String conversationId) { - this(prompt, new ArrayList<>()); - this.conversationId = conversationId; - } - - public PromptContext(Prompt prompt, List contents) { - this.prompt = prompt; - this.promptHistory = new ArrayList<>(); - this.promptHistory.add(prompt); - this.contents = contents; - } - - public Prompt getPrompt() { - return prompt; - } - - public void setPrompt(Prompt prompt) { - this.prompt = prompt; - } - - public void addData(Content datum) { - this.contents.add(datum); - } - - public List getContents() { - return contents; - } - - public void setContents(List contents) { - this.contents = contents; - } - - public void addPromptHistory(Prompt prompt) { - this.promptHistory.add(prompt); - } - - public List getPromptHistory() { - return promptHistory; - } - - public String getConversationId() { - return conversationId; - } - - public Map getMetadata() { - return metadata; - } - - public static Builder from(PromptContext promptContext) { - return PromptContext.builder() - .withContents( - new ArrayList<>(promptContext.getContents() != null ? promptContext.getContents() : List.of())) - .withPrompt(promptContext.getPrompt().copy()) // deep copy - .withMetadata(new HashMap<>(promptContext.getMetadata() != null ? promptContext.getMetadata() : Map.of())) - .withPromptHistory(new ArrayList<>( - promptContext.getPromptHistory() != null ? promptContext.getPromptHistory() : List.of())) - .withConversationId(promptContext.getConversationId()); - } - - public static Builder builder() { - return new Builder(); - } - - public static class Builder { - - private Prompt prompt; - - private List contents; - - private List promptHistory; - - private String conversationId; - - private Map metadata = new HashMap<>(); - - public Builder withPrompt(Prompt prompt) { - this.prompt = prompt; - return this; - } - - public Builder withContents(List contents) { - this.contents = new ArrayList<>(contents); - return this; - } - - public Builder withPromptHistory(List promptHistory) { - this.promptHistory = new ArrayList<>(promptHistory); - return this; - } - - public Builder addPromptHistory(Prompt prompt) { - this.promptHistory.add(prompt); - return this; - } - - public Builder withConversationId(String conversationId) { - this.conversationId = conversationId; - return this; - } - - public Builder withMetadata(Map metadata) { - this.metadata = new HashMap<>(metadata); - return this; - } - - public PromptContext build() { - PromptContext promptContext = new PromptContext(prompt, contents); - promptContext.promptHistory = promptHistory; - promptContext.conversationId = conversationId; - promptContext.metadata = metadata; - return promptContext; - } - - } - - @Override - public String toString() { - return "PromptContext{" + "prompt=" + prompt + ", contents=" + contents + ", promptHistory=" + promptHistory - + ", conversationId='" + conversationId + '\'' + ", metadata=" + metadata + '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) - return true; - if (!(o instanceof PromptContext that)) - return false; - return Objects.equals(prompt, that.prompt) && Objects.equals(contents, that.contents) - && Objects.equals(promptHistory, that.promptHistory) - && Objects.equals(conversationId, that.conversationId) && Objects.equals(metadata, that.metadata); - } - - @Override - public int hashCode() { - return Objects.hash(prompt, contents, promptHistory, conversationId, metadata); - } - -} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/PromptTransformer.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/PromptTransformer.java index 7d596a0e9e0..67c74c5a3b2 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/PromptTransformer.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/PromptTransformer.java @@ -17,23 +17,24 @@ package org.springframework.ai.chat.prompt.transformer; /** - * Responsible for transforming a Prompt. The PromptContext contains the necessary data to - * make the transformation + * Responsible for transforming a Prompt. The ChatServiceContext contains the necessary + * data to make the transformation * - * Implementations may retrieve data and modify the Prompt object in the PromptContext as - * needed. + * Implementations may retrieve data and modify the Prompt object in the + * ChatServiceContext as needed. * * @author Mark Pollack - * @since 1.0 M1 + * @author Christian Tzolov + * @since 1.0.0 M1 */ @FunctionalInterface public interface PromptTransformer { /** - * Transforms the given PromptContext. - * @param context the PromptContext to transform - * @return the transformed PromptContext + * Transforms the given ChatServiceContext. + * @param context the ChatServiceContext to transform + * @return the transformed ChatServiceContext */ - PromptContext transform(PromptContext context); + ChatServiceContext transform(ChatServiceContext context); } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/QuestionContextAugmentor.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/QuestionContextAugmentor.java index 12e4aada2aa..05d79438e4d 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/QuestionContextAugmentor.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/QuestionContextAugmentor.java @@ -32,11 +32,15 @@ * additional context to create a new prompt. The default user text contains the * placeholder names "question" and "context". The "question" placeholder is filled using * the value of the current UserMessage and the "context" placeholder is filled with - * Documents contained in the PromptContext's Nodes. + * Documents contained in the ChatServiceContext's Nodes. + * + * @author Mark Pollack + * @author Christian Tzolov + * @since 1.0.0 M1 */ -public class QuestionContextAugmentor implements PromptTransformer { +public class QuestionContextAugmentor extends AbstractPromptTransformer { - private static final String DEFAULT_USER_PROMPT_TEXT = """ + private static final String DEFAULT_USER_TEXT = """ "Context information is below.\\n" "---------------------\\n" "{context}\\n" @@ -48,16 +52,26 @@ public class QuestionContextAugmentor implements PromptTransformer { "Answer: " """; + private String userText; + + public QuestionContextAugmentor() { + this.userText = DEFAULT_USER_TEXT; + this.setName("QuestionContextAugmentor"); + } + + public String getUserText() { + return userText; + } + @Override - public PromptContext transform(PromptContext promptContext) { - String context = doCreateContext(promptContext.getContents()); - Map contextMap = doCreateContextMap(promptContext.getPrompt(), context); - Prompt prompt = doCreatePrompt(promptContext.getPrompt(), contextMap); - promptContext.setPrompt(prompt); - promptContext.addPromptHistory(prompt); // BUG? shouldn't this be original - // promptContext.getPrompt()? + public ChatServiceContext transform(ChatServiceContext chatServiceContext) { + String context = doCreateContext(chatServiceContext.getContents()); + Map contextMap = doCreateContextMap(chatServiceContext.getPrompt(), context); + Prompt prompt = doCreatePrompt(chatServiceContext.getPrompt(), contextMap); + chatServiceContext.updatePrompt(prompt, this.getName(), "Updated prompt with Q/A user text"); + // For now return the modified instance instead of a copy - return promptContext; + return chatServiceContext; } protected String doCreateContext(List data) { @@ -75,7 +89,7 @@ private Map doCreateContextMap(Prompt prompt, String context) { } protected Prompt doCreatePrompt(Prompt originalPrompt, Map contextMap) { - PromptTemplate promptTemplate = new PromptTemplate(DEFAULT_USER_PROMPT_TEXT); + PromptTemplate promptTemplate = new PromptTemplate(getUserText()); Message userMessageToAppend = promptTemplate.createMessage(contextMap); List messageList = originalPrompt.getInstructions() .stream() @@ -85,4 +99,33 @@ protected Prompt doCreatePrompt(Prompt originalPrompt, Map conte return new Prompt(messageList, (ChatOptions) originalPrompt.getOptions()); } + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String name; + + private String userText; + + public Builder withName(String name) { + this.name = name; + return this; + } + + public Builder withUserText(String userText) { + this.userText = userText; + return this; + } + + public QuestionContextAugmentor build() { + QuestionContextAugmentor instance = new QuestionContextAugmentor(); + instance.userText = this.userText != null ? this.userText : instance.userText; + instance.setName(this.name != null ? this.name : instance.getName()); + return instance; + } + + } + } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/TransformerContentType.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/TransformerContentType.java index 270e8594350..b4839ecf9ec 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/TransformerContentType.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/TransformerContentType.java @@ -17,7 +17,10 @@ package org.springframework.ai.chat.prompt.transformer; /** + * This class provides constants for different content types used by transformers. + * * @author Christian Tzolov + * @since 1.0.0 M1 */ public class TransformerContentType { diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/VectorStoreRetriever.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/VectorStoreRetriever.java index 8a9e58b1cca..30851f644a5 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/VectorStoreRetriever.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/VectorStoreRetriever.java @@ -23,16 +23,29 @@ import org.springframework.ai.document.Document; import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.VectorStore; -import org.springframework.ai.vectorstore.filter.Filter; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; /** - * Transforms the PromptContext by retrieving documents from a VectorStore + * A transformer class that retrieves documents from a {@link VectorStore} + * + *

+ * The {@code VectorStoreRetriever} leverages a {@link SearchRequest} to query the + * {@link VectorStore} and retrieve documents that are semantically similar to the user's + * input. These documents are then added to the {@link ChatServiceContext} for further + * processing. + *

+ * + * @see VectorStore + * @see SearchRequest + * @see ChatServiceContext + * @author Mark Pollack + * @author Christian Tzolov + * @since 1.0.0 M1 */ -public class VectorStoreRetriever implements PromptTransformer { +public class VectorStoreRetriever extends AbstractPromptTransformer { private final Logger logger = LoggerFactory.getLogger(getClass()); @@ -41,8 +54,13 @@ public class VectorStoreRetriever implements PromptTransformer { private final SearchRequest searchRequest; public VectorStoreRetriever(VectorStore vectorStore, SearchRequest searchRequest) { + this(vectorStore, searchRequest, "VectorStoreRetriever"); + } + + public VectorStoreRetriever(VectorStore vectorStore, SearchRequest searchRequest, String name) { this.vectorStore = vectorStore; this.searchRequest = searchRequest; + this.setName(name); } public VectorStore getVectorStore() { @@ -54,8 +72,8 @@ public SearchRequest getSearchRequest() { } @Override - public PromptContext transform(PromptContext promptContext) { - List instructions = promptContext.getPrompt().getInstructions(); + public ChatServiceContext transform(ChatServiceContext chatServiceContext) { + List instructions = chatServiceContext.getPrompt().getInstructions(); String userMessage = instructions.stream() .filter(m -> m.getMessageType() == MessageType.USER) .map(m -> m.getContent()) @@ -67,9 +85,9 @@ public PromptContext transform(PromptContext promptContext) { for (Document document : documents) { var content = new Document(document.getContent(), document.getMetadata()); // content.getMetadata().put(TransformerContentType.DOMAIN_DATA, true); - promptContext.addData(content); + chatServiceContext.addData(content); } - return promptContext; + return chatServiceContext; } @Override diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/ChatBot.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/service/ChatService.java similarity index 54% rename from spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/ChatBot.java rename to spring-ai-core/src/main/java/org/springframework/ai/chat/service/ChatService.java index ff4eeb51085..647ad9c3c07 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/ChatBot.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/service/ChatService.java @@ -14,27 +14,26 @@ * limitations under the License. */ -package org.springframework.ai.chat.chatbot; +package org.springframework.ai.chat.service; -import org.springframework.ai.chat.prompt.transformer.PromptContext; +import org.springframework.ai.chat.prompt.transformer.ChatServiceContext; /** - * A ChatBot encapsulates the logic to perform common AI use cases such as Retrieval - * Augmented Generation. + * A ChatService encapsulates the logic to implement AI use cases. * * @author Mark Pollack * @since 1.0 M1 */ -public interface ChatBot { +public interface ChatService { /** - * Call the chatbot to execute AI actions - * @param promptContext A shared data structure used by the ChatBot to perform - * processing of the Prompt. It includes the intial Prompt and a conversation ID at + * Call the service to execute AI actions + * @param chatServiceContext A data structure used by the ChatService to perform + * processing of the Prompt. It includes the initial Prompt and a conversation ID at * the start of execution. - * @return the ChatBotResponse that contains the ChatResponse and the latest - * PromptContext + * @return the ChatServiceResponse that contains the ChatResponse and the latest + * ChatServiceContext */ - ChatBotResponse call(PromptContext promptContext); + ChatServiceResponse call(ChatServiceContext chatServiceContext); } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/ChatBotListener.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/service/ChatServiceListener.java similarity index 61% rename from spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/ChatBotListener.java rename to spring-ai-core/src/main/java/org/springframework/ai/chat/service/ChatServiceListener.java index 7e591b46db6..8e8596cc00e 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/ChatBotListener.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/service/ChatServiceListener.java @@ -14,23 +14,23 @@ * limitations under the License. */ -package org.springframework.ai.chat.chatbot; +package org.springframework.ai.chat.service; -import org.springframework.ai.chat.prompt.transformer.PromptContext; +import org.springframework.ai.chat.prompt.transformer.ChatServiceContext; /** - * The ChatBotListener is a callback interface that can be implemented by classes that - * want to be notified of the completion of a ChatBot execution. + * The ChatServiceListener is a callback interface that can be implemented by classes that + * want to be notified of the completion of a ChatService execution. * * @author Mark Pollack * @author Christian Tzolov */ -public interface ChatBotListener { +public interface ChatServiceListener { - default void onStart(PromptContext promptContext) { + default void onStart(ChatServiceContext chatServiceContext) { } - void onComplete(ChatBotResponse chatBotResponse); + void onComplete(ChatServiceResponse chatServiceResponse); } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/ChatBotResponse.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/service/ChatServiceResponse.java similarity index 54% rename from spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/ChatBotResponse.java rename to spring-ai-core/src/main/java/org/springframework/ai/chat/service/ChatServiceResponse.java index 344d8d08b04..ecd46969d13 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/ChatBotResponse.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/service/ChatServiceResponse.java @@ -14,33 +14,33 @@ * limitations under the License. */ -package org.springframework.ai.chat.chatbot; +package org.springframework.ai.chat.service; import org.springframework.ai.chat.ChatResponse; -import org.springframework.ai.chat.prompt.transformer.PromptContext; +import org.springframework.ai.chat.prompt.transformer.ChatServiceContext; import java.util.Objects; /** - * Encapsulates the response from the ChatBot. Contains the most up-to-date PromptContext - * and the final ChatResponse + * Encapsulates the response from the ChatService. Contains the most up-to-date + * ChatServiceContext and the final ChatResponse * * @author Mark Pollack * @since 1.0 M1 */ -public class ChatBotResponse { +public class ChatServiceResponse { - private final PromptContext promptContext; + private final ChatServiceContext chatServiceContext; private final ChatResponse chatResponse; - public ChatBotResponse(PromptContext promptContext, ChatResponse chatResponse) { - this.promptContext = promptContext; + public ChatServiceResponse(ChatServiceContext chatServiceContext, ChatResponse chatResponse) { + this.chatServiceContext = chatServiceContext; this.chatResponse = chatResponse; } - public PromptContext getPromptContext() { - return promptContext; + public ChatServiceContext getPromptContext() { + return chatServiceContext; } public ChatResponse getChatResponse() { @@ -49,21 +49,23 @@ public ChatResponse getChatResponse() { @Override public String toString() { - return "ChatBotResponse{" + "promptContext=" + promptContext + ", chatResponse=" + chatResponse + '}'; + return "ChatServiceResponse{" + "chatServiceContext=" + chatServiceContext + ", chatResponse=" + chatResponse + + '}'; } @Override public boolean equals(Object o) { if (this == o) return true; - if (!(o instanceof ChatBotResponse that)) + if (!(o instanceof ChatServiceResponse that)) return false; - return Objects.equals(promptContext, that.promptContext) && Objects.equals(chatResponse, that.chatResponse); + return Objects.equals(chatServiceContext, that.chatServiceContext) + && Objects.equals(chatResponse, that.chatResponse); } @Override public int hashCode() { - return Objects.hash(promptContext, chatResponse); + return Objects.hash(chatServiceContext, chatResponse); } } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/DefaultChatBot.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/service/PromptTransformingChatService.java similarity index 50% rename from spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/DefaultChatBot.java rename to spring-ai-core/src/main/java/org/springframework/ai/chat/service/PromptTransformingChatService.java index ebbac06caf8..761ae5f429e 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/DefaultChatBot.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/service/PromptTransformingChatService.java @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.ai.chat.chatbot; +package org.springframework.ai.chat.service; import org.springframework.ai.chat.ChatClient; import org.springframework.ai.chat.ChatResponse; -import org.springframework.ai.chat.prompt.transformer.PromptContext; +import org.springframework.ai.chat.prompt.transformer.ChatServiceContext; import org.springframework.ai.chat.prompt.transformer.PromptTransformer; import java.util.ArrayList; @@ -25,10 +25,15 @@ import java.util.Objects; /** + * A PromptTransformingChatService implements the ChatService interface and performs + * transformation of the prompt using a series of PromptTransformers. It also provides a + * builder class for easier construction of the PromptTransformingChatService instance. + * * @author Mark Pollack * @author Christian Tzolov + * @since 1.0 M1 */ -public class DefaultChatBot implements ChatBot { +public class PromptTransformingChatService implements ChatService { private ChatClient chatClient; @@ -38,60 +43,60 @@ public class DefaultChatBot implements ChatBot { private List augmentors; - private List chatBotListeners; + private List chatServiceListeners; - public DefaultChatBot(ChatClient chatClient, List retrievers, + public PromptTransformingChatService(ChatClient chatClient, List retrievers, List documentPostProcessors, List augmentors, - List chatBotListeners) { + List chatServiceListeners) { Objects.requireNonNull(chatClient, "chatClient must not be null"); this.chatClient = chatClient; this.retrievers = retrievers; this.documentPostProcessors = documentPostProcessors; this.augmentors = augmentors; - this.chatBotListeners = chatBotListeners; + this.chatServiceListeners = chatServiceListeners; } - public static DefaultChatBotBuilder builder(ChatClient chatClient) { - return new DefaultChatBotBuilder().withChatClient(chatClient); + public static Builder builder(ChatClient chatClient) { + return new Builder().withChatClient(chatClient); } @Override - public ChatBotResponse call(PromptContext promptContext) { + public ChatServiceResponse call(ChatServiceContext chatServiceContext) { - PromptContext promptContextOnStart = PromptContext.from(promptContext).build(); + ChatServiceContext chatServiceContextOnStart = ChatServiceContext.from(chatServiceContext).build(); // Perform retrieval of documents and messages for (PromptTransformer retriever : this.retrievers) { - promptContext = retriever.transform(promptContext); + chatServiceContext = retriever.transform(chatServiceContext); } // Perform post processing of all retrieved documents and messages for (PromptTransformer documentPostProcessor : this.documentPostProcessors) { - promptContext = documentPostProcessor.transform(promptContext); + chatServiceContext = documentPostProcessor.transform(chatServiceContext); } // Perform prompt augmentation for (PromptTransformer augmentor : this.augmentors) { - promptContext = augmentor.transform(promptContext); + chatServiceContext = augmentor.transform(chatServiceContext); } // Invoke Listeners onStart - for (ChatBotListener listener : this.chatBotListeners) { - listener.onStart(promptContextOnStart); + for (ChatServiceListener listener : this.chatServiceListeners) { + listener.onStart(chatServiceContextOnStart); } // Perform generation - ChatResponse chatResponse = this.chatClient.call(promptContext.getPrompt()); + ChatResponse chatResponse = this.chatClient.call(chatServiceContext.getPrompt()); // Invoke Listeners onComplete - ChatBotResponse chatBotResponse = new ChatBotResponse(promptContext, chatResponse); - for (ChatBotListener listener : this.chatBotListeners) { - listener.onComplete(chatBotResponse); + ChatServiceResponse chatServiceResponse = new ChatServiceResponse(chatServiceContext, chatResponse); + for (ChatServiceListener listener : this.chatServiceListeners) { + listener.onComplete(chatServiceResponse); } - return chatBotResponse; + return chatServiceResponse; } - public static class DefaultChatBotBuilder { + public static class Builder { private ChatClient chatClient; @@ -101,35 +106,36 @@ public static class DefaultChatBotBuilder { private List augmentors = new ArrayList<>(); - private List chatBotListeners = new ArrayList<>(); + private List chatServiceListeners = new ArrayList<>(); - public DefaultChatBotBuilder withChatClient(ChatClient chatClient) { + public Builder withChatClient(ChatClient chatClient) { this.chatClient = chatClient; return this; } - public DefaultChatBotBuilder withRetrievers(List retrievers) { + public Builder withRetrievers(List retrievers) { this.retrievers = retrievers; return this; } - public DefaultChatBotBuilder withContentPostProcessors(List documentPostProcessors) { + public Builder withContentPostProcessors(List documentPostProcessors) { this.documentPostProcessors = documentPostProcessors; return this; } - public DefaultChatBotBuilder withAugmentors(List augmentors) { + public Builder withAugmentors(List augmentors) { this.augmentors = augmentors; return this; } - public DefaultChatBotBuilder withChatBotListeners(List chatBotListeners) { - this.chatBotListeners = chatBotListeners; + public Builder withChatServiceListeners(List chatServiceListeners) { + this.chatServiceListeners = chatServiceListeners; return this; } - public DefaultChatBot build() { - return new DefaultChatBot(chatClient, retrievers, documentPostProcessors, augmentors, chatBotListeners); + public PromptTransformingChatService build() { + return new PromptTransformingChatService(chatClient, retrievers, documentPostProcessors, augmentors, + chatServiceListeners); } } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/StreamingChatBot.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/service/StreamingChatService.java similarity index 51% rename from spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/StreamingChatBot.java rename to spring-ai-core/src/main/java/org/springframework/ai/chat/service/StreamingChatService.java index d5d4843b154..7abb8e63e47 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/StreamingChatBot.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/service/StreamingChatService.java @@ -13,28 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.ai.chat.chatbot; +package org.springframework.ai.chat.service; -import org.springframework.ai.chat.prompt.transformer.PromptContext; +import org.springframework.ai.chat.prompt.transformer.ChatServiceContext; /** - * A ChatBot encapsulates the logic to perform common AI use cases such as Retrieval - * Augmented Generation. + * A ChatService encapsulates the logic to implement AI use cases. * * @author Mark Pollack * @author Christian Tzolov * @since 1.0 M1 */ -public interface StreamingChatBot { +public interface StreamingChatService { /** - * Call the chatbot to execute AI actions - * @param promptContext A shared data structure used by the ChatBot to perform - * processing of the Prompt. It includes the intial Prompt and a conversation ID at - * the start of execution. - * @return the StreamingChatBotResponse that contains the ChatResponse and the latest - * PromptContext + * Call the service to execute AI actions + * @param chatServiceContext A shared data structure used by the ChatService to + * perform processing of the Prompt. It includes the intial Prompt and a conversation + * ID at the start of execution. + * @return the StreamingChatServiceResponse that contains the ChatResponse and the + * latest ChatServiceContext */ - StreamingChatBotResponse stream(PromptContext promptContext); + StreamingChatServiceResponse stream(ChatServiceContext chatServiceContext); } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/service/StreamingChatServiceResponse.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/service/StreamingChatServiceResponse.java new file mode 100644 index 00000000000..5b97e127e7c --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/service/StreamingChatServiceResponse.java @@ -0,0 +1,73 @@ +package org.springframework.ai.chat.service; + +import org.springframework.ai.chat.prompt.transformer.ChatServiceContext; +import reactor.core.publisher.Flux; + +import org.springframework.ai.chat.ChatResponse; + +/** + * Encapsulates the response from the ChatService. Contains the most up-to-date + * ChatServiceContext and the final ChatResponse + * + * @author Mark Pollack + * @since 1.0 M1 + */ +public class StreamingChatServiceResponse { + + private final ChatServiceContext chatServiceContext; + + private final Flux chatResponse; + + public StreamingChatServiceResponse(ChatServiceContext chatServiceContext, Flux chatResponse) { + this.chatServiceContext = chatServiceContext; + this.chatResponse = chatResponse; + } + + public ChatServiceContext getPromptContext() { + return chatServiceContext; + } + + public Flux getChatResponse() { + return chatResponse; + } + + @Override + public String toString() { + return "ChatServiceResponse{" + "chatServiceContext=" + chatServiceContext + ", chatResponse=" + chatResponse + + '}'; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((chatServiceContext == null) ? 0 : chatServiceContext.hashCode()); + result = prime * result + ((chatResponse == null) ? 0 : chatResponse.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + StreamingChatServiceResponse other = (StreamingChatServiceResponse) obj; + if (chatServiceContext == null) { + if (other.chatServiceContext != null) + return false; + } + else if (!chatServiceContext.equals(other.chatServiceContext)) + return false; + if (chatResponse == null) { + if (other.chatResponse != null) + return false; + } + else if (!chatResponse.equals(other.chatResponse)) + return false; + return true; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/DefaultStreamingChatBot.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/service/StreamingPromptTransformingChatService.java similarity index 54% rename from spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/DefaultStreamingChatBot.java rename to spring-ai-core/src/main/java/org/springframework/ai/chat/service/StreamingPromptTransformingChatService.java index bdc79d0b8e2..c9424153105 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/chatbot/DefaultStreamingChatBot.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/service/StreamingPromptTransformingChatService.java @@ -13,25 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.ai.chat.chatbot; +package org.springframework.ai.chat.service; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import org.springframework.ai.chat.prompt.transformer.ChatServiceContext; import reactor.core.publisher.Flux; import org.springframework.ai.chat.ChatResponse; import org.springframework.ai.chat.StreamingChatClient; import org.springframework.ai.chat.messages.MessageAggregator; -import org.springframework.ai.chat.prompt.transformer.PromptContext; import org.springframework.ai.chat.prompt.transformer.PromptTransformer; /** * @author Mark Pollack * @author Christian Tzolov */ -public class DefaultStreamingChatBot implements StreamingChatBot { +public class StreamingPromptTransformingChatService implements StreamingChatService { private StreamingChatClient streamingChatClient; @@ -41,63 +41,63 @@ public class DefaultStreamingChatBot implements StreamingChatBot { private List augmentors; - private List chatBotListeners; + private List chatServiceListeners; - public DefaultStreamingChatBot(StreamingChatClient chatClient, List retrievers, + public StreamingPromptTransformingChatService(StreamingChatClient chatClient, List retrievers, List documentPostProcessors, List augmentors, - List chatBotListeners) { + List chatServiceListeners) { Objects.requireNonNull(chatClient, "chatClient must not be null"); this.streamingChatClient = chatClient; this.retrievers = retrievers; this.documentPostProcessors = documentPostProcessors; this.augmentors = augmentors; - this.chatBotListeners = chatBotListeners; + this.chatServiceListeners = chatServiceListeners; } - public static DefaultChatBotBuilder builder(StreamingChatClient chatClient) { - return new DefaultChatBotBuilder().withChatClient(chatClient); + public static Builder builder(StreamingChatClient chatClient) { + return new Builder().withChatClient(chatClient); } @Override - public StreamingChatBotResponse stream(PromptContext promptContext) { + public StreamingChatServiceResponse stream(ChatServiceContext chatServiceContext) { - PromptContext promptContextOnStart = PromptContext.from(promptContext).build(); + ChatServiceContext chatServiceContextOnStart = ChatServiceContext.from(chatServiceContext).build(); // Perform retrieval of documents and messages for (PromptTransformer retriever : this.retrievers) { - promptContext = retriever.transform(promptContext); + chatServiceContext = retriever.transform(chatServiceContext); } // Perform post processing of all retrieved documents and messages for (PromptTransformer documentPostProcessor : this.documentPostProcessors) { - promptContext = documentPostProcessor.transform(promptContext); + chatServiceContext = documentPostProcessor.transform(chatServiceContext); } // Perform prompt augmentation for (PromptTransformer augmentor : this.augmentors) { - promptContext = augmentor.transform(promptContext); + chatServiceContext = augmentor.transform(chatServiceContext); } // Invoke Listeners onStart - for (ChatBotListener listener : this.chatBotListeners) { - listener.onStart(promptContextOnStart); + for (ChatServiceListener listener : this.chatServiceListeners) { + listener.onStart(chatServiceContextOnStart); } // Perform generation - final var promptContext2 = promptContext; + final var promptContext2 = chatServiceContext; Flux fluxChatResponse = new MessageAggregator() - .aggregate(this.streamingChatClient.stream(promptContext.getPrompt()), chatResponse -> { - for (ChatBotListener listener : this.chatBotListeners) { - listener.onComplete(new ChatBotResponse(promptContext2, chatResponse)); + .aggregate(this.streamingChatClient.stream(chatServiceContext.getPrompt()), chatResponse -> { + for (ChatServiceListener listener : this.chatServiceListeners) { + listener.onComplete(new ChatServiceResponse(promptContext2, chatResponse)); } }); // Invoke Listeners onComplete - return new StreamingChatBotResponse(promptContext, fluxChatResponse); + return new StreamingChatServiceResponse(chatServiceContext, fluxChatResponse); } - public static class DefaultChatBotBuilder { + public static class Builder { private StreamingChatClient chatClient; @@ -107,36 +107,36 @@ public static class DefaultChatBotBuilder { private List augmentors = new ArrayList<>(); - private List chatBotListeners = new ArrayList<>(); + private List chatServiceListeners = new ArrayList<>(); - public DefaultChatBotBuilder withChatClient(StreamingChatClient chatClient) { + public Builder withChatClient(StreamingChatClient chatClient) { this.chatClient = chatClient; return this; } - public DefaultChatBotBuilder withRetrievers(List retrievers) { + public Builder withRetrievers(List retrievers) { this.retrievers = retrievers; return this; } - public DefaultChatBotBuilder withDocumentPostProcessors(List documentPostProcessors) { + public Builder withDocumentPostProcessors(List documentPostProcessors) { this.documentPostProcessors = documentPostProcessors; return this; } - public DefaultChatBotBuilder withAugmentors(List augmentors) { + public Builder withAugmentors(List augmentors) { this.augmentors = augmentors; return this; } - public DefaultChatBotBuilder withChatBotListeners(List chatBotListeners) { - this.chatBotListeners = chatBotListeners; + public Builder withChatServiceListeners(List chatServiceListeners) { + this.chatServiceListeners = chatServiceListeners; return this; } - public DefaultStreamingChatBot build() { - return new DefaultStreamingChatBot(chatClient, retrievers, documentPostProcessors, augmentors, - chatBotListeners); + public StreamingPromptTransformingChatService build() { + return new StreamingPromptTransformingChatService(chatClient, retrievers, documentPostProcessors, + augmentors, chatServiceListeners); } } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/evaluation/EvaluationRequest.java b/spring-ai-core/src/main/java/org/springframework/ai/evaluation/EvaluationRequest.java index 657370e86ff..9d30bdfab4f 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/evaluation/EvaluationRequest.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/evaluation/EvaluationRequest.java @@ -1,7 +1,7 @@ package org.springframework.ai.evaluation; import org.springframework.ai.chat.ChatResponse; -import org.springframework.ai.chat.chatbot.ChatBotResponse; +import org.springframework.ai.chat.service.ChatServiceResponse; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.model.Content; @@ -16,9 +16,9 @@ public class EvaluationRequest { private final ChatResponse chatResponse; - public EvaluationRequest(ChatBotResponse chatBotResponse) { - this(chatBotResponse.getPromptContext().getPromptHistory().get(0), - chatBotResponse.getPromptContext().getContents(), chatBotResponse.getChatResponse()); + public EvaluationRequest(ChatServiceResponse chatServiceResponse) { + this(chatServiceResponse.getPromptContext().getPromptChanges().get(0).revised(), + chatServiceResponse.getPromptContext().getContents(), chatServiceResponse.getChatResponse()); } public EvaluationRequest(Prompt prompt, List dataList, ChatResponse chatResponse) { diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/Content.java b/spring-ai-core/src/main/java/org/springframework/ai/model/Content.java index a221d7f47ca..4ecaf351bae 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/model/Content.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/Content.java @@ -19,7 +19,7 @@ public interface Content { /** * Get the content of the message. */ - String getContent(); + String getContent(); // TODO consider getText /** * Get the media associated with the content. diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/history/ChatMemoryTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/memory/ChatMemoryTests.java similarity index 78% rename from spring-ai-core/src/test/java/org/springframework/ai/chat/history/ChatMemoryTests.java rename to spring-ai-core/src/test/java/org/springframework/ai/chat/memory/ChatMemoryTests.java index f1b2fca904c..741fd53418c 100644 --- a/spring-ai-core/src/test/java/org/springframework/ai/chat/history/ChatMemoryTests.java +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/memory/ChatMemoryTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.chat.history; +package org.springframework.ai.chat.memory; import java.util.List; @@ -29,12 +29,12 @@ import org.springframework.ai.chat.ChatResponse; import org.springframework.ai.chat.Generation; import org.springframework.ai.chat.StreamingChatClient; -import org.springframework.ai.chat.chatbot.ChatBotResponse; -import org.springframework.ai.chat.chatbot.DefaultChatBot; +import org.springframework.ai.chat.service.ChatServiceResponse; +import org.springframework.ai.chat.service.PromptTransformingChatService; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.chat.prompt.transformer.PromptContext; +import org.springframework.ai.chat.prompt.transformer.ChatServiceContext; import org.springframework.ai.model.Content; import org.springframework.ai.tokenizer.JTokkitTokenCountEstimator; @@ -61,15 +61,15 @@ public void chatMemoryMessageListAugmentor() { ChatMemory chatHistory = new InMemoryChatMemory(); - DefaultChatBot chatBot = DefaultChatBot.builder(chatClient) - .withRetrievers(List.of(new ChatMemoryRetriever(chatHistory))) + PromptTransformingChatService chatService = PromptTransformingChatService.builder(chatClient) + .withRetrievers(List.of(ChatMemoryRetriever.builder().withChatHistory(chatHistory).build())) .withContentPostProcessors( List.of(new LastMaxTokenSizeContentTransformer(new JTokkitTokenCountEstimator(), 10))) .withAugmentors(List.of(new MessageChatMemoryAugmentor())) - .withChatBotListeners(List.of(new ChatMemoryChatBotListener(chatHistory))) + .withChatServiceListeners(List.of(new ChatMemoryChatServiceListener(chatHistory))) .build(); - chatClientUserMessages(chatBot, chatHistory); + chatClientUserMessages(chatService, chatHistory); } @Test @@ -77,32 +77,32 @@ public void chatMemorySystemPromptAugmentor() { ChatMemory chatHistory = new InMemoryChatMemory(); - DefaultChatBot chatBot = DefaultChatBot.builder(chatClient) + PromptTransformingChatService chatService = PromptTransformingChatService.builder(chatClient) .withRetrievers(List.of(new ChatMemoryRetriever(chatHistory))) .withContentPostProcessors( List.of(new LastMaxTokenSizeContentTransformer(new JTokkitTokenCountEstimator(), 10))) .withAugmentors(List.of(new SystemPromptChatMemoryAugmentor())) - .withChatBotListeners(List.of(new ChatMemoryChatBotListener(chatHistory))) + .withChatServiceListeners(List.of(new ChatMemoryChatServiceListener(chatHistory))) .build(); - chatClientUserMessages(chatBot, chatHistory); + chatClientUserMessages(chatService, chatHistory); } - public void chatClientUserMessages(DefaultChatBot chatBot, ChatMemory chatHistory) { + public void chatClientUserMessages(PromptTransformingChatService chatService, ChatMemory chatHistory) { when(chatClient.call(promptCaptor.capture())) .thenReturn(new ChatResponse(List.of(new Generation("assistant:1")))) .thenReturn(new ChatResponse(List.of(new Generation("assistant:2")))) .thenReturn(new ChatResponse(List.of(new Generation("assistant:3")))); - var promptContext = PromptContext.builder() + var promptContext = ChatServiceContext.builder() .withConversationId("test-session-id") .withPrompt(new Prompt( List.of(new UserMessage("user:1"), new UserMessage("user:2"), new UserMessage("user:3"), new UserMessage("user:4"), new UserMessage("user:5")))) .build(); - ChatBotResponse response1 = chatBot.call(promptContext); + ChatServiceResponse response1 = chatService.call(promptContext); assertThat(response1.getChatResponse().getResult().getOutput().getContent()).isEqualTo("assistant:1"); @@ -112,7 +112,7 @@ public void chatClientUserMessages(DefaultChatBot chatBot, ChatMemory chatHistor List history = chatHistory.get("test-session-id", 1000); assertThat(history).hasSize(6); - ChatBotResponse response2 = chatBot.call(PromptContext.builder() + ChatServiceResponse response2 = chatService.call(ChatServiceContext.builder() .withConversationId("test-session-id") .withPrompt(new Prompt( List.of(new UserMessage("user:6"), new UserMessage("user:7"), new UserMessage("user:8")))) @@ -129,7 +129,7 @@ public void chatClientUserMessages(DefaultChatBot chatBot, ChatMemory chatHistor assertThat(contents.get(1).getContent()).isEqualTo("user:5"); assertThat(contents.get(2).getContent()).isEqualTo("assistant:1"); - ChatBotResponse response3 = chatBot.call(PromptContext.builder() + ChatServiceResponse response3 = chatService.call(ChatServiceContext.builder() .withConversationId("test-session-id") .withPrompt(new Prompt(List.of(new UserMessage("user:9")))).build()); assertThat(response3.getChatResponse().getResult().getOutput().getContent()).isEqualTo("assistant:3"); diff --git a/spring-ai-test/src/main/java/org/springframework/ai/evaluation/BaseMemoryTest.java b/spring-ai-test/src/main/java/org/springframework/ai/evaluation/BaseMemoryTest.java index 345c556125f..6721d2bb621 100644 --- a/spring-ai-test/src/main/java/org/springframework/ai/evaluation/BaseMemoryTest.java +++ b/spring-ai-test/src/main/java/org/springframework/ai/evaluation/BaseMemoryTest.java @@ -22,11 +22,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.ai.chat.chatbot.ChatBot; -import org.springframework.ai.chat.chatbot.StreamingChatBot; +import org.springframework.ai.chat.prompt.transformer.ChatServiceContext; +import org.springframework.ai.chat.service.ChatService; +import org.springframework.ai.chat.service.StreamingChatService; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.chat.prompt.transformer.PromptContext; import static org.assertj.core.api.Assertions.assertThat; @@ -39,48 +39,49 @@ public class BaseMemoryTest { protected RelevancyEvaluator relevancyEvaluator; - protected ChatBot chatBot; + protected ChatService chatService; - protected StreamingChatBot streamingChatBot; + protected StreamingChatService streamingChatService; - public BaseMemoryTest(RelevancyEvaluator relevancyEvaluator, ChatBot chatBot, - StreamingChatBot streamingChatClient) { + public BaseMemoryTest(RelevancyEvaluator relevancyEvaluator, ChatService chatService, + StreamingChatService streamingChatClient) { this.relevancyEvaluator = relevancyEvaluator; - this.chatBot = chatBot; - this.streamingChatBot = streamingChatClient; + this.chatService = chatService; + this.streamingChatService = streamingChatClient; } @Test - void memoryChatBot() { + void memoryChatService() { var prompt = new Prompt(new UserMessage("my name John Vincent Atanasoff")); - PromptContext promptContext = new PromptContext(prompt); + ChatServiceContext chatServiceContext = new ChatServiceContext(prompt); - var chatBotResponse1 = this.chatBot.call(promptContext); + var chatServiceResponse1 = this.chatService.call(chatServiceContext); - logger.info("Response1: " + chatBotResponse1.getChatResponse().getResult().getOutput().getContent()); + logger.info("Response1: " + chatServiceResponse1.getChatResponse().getResult().getOutput().getContent()); // response varies too much. - // assertThat(chatBotResponse1.getChatResponse().getResult().getOutput().getContent()).contains("John"); + // assertThat(chatServiceResponse1.getChatResponse().getResult().getOutput().getContent()).contains("John"); - var chatBotResponse2 = this.chatBot.call(new PromptContext(new Prompt(new String("What is my name?")))); - logger.info("Response2: " + chatBotResponse2.getChatResponse().getResult().getOutput().getContent()); - assertThat(chatBotResponse2.getChatResponse().getResult().getOutput().getContent()) + var chatServiceResponse2 = this.chatService + .call(new ChatServiceContext(new Prompt(new String("What is my name?")))); + logger.info("Response2: " + chatServiceResponse2.getChatResponse().getResult().getOutput().getContent()); + assertThat(chatServiceResponse2.getChatResponse().getResult().getOutput().getContent()) .contains("John Vincent Atanasoff"); EvaluationResponse evaluationResponse = this.relevancyEvaluator - .evaluate(new EvaluationRequest(chatBotResponse2)); + .evaluate(new EvaluationRequest(chatServiceResponse2)); logger.info("" + evaluationResponse); } @Test - void memoryStreamingChatBot() { + void memoryStreamingChatService() { var prompt = new Prompt(new UserMessage("my name John Vincent Atanasoff")); - PromptContext promptContext = new PromptContext(prompt); + ChatServiceContext chatServiceContext = new ChatServiceContext(prompt); - var fluxChatBotResponse1 = this.streamingChatBot.stream(promptContext); + var fluxChatServiceResponse1 = this.streamingChatService.stream(chatServiceContext); - String chatBotResponse1 = fluxChatBotResponse1.getChatResponse() + String chatServiceResponse1 = fluxChatServiceResponse1.getChatResponse() .collectList() .block() .stream() @@ -88,13 +89,13 @@ void memoryStreamingChatBot() { .map(response -> response.getResult().getOutput().getContent()) .collect(Collectors.joining()); - logger.info("Response1: " + chatBotResponse1); - // response varies too much assertThat(chatBotResponse1).contains("John"); + logger.info("Response1: " + chatServiceResponse1); + // response varies too much assertThat(chatServiceResponse1).contains("John"); - var fluxChatBotResponse2 = this.streamingChatBot - .stream(new PromptContext(new Prompt(new String("What is my name?")))); + var fluxChatServiceResponse2 = this.streamingChatService + .stream(new ChatServiceContext(new Prompt(new String("What is my name?")))); - String chatBotResponse2 = fluxChatBotResponse2.getChatResponse() + String chatServiceResponse2 = fluxChatServiceResponse2.getChatResponse() .collectList() .block() .stream() @@ -102,8 +103,8 @@ void memoryStreamingChatBot() { .map(response -> response.getResult().getOutput().getContent()) .collect(Collectors.joining()); - logger.info("Response2: " + chatBotResponse2); - assertThat(chatBotResponse2).contains("John Vincent Atanasoff"); + logger.info("Response2: " + chatServiceResponse2); + assertThat(chatServiceResponse2).contains("John Vincent Atanasoff"); } } From 82f473f2bc64619e6f99265645f8dd48492cdad2 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Wed, 15 May 2024 16:59:18 +0200 Subject: [PATCH 16/39] resolve javadoc reference issue --- .../ai/chat/prompt/transformer/ChatServiceContext.java | 1 + 1 file changed, 1 insertion(+) diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/ChatServiceContext.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/ChatServiceContext.java index 23191f8f337..82868e52841 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/ChatServiceContext.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/ChatServiceContext.java @@ -17,6 +17,7 @@ package org.springframework.ai.chat.prompt.transformer; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.service.ChatService; import org.springframework.ai.model.Content; import java.util.*; From 227f0703ec63f873c08de98ee4d5b4ff1b43f702 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Thu, 16 May 2024 09:41:01 +0200 Subject: [PATCH 17/39] Fix: function calling not able to resolve input types Resolves #726 --- .../ai/model/function/TypeResolverHelper.java | 14 +-- .../function/StandaloneWeatherFunction.java | 34 +++++++ .../model/function/TypeResolverHelperIT.java | 90 +++++++++++++++++++ 3 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 spring-ai-core/src/test/java/org/springframework/ai/model/function/StandaloneWeatherFunction.java create mode 100644 spring-ai-core/src/test/java/org/springframework/ai/model/function/TypeResolverHelperIT.java diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/function/TypeResolverHelper.java b/spring-ai-core/src/main/java/org/springframework/ai/model/function/TypeResolverHelper.java index 7d40ebac71a..f35411b1573 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/model/function/TypeResolverHelper.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/function/TypeResolverHelper.java @@ -22,6 +22,8 @@ import net.jodah.typetools.TypeResolver; +import org.springframework.cloud.function.context.catalog.FunctionTypeUtils; + /** * @author Christian Tzolov */ @@ -58,6 +60,12 @@ public static Type getFunctionArgumentType(Class> funct } public static Type getFunctionArgumentType(Type functionType, int argumentIndex) { + + // Resolves: https://github.com/spring-projects/spring-ai/issues/726 + if (!(functionType instanceof ParameterizedType)) { + functionType = FunctionTypeUtils.discoverFunctionTypeFromClass(FunctionTypeUtils.getRawType(functionType)); + } + var argumentType = functionType instanceof ParameterizedType ? ((ParameterizedType) functionType).getActualTypeArguments()[argumentIndex] : Object.class; @@ -77,10 +85,4 @@ public static Class toRawClass(Type type) { : null; } - // public static void main(String[] args) { - // Class> clazz = MockWeatherService.class; - // System.out.println(getFunctionInputType(clazz)); - - // } - } diff --git a/spring-ai-core/src/test/java/org/springframework/ai/model/function/StandaloneWeatherFunction.java b/spring-ai-core/src/test/java/org/springframework/ai/model/function/StandaloneWeatherFunction.java new file mode 100644 index 00000000000..b5ac63a5200 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/model/function/StandaloneWeatherFunction.java @@ -0,0 +1,34 @@ +/* + * 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.model.function; + +import java.util.function.Function; + +import org.springframework.ai.model.function.TypeResolverHelperIT.WeatherRequest; +import org.springframework.ai.model.function.TypeResolverHelperIT.WeatherResponse; + +/** + * @author Christian Tzolov + */ +public class StandaloneWeatherFunction implements Function { + + @Override + public WeatherResponse apply(WeatherRequest weatherRequest) { + return new WeatherResponse(42.0f); + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/model/function/TypeResolverHelperIT.java b/spring-ai-core/src/test/java/org/springframework/ai/model/function/TypeResolverHelperIT.java new file mode 100644 index 00000000000..f4647be23c9 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/model/function/TypeResolverHelperIT.java @@ -0,0 +1,90 @@ +/* + * Copyright 2023 - 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.model.function; + +import java.lang.reflect.Type; +import java.util.function.Function; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.function.context.config.FunctionContextUtils; +import org.springframework.context.annotation.Bean; +import org.springframework.context.support.GenericApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class TypeResolverHelperIT { + + @Autowired + GenericApplicationContext applicationContext; + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "weatherClassDefinition", "weatherFunctionDefinition", "standaloneWeatherFunction" }) + void beanInputTypeResolutionTest(String beanName) { + assertThat(applicationContext).isNotNull(); + Type beanType = FunctionContextUtils.findType(applicationContext.getBeanFactory(), beanName); + assertThat(beanType).isNotNull(); + Type functionInputType = TypeResolverHelper.getFunctionArgumentType(beanType, 0); + assertThat(functionInputType).isNotNull(); + assertThat(functionInputType.getTypeName()).isEqualTo(WeatherRequest.class.getName()); + + } + + public record WeatherRequest(String city) { + } + + public record WeatherResponse(float temperatureInCelsius) { + } + + public static class Outer { + + public static class InnerWeatherFunction implements Function { + + @Override + public WeatherResponse apply(WeatherRequest weatherRequest) { + return new WeatherResponse(42.0f); + } + + } + + } + + @SpringBootConfiguration + public static class TypeResolverHelperConfiguration { + + @Bean() + Outer.InnerWeatherFunction weatherClassDefinition() { + return new Outer.InnerWeatherFunction(); + } + + @Bean() + Function weatherFunctionDefinition() { + return new Outer.InnerWeatherFunction(); + } + + @Bean() + StandaloneWeatherFunction standaloneWeatherFunction() { + return new StandaloneWeatherFunction(); + } + + } + +} From fd9c98661d42ffbd233b29b54f63fbf69af4826e Mon Sep 17 00:00:00 2001 From: Mark Pollack Date: Thu, 16 May 2024 12:29:47 +0200 Subject: [PATCH 18/39] API refactoring * Added more natural method names to DocumentReader, Transformer, Writer * Changed Content getMedia to return Collection, deprecated use of List * Change constructor for Media to accept URL and Resource, deprecate Object constructor * Update ETL documentation and a few tests to avoid now deprecated APIs. --- .../ai/anthropic/AnthropicChatClientIT.java | 4 +- .../BedrockAnthropic3ChatClientIT.java | 4 +- .../ollama/OllamaChatClientMultimodalIT.java | 4 +- .../ai/openai/chat/OpenAiChatClientIT.java | 17 +++--- ...OpenAiPromptTransformingChatServiceIT.java | 18 +++++- .../gemini/VertexAiGeminiChatClientIT.java | 4 +- .../ai/chat/messages/AbstractMessage.java | 33 +++++------ .../ai/chat/messages/Media.java | 34 +++++++++++ .../ai/chat/messages/UserMessage.java | 3 +- .../springframework/ai/document/Document.java | 7 +-- .../ai/document/DocumentReader.java | 4 ++ .../ai/document/DocumentTransformer.java | 4 ++ .../ai/document/DocumentWriter.java | 4 ++ .../org/springframework/ai/model/Content.java | 15 ++++- .../ai/transformer/splitter/TextSplitter.java | 8 +++ .../splitter/TokenTextSplitter.java | 4 +- .../modules/ROOT/pages/api/etl-pipeline.adoc | 58 ++++++++++++++----- 17 files changed, 165 insertions(+), 60 deletions(-) diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatClientIT.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatClientIT.java index 92ba0a6eb47..ce5b45d37ec 100644 --- a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatClientIT.java +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatClientIT.java @@ -182,12 +182,12 @@ void beanStreamOutputConverterRecords() { @Test void multiModalityTest() throws IOException { - byte[] imageData = new ClassPathResource("/test.png").getContentAsByteArray(); + var imageData = new ClassPathResource("/test.png"); var userMessage = new UserMessage("Explain what do you see on this picture?", List.of(new Media(MimeTypeUtils.IMAGE_PNG, imageData))); - ChatResponse response = chatClient.call(new Prompt(List.of(userMessage))); + var response = chatClient.call(new Prompt(List.of(userMessage))); logger.info(response.getResult().getOutput().getContent()); assertThat(response.getResult().getOutput().getContent()).contains("bananas", "apple", "basket"); diff --git a/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/anthropic3/BedrockAnthropic3ChatClientIT.java b/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/anthropic3/BedrockAnthropic3ChatClientIT.java index 8a98b882196..9568f4f69d8 100644 --- a/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/anthropic3/BedrockAnthropic3ChatClientIT.java +++ b/models/spring-ai-bedrock/src/test/java/org/springframework/ai/bedrock/anthropic3/BedrockAnthropic3ChatClientIT.java @@ -206,12 +206,12 @@ void beanStreamOutputConverterRecords() { @Test void multiModalityTest() throws IOException { - byte[] imageData = new ClassPathResource("/test.png").getContentAsByteArray(); + var imageData = new ClassPathResource("/test.png"); var userMessage = new UserMessage("Explain what do you see o this picture?", List.of(new Media(MimeTypeUtils.IMAGE_PNG, imageData))); - ChatResponse response = client.call(new Prompt(List.of(userMessage))); + var response = client.call(new Prompt(List.of(userMessage))); logger.info(response.getResult().getOutput().getContent()); assertThat(response.getResult().getOutput().getContent()).contains("bananas", "apple", "basket"); diff --git a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatClientMultimodalIT.java b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatClientMultimodalIT.java index 4d587e005c4..0061ea4b38b 100644 --- a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatClientMultimodalIT.java +++ b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatClientMultimodalIT.java @@ -71,12 +71,12 @@ public static void beforeAll() throws IOException, InterruptedException { @Test void multiModalityTest() throws IOException { - byte[] imageData = new ClassPathResource("/test.png").getContentAsByteArray(); + var imageData = new ClassPathResource("/test.png"); var userMessage = new UserMessage("Explain what do you see on this picture?", List.of(new Media(MimeTypeUtils.IMAGE_PNG, imageData))); - ChatResponse response = client.call(new Prompt(List.of(userMessage))); + var response = client.call(new Prompt(List.of(userMessage))); logger.info(response.getResult().getOutput().getContent()); assertThat(response.getResult().getOutput().getContent()).contains("bananas", "apple", "basket"); diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java index 099a7394b96..0366aa626d9 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java @@ -16,6 +16,7 @@ package org.springframework.ai.openai.chat; import java.io.IOException; +import java.net.URL; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -249,12 +250,12 @@ void streamFunctionCallTest() { @ValueSource(strings = { "gpt-4-vision-preview", "gpt-4o" }) void multiModalityEmbeddedImage(String modelName) throws IOException { - byte[] imageData = new ClassPathResource("/test.png").getContentAsByteArray(); + var imageData = new ClassPathResource("/test.png"); var userMessage = new UserMessage("Explain what do you see on this picture?", List.of(new Media(MimeTypeUtils.IMAGE_PNG, imageData))); - ChatResponse response = chatClient + var response = chatClient .call(new Prompt(List.of(userMessage), OpenAiChatOptions.builder().withModel(modelName).build())); logger.info(response.getResult().getOutput().getContent()); @@ -266,9 +267,9 @@ void multiModalityEmbeddedImage(String modelName) throws IOException { @ValueSource(strings = { "gpt-4-vision-preview", "gpt-4o" }) void multiModalityImageUrl(String modelName) throws IOException { - var userMessage = new UserMessage("Explain what do you see on this picture?", - List.of(new Media(MimeTypeUtils.IMAGE_PNG, - "https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/_images/multimodal.test.png"))); + var userMessage = new UserMessage("Explain what do you see on this picture?", List + .of(new Media(MimeTypeUtils.IMAGE_PNG, + new URL("https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/_images/multimodal.test.png")))); ChatResponse response = chatClient .call(new Prompt(List.of(userMessage), OpenAiChatOptions.builder().withModel(modelName).build())); @@ -281,9 +282,9 @@ void multiModalityImageUrl(String modelName) throws IOException { @Test void streamingMultiModalityImageUrl() throws IOException { - var userMessage = new UserMessage("Explain what do you see on this picture?", - List.of(new Media(MimeTypeUtils.IMAGE_PNG, - "https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/_images/multimodal.test.png"))); + var userMessage = new UserMessage("Explain what do you see on this picture?", List + .of(new Media(MimeTypeUtils.IMAGE_PNG, + new URL("https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/_images/multimodal.test.png")))); Flux response = streamingChatClient.stream(new Prompt(List.of(userMessage), OpenAiChatOptions.builder().withModel(OpenAiApi.ChatModel.GPT_4_VISION_PREVIEW.getValue()).build())); diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/OpenAiPromptTransformingChatServiceIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/OpenAiPromptTransformingChatServiceIT.java index b164b8343d4..be45cee3ef5 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/OpenAiPromptTransformingChatServiceIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/OpenAiPromptTransformingChatServiceIT.java @@ -17,6 +17,7 @@ package org.springframework.ai.openai.chat.service; import java.util.List; +import java.util.function.Supplier; import io.qdrant.client.QdrantClient; import io.qdrant.client.QdrantGrpcClient; @@ -112,7 +113,7 @@ void simpleChat() { void loadData() { JsonReader jsonReader = new JsonReader(bikesResource, "name", "price", "shortDescription", "description"); var textSplitter = new TokenTextSplitter(); - List splitDocuments = textSplitter.apply(jsonReader.get()); + List splitDocuments = textSplitter.split(jsonReader.get()); for (Document splitDocument : splitDocuments) { splitDocument.getMetadata().put(TransformerContentType.EXTERNAL_KNOWLEDGE, "true"); @@ -121,6 +122,21 @@ void loadData() { vectorStore.accept(splitDocuments); } + void loadData2() { + JsonReader jsonReader = null; + TokenTextSplitter tokenTextSplitter = null; + VectorStore vectorStore = null; + + List documents = jsonReader.read(); + List splitDocuments = tokenTextSplitter.split(documents); + vectorStore.write(splitDocuments); + + // Now in java.util.Function style. + + Supplier> docs = jsonReader::read; + + } + @SpringBootConfiguration static class Config { diff --git a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatClientIT.java b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatClientIT.java index b9cfa126c46..9eff8981e3f 100644 --- a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatClientIT.java +++ b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatClientIT.java @@ -186,12 +186,12 @@ void beanStreamOutputConverterRecords() { @Test void multiModalityTest() throws IOException { - byte[] data = new ClassPathResource("/vertex.test.png").getContentAsByteArray(); + var data = new ClassPathResource("/vertex.test.png"); var userMessage = new UserMessage("Explain what do you see o this picture?", List.of(new Media(MimeTypeUtils.IMAGE_PNG, data))); - ChatResponse response = client.call(new Prompt(List.of(userMessage))); + var response = client.call(new Prompt(List.of(userMessage))); // Response should contain something like: // I see a bunch of bananas in a golden basket. The bananas are ripe and yellow. diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/AbstractMessage.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/AbstractMessage.java index 3c0d7a85558..b58cb3c218f 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/AbstractMessage.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/AbstractMessage.java @@ -18,12 +18,7 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.Charset; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; +import java.util.*; import org.springframework.core.io.Resource; import org.springframework.util.Assert; @@ -44,7 +39,7 @@ public abstract class AbstractMessage implements Message { protected final String textContent; - protected final List mediaData; + protected final List media; /** * Additional options for the message to influence the response, not a generative map. @@ -59,25 +54,25 @@ protected AbstractMessage(MessageType messageType, String content, Map(); + this.media = new ArrayList<>(); this.metadata = new HashMap<>(metadata); this.metadata.put(MESSAGE_TYPE, messageType); } - protected AbstractMessage(MessageType messageType, String textContent, List mediaData) { - this(messageType, textContent, mediaData, Map.of(MESSAGE_TYPE, messageType)); + protected AbstractMessage(MessageType messageType, String textContent, List media) { + this(messageType, textContent, media, Map.of(MESSAGE_TYPE, messageType)); } - protected AbstractMessage(MessageType messageType, String textContent, List mediaData, + protected AbstractMessage(MessageType messageType, String textContent, Collection media, Map metadata) { Assert.notNull(messageType, "Message type must not be null"); Assert.notNull(textContent, "Content must not be null"); - Assert.notNull(mediaData, "media data must not be null"); + Assert.notNull(media, "media data must not be null"); this.messageType = messageType; this.textContent = textContent; - this.mediaData = new ArrayList<>(mediaData); + this.media = new ArrayList<>(media); this.metadata = new HashMap<>(metadata); this.metadata.put(MESSAGE_TYPE, messageType); } @@ -94,7 +89,7 @@ protected AbstractMessage(MessageType messageType, Resource resource, Map(metadata); this.metadata.put(MESSAGE_TYPE, messageType); - this.mediaData = new ArrayList<>(); + this.media = new ArrayList<>(); try (InputStream inputStream = resource.getInputStream()) { this.textContent = StreamUtils.copyToString(inputStream, Charset.defaultCharset()); @@ -110,8 +105,8 @@ public String getContent() { } @Override - public List getMedia() { - return this.mediaData; + public List getMedia(String... dummy) { + return this.media; } @Override @@ -126,7 +121,7 @@ public MessageType getMessageType() { @Override public int hashCode() { - return Objects.hash(this.messageType, this.textContent, this.mediaData, this.metadata); + return Objects.hash(this.messageType, this.textContent, this.media, this.metadata); } @Override @@ -139,8 +134,8 @@ public boolean equals(Object obj) { } AbstractMessage other = (AbstractMessage) obj; return Objects.equals(this.messageType, other.messageType) - && Objects.equals(this.textContent, other.textContent) - && Objects.equals(this.mediaData, other.mediaData) && Objects.equals(this.metadata, other.metadata); + && Objects.equals(this.textContent, other.textContent) && Objects.equals(this.media, other.media) + && Objects.equals(this.metadata, other.metadata); } } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/Media.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/Media.java index ffe37793862..7077230b8c1 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/Media.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/Media.java @@ -15,9 +15,13 @@ */ package org.springframework.ai.chat.messages; +import org.springframework.core.io.Resource; import org.springframework.util.Assert; import org.springframework.util.MimeType; +import java.io.IOException; +import java.net.URL; + /** * The Media class represents the data and metadata of a media attachment in a message. It * consists of a MIME type and the raw data. @@ -33,16 +37,46 @@ public class Media { private final Object data; + /** + * The Media class represents the data and metadata of a media attachment in a + * message. It consists of a MIME type and the raw data. + * + * This class is used as a parameter in the constructor of the UserMessage class. + * @deprecated This constructor is deprecated since version 1.0.0 M1 and will be + * removed in a future release. + */ + @Deprecated(since = "1.0.0 M1", forRemoval = true) public Media(MimeType mimeType, Object data) { Assert.notNull(mimeType, "MimeType must not be null"); this.mimeType = mimeType; this.data = data; } + public Media(MimeType mimeType, URL url) { + Assert.notNull(mimeType, "MimeType must not be null"); + this.mimeType = mimeType; + this.data = url.toString(); + } + + public Media(MimeType mimeType, Resource resource) { + Assert.notNull(mimeType, "MimeType must not be null"); + this.mimeType = mimeType; + try { + this.data = resource.getContentAsByteArray(); + } + catch (IOException e) { + throw new RuntimeException(e); + } + } + public MimeType getMimeType() { return this.mimeType; } + /** + * Get the media data object + * @return a java.net.URL.toString() or a byte[] + */ public Object getData() { return this.data; } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/UserMessage.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/UserMessage.java index e792c985b1d..85f071eeb58 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/UserMessage.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/UserMessage.java @@ -16,6 +16,7 @@ package org.springframework.ai.chat.messages; import java.util.Arrays; +import java.util.Collection; import java.util.List; import java.util.Map; @@ -44,7 +45,7 @@ public UserMessage(String textContent, Media... media) { this(textContent, Arrays.asList(media)); } - public UserMessage(String textContent, List mediaList, Map metadata) { + public UserMessage(String textContent, Collection mediaList, Map metadata) { super(MessageType.USER, textContent, mediaList, metadata); } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/document/Document.java b/spring-ai-core/src/main/java/org/springframework/ai/document/Document.java index 30c4b479a74..dbc5fac8f95 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/document/Document.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/document/Document.java @@ -15,10 +15,7 @@ */ package org.springframework.ai.document; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -112,7 +109,7 @@ public String getContent() { } @Override - public List getMedia() { + public List getMedia(String... dummy) { return this.media; } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/document/DocumentReader.java b/spring-ai-core/src/main/java/org/springframework/ai/document/DocumentReader.java index 9d93a218bba..75b4fe2b26b 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/document/DocumentReader.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/document/DocumentReader.java @@ -20,4 +20,8 @@ public interface DocumentReader extends Supplier> { + default List read() { + return get(); + } + } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/document/DocumentTransformer.java b/spring-ai-core/src/main/java/org/springframework/ai/document/DocumentTransformer.java index 1253d6816e9..8c325a7bd0d 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/document/DocumentTransformer.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/document/DocumentTransformer.java @@ -20,4 +20,8 @@ public interface DocumentTransformer extends Function, List> { + default List transform(List transform) { + return apply(transform); + } + } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/document/DocumentWriter.java b/spring-ai-core/src/main/java/org/springframework/ai/document/DocumentWriter.java index b91dc9a2e38..31aeaf905ab 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/document/DocumentWriter.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/document/DocumentWriter.java @@ -23,4 +23,8 @@ */ public interface DocumentWriter extends Consumer> { + default void write(List documents) { + accept(documents); + } + } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/Content.java b/spring-ai-core/src/main/java/org/springframework/ai/model/Content.java index 4ecaf351bae..ea1eb741a32 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/model/Content.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/Content.java @@ -2,6 +2,7 @@ import org.springframework.ai.chat.messages.Media; +import java.util.Collection; import java.util.List; import java.util.Map; @@ -24,7 +25,19 @@ public interface Content { /** * Get the media associated with the content. */ - List getMedia(); + default Collection getMedia() { + return getMedia(""); + } + + /** + * Retrieves the collection of media attachments associated with the content. + * @param dummy a dummy parameter to ensure method signature uniqueness + * @return a list of Media objects representing the media attachments + * @deprecated This method is deprecated since version 1.0.0 M1 and will be removed in + * a future release + */ + @Deprecated(since = "1.0.0 M1", forRemoval = true) + List getMedia(String... dummy); /** * return Get the metadata associated with the content. diff --git a/spring-ai-core/src/main/java/org/springframework/ai/transformer/splitter/TextSplitter.java b/spring-ai-core/src/main/java/org/springframework/ai/transformer/splitter/TextSplitter.java index 9c5f0671b9d..697643af2c0 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/transformer/splitter/TextSplitter.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/transformer/splitter/TextSplitter.java @@ -43,6 +43,14 @@ public List apply(List documents) { return doSplitDocuments(documents); } + public List split(List documents) { + return this.apply(documents); + } + + public List split(Document document) { + return this.apply(List.of(document)); + } + public void setCopyContentFormatter(boolean copyContentFormatter) { this.copyContentFormatter = copyContentFormatter; } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/transformer/splitter/TokenTextSplitter.java b/spring-ai-core/src/main/java/org/springframework/ai/transformer/splitter/TokenTextSplitter.java index c820b51a3e7..cc034b49cbe 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/transformer/splitter/TokenTextSplitter.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/transformer/splitter/TokenTextSplitter.java @@ -68,10 +68,10 @@ public TokenTextSplitter(int defaultChunkSize, int minChunkSizeChars, int minChu @Override protected List splitText(String text) { - return split(text, this.defaultChunkSize); + return doSplit(text, this.defaultChunkSize); } - public List split(String text, int chunkSize) { + protected List doSplit(String text, int chunkSize) { if (text == null || text.trim().isEmpty()) { return new ArrayList<>(); } diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/etl-pipeline.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/etl-pipeline.adoc index d0656f16084..807d15f7bc1 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/etl-pipeline.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/etl-pipeline.adoc @@ -26,12 +26,22 @@ Let's say we have the following instances of those three ETL types * `TokenTextSplitter` an implementation of `DocumentTransformer` * `VectorStore` an implementation of `DocumentWriter` -To perform the basic loading of data into a Vector Database for use with the Retrieval Augmented Generation pattern, use the following code. +To perform the basic loading of data into a Vector Database for use with the Retrieval Augmented Generation pattern, use the following code in Java function style syntax. + [source,java] ---- vectorStore.accept(tokenTextSplitter.apply(pdfReader.get())); ---- +Alternatively, you can use method names that are more naturally expressive for the domain + +[source,java] +---- +vectorStore.write(tokenTextSplitter.split(pdfReader.read())); +---- + + + == Getting Started To begin creating a Spring AI RAG application, follow these steps: @@ -57,6 +67,9 @@ Provides a source of documents from diverse origins. ---- public interface DocumentReader extends Supplier> { + default List read() { + return get(); + } } ---- @@ -68,14 +81,17 @@ Example: [source,java] ---- @Component -public class MyAiApp { +class MyAiAppComponent { + + private final Resource resource; - @Value("classpath:bikes.json") // This is the json document to load - private Resource resource; + MyAiAppComponent(@Value("classpath:bikes.json") Resource resource) { + this.resource = resource; + } List loadJsonAsDocuments() { JsonReader jsonReader = new JsonReader(resource, "description"); - return jsonReader.get(); + return jsonReader.read(); } } ---- @@ -88,16 +104,18 @@ Example: [source,java] ---- @Component -public class MyTextReader { +class MyTextReader { - @Value("classpath:text-source.txt") // This is the text document to load - private Resource resource; + private final Resource resource; + MyTextReader(@Value("classpath:text-source.txt") Resource resource) { + this.resource = resource; + } List loadText() { TextReader textReader = new TextReader(resource); textReader.getCustomMetadata().put("filename", "text-source.txt"); - return textReader.get(); + return textReader.read(); } } ---- @@ -123,7 +141,7 @@ public class MyPagePdfDocumentReader { .withPagesPerDocument(1) .build()); - return pdfReader.get(); + return pdfReader.read(); } } @@ -153,7 +171,7 @@ public class MyPagePdfDocumentReader { .withPagesPerDocument(1) .build()); - return pdfReader.get(); + return pdfReader.read(); } } ---- @@ -167,14 +185,18 @@ Example: [source,java] ---- @Component -public class MyTikaDocumentReader { +class MyTikaDocumentReader { - @Value("classpath:/word-sample.docx") // This is the word document to load - private Resource resource; + private final Resource resource; + + MyTikaDocumentReader(@Value("classpath:/word-sample.docx") + Resource resource) { + this.resource = resource; + } List loadText() { TikaDocumentReader tikaDocumentReader = new TikaDocumentReader(resource); - return tikaDocumentReader.get(); + return tikaDocumentReader.read(); } } ---- @@ -187,6 +209,9 @@ Transforms a batch of documents as part of the processing workflow. ---- public interface DocumentTransformer extends Function, List> { + default List transform(List transform) { + return apply(transform); + } } ---- @@ -213,6 +238,9 @@ Manages the final stage of the ETL process, preparing documents for storage. ```java public interface DocumentWriter extends Consumer> { + default void write(List documents) { + accept(documents); + } } ``` ==== FileDocumentWriter From 549c480489d556c0d61ba895d6248bae33648f3d Mon Sep 17 00:00:00 2001 From: Mark Pollack Date: Thu, 16 May 2024 14:01:20 +0200 Subject: [PATCH 19/39] Refactoring * Put creation of EvaluationRequest in ChatServiceResponse * Add string constructor to QuestionContextAugmentor * change vectorStore accept() usage to write() --- .../chat/service/LongShortTermChatMemoryWithRagIT.java | 2 +- .../service/OpenAiPromptTransformingChatServiceIT.java | 8 ++++---- .../chat/prompt/transformer/QuestionContextAugmentor.java | 6 +++++- .../ai/chat/service/ChatServiceResponse.java | 6 ++++++ .../springframework/ai/evaluation/EvaluationRequest.java | 5 ----- .../org/springframework/ai/evaluation/BaseMemoryTest.java | 2 +- 6 files changed, 17 insertions(+), 12 deletions(-) diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/LongShortTermChatMemoryWithRagIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/LongShortTermChatMemoryWithRagIT.java index f8fc8f71f6f..c2739a01884 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/LongShortTermChatMemoryWithRagIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/LongShortTermChatMemoryWithRagIT.java @@ -144,7 +144,7 @@ void memoryChatService() { assertThat(chatServiceResponse2.getChatResponse().getResult().getOutput().getContent()).contains("Christian"); EvaluationResponse evaluationResponse = this.relevancyEvaluator - .evaluate(new EvaluationRequest(chatServiceResponse2)); + .evaluate(chatServiceResponse2.toEvaluationRequest()); assertTrue(evaluationResponse.isPass(), "Response is not relevant to the question"); diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/OpenAiPromptTransformingChatServiceIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/OpenAiPromptTransformingChatServiceIT.java index be45cee3ef5..a5f1ed415b8 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/OpenAiPromptTransformingChatServiceIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/service/OpenAiPromptTransformingChatServiceIT.java @@ -104,8 +104,8 @@ void simpleChat() { .withModel(GPT_4_TURBO_PREVIEW.getValue()) .build(); var relevancyEvaluator = new RelevancyEvaluator(this.chatClient, openAiChatOptions); - EvaluationRequest evaluationRequest = new EvaluationRequest(chatServiceResponse); - EvaluationResponse evaluationResponse = relevancyEvaluator.evaluate(evaluationRequest); + + EvaluationResponse evaluationResponse = relevancyEvaluator.evaluate(chatServiceResponse.toEvaluationRequest()); assertTrue(evaluationResponse.isPass(), "Response is not relevant to the question"); } @@ -113,13 +113,13 @@ void simpleChat() { void loadData() { JsonReader jsonReader = new JsonReader(bikesResource, "name", "price", "shortDescription", "description"); var textSplitter = new TokenTextSplitter(); - List splitDocuments = textSplitter.split(jsonReader.get()); + List splitDocuments = textSplitter.split(jsonReader.read()); for (Document splitDocument : splitDocuments) { splitDocument.getMetadata().put(TransformerContentType.EXTERNAL_KNOWLEDGE, "true"); } - vectorStore.accept(splitDocuments); + vectorStore.write(splitDocuments); } void loadData2() { diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/QuestionContextAugmentor.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/QuestionContextAugmentor.java index 05d79438e4d..bdd4830df7a 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/QuestionContextAugmentor.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/QuestionContextAugmentor.java @@ -55,7 +55,11 @@ public class QuestionContextAugmentor extends AbstractPromptTransformer { private String userText; public QuestionContextAugmentor() { - this.userText = DEFAULT_USER_TEXT; + this(DEFAULT_USER_TEXT); + } + + public QuestionContextAugmentor(String userText) { + this.userText = userText; this.setName("QuestionContextAugmentor"); } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/service/ChatServiceResponse.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/service/ChatServiceResponse.java index ecd46969d13..436bd437a61 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/service/ChatServiceResponse.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/service/ChatServiceResponse.java @@ -18,6 +18,7 @@ import org.springframework.ai.chat.ChatResponse; import org.springframework.ai.chat.prompt.transformer.ChatServiceContext; +import org.springframework.ai.evaluation.EvaluationRequest; import java.util.Objects; @@ -47,6 +48,11 @@ public ChatResponse getChatResponse() { return chatResponse; } + public EvaluationRequest toEvaluationRequest() { + return new EvaluationRequest(getPromptContext().getPromptChanges().get(0).revised(), + getPromptContext().getContents(), getChatResponse()); + } + @Override public String toString() { return "ChatServiceResponse{" + "chatServiceContext=" + chatServiceContext + ", chatResponse=" + chatResponse diff --git a/spring-ai-core/src/main/java/org/springframework/ai/evaluation/EvaluationRequest.java b/spring-ai-core/src/main/java/org/springframework/ai/evaluation/EvaluationRequest.java index 9d30bdfab4f..0939a69818a 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/evaluation/EvaluationRequest.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/evaluation/EvaluationRequest.java @@ -16,11 +16,6 @@ public class EvaluationRequest { private final ChatResponse chatResponse; - public EvaluationRequest(ChatServiceResponse chatServiceResponse) { - this(chatServiceResponse.getPromptContext().getPromptChanges().get(0).revised(), - chatServiceResponse.getPromptContext().getContents(), chatServiceResponse.getChatResponse()); - } - public EvaluationRequest(Prompt prompt, List dataList, ChatResponse chatResponse) { this.prompt = prompt; this.dataList = dataList; diff --git a/spring-ai-test/src/main/java/org/springframework/ai/evaluation/BaseMemoryTest.java b/spring-ai-test/src/main/java/org/springframework/ai/evaluation/BaseMemoryTest.java index 6721d2bb621..5fa66748162 100644 --- a/spring-ai-test/src/main/java/org/springframework/ai/evaluation/BaseMemoryTest.java +++ b/spring-ai-test/src/main/java/org/springframework/ai/evaluation/BaseMemoryTest.java @@ -69,7 +69,7 @@ void memoryChatService() { .contains("John Vincent Atanasoff"); EvaluationResponse evaluationResponse = this.relevancyEvaluator - .evaluate(new EvaluationRequest(chatServiceResponse2)); + .evaluate(chatServiceResponse2.toEvaluationRequest()); logger.info("" + evaluationResponse); } From 1b1daa7ee7e9a916a74b12fa9c72de6bd9575a74 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Thu, 16 May 2024 15:35:00 +0200 Subject: [PATCH 20/39] Add GEMINI_PRO_1_5_PRO and GEMINI_PRO_1_5_FLASH and update geminie maven BOM to 26.39.0 Also fix few Ollama docs and code formatting issue. --- .../ai/ollama/OllamaChatClient.java | 1 + .../ai/ollama/OllamaEmbeddingClient.java | 9 +++++++++ .../ai/ollama/OllamaChatClientMultimodalIT.java | 3 +-- .../ai/ollama/OllamaChatRequestTests.java | 6 +++--- .../gemini/VertexAiGeminiChatClient.java | 6 +++++- ...ertexAiGeminiChatClientFunctionCallingIT.java | 8 +++++--- pom.xml | 2 +- .../modules/ROOT/pages/api/chat/ollama-chat.adoc | 6 ++++-- .../ollama/OllamaAutoConfiguration.java | 7 ++----- .../tool/FunctionCallWithFunctionBeanIT.java | 16 ++++++++++++---- 10 files changed, 43 insertions(+), 21 deletions(-) diff --git a/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaChatClient.java b/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaChatClient.java index 26ef069badf..273d988667a 100644 --- a/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaChatClient.java +++ b/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaChatClient.java @@ -87,6 +87,7 @@ public OllamaChatClient withModel(String model) { /** * @deprecated Use {@link OllamaOptions} constructor instead. */ + @Deprecated public OllamaChatClient withDefaultOptions(OllamaOptions options) { this.defaultOptions = options; return this; diff --git a/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaEmbeddingClient.java b/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaEmbeddingClient.java index 296498e5028..1748709aac0 100644 --- a/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaEmbeddingClient.java +++ b/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/OllamaEmbeddingClient.java @@ -66,6 +66,11 @@ public OllamaEmbeddingClient(OllamaApi ollamaApi) { this.ollamaApi = ollamaApi; } + public OllamaEmbeddingClient(OllamaApi ollamaApi, OllamaOptions defaultOptions) { + this.ollamaApi = ollamaApi; + this.defaultOptions = defaultOptions; + } + /** * @deprecated Use {@link OllamaOptions#setModel} instead. */ @@ -75,6 +80,10 @@ public OllamaEmbeddingClient withModel(String model) { return this; } + /** + * @deprecated Use {@link OllamaOptions} constructor instead. + */ + @Deprecated public OllamaEmbeddingClient withDefaultOptions(OllamaOptions options) { this.defaultOptions = options; return this; diff --git a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatClientMultimodalIT.java b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatClientMultimodalIT.java index 0061ea4b38b..8599cfe9ed3 100644 --- a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatClientMultimodalIT.java +++ b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatClientMultimodalIT.java @@ -25,8 +25,8 @@ import org.junit.jupiter.api.Test; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.ollama.OllamaContainer; -import org.springframework.ai.chat.ChatResponse; import org.springframework.ai.chat.messages.Media; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.prompt.Prompt; @@ -38,7 +38,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.core.io.ClassPathResource; import org.springframework.util.MimeTypeUtils; -import org.testcontainers.ollama.OllamaContainer; import static org.assertj.core.api.Assertions.assertThat; diff --git a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatRequestTests.java b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatRequestTests.java index 1a6b720c760..f78b8f2fc30 100644 --- a/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatRequestTests.java +++ b/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatRequestTests.java @@ -30,7 +30,7 @@ */ public class OllamaChatRequestTests { - OllamaChatClient client = new OllamaChatClient(new OllamaApi()).withDefaultOptions( + OllamaChatClient client = new OllamaChatClient(new OllamaApi(), new OllamaOptions().withModel("MODEL_NAME").withTopK(99).withTemperature(66.6f).withNumGPU(1)); @Test @@ -105,8 +105,8 @@ public void createRequestWithPromptOptionsModelOverride() { @Test public void createRequestWithDefaultOptionsModelOverride() { - OllamaChatClient client2 = new OllamaChatClient(new OllamaApi()) - .withDefaultOptions(new OllamaOptions().withModel("DEFAULT_OPTIONS_MODEL")); + OllamaChatClient client2 = new OllamaChatClient(new OllamaApi(), + new OllamaOptions().withModel("DEFAULT_OPTIONS_MODEL")); var request = client2.ollamaChatRequest(new Prompt("Test message content"), true); diff --git a/models/spring-ai-vertex-ai-gemini/src/main/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatClient.java b/models/spring-ai-vertex-ai-gemini/src/main/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatClient.java index dfb2a14bc54..4091cdd3699 100644 --- a/models/spring-ai-vertex-ai-gemini/src/main/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatClient.java +++ b/models/spring-ai-vertex-ai-gemini/src/main/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatClient.java @@ -99,7 +99,11 @@ public enum ChatModel { GEMINI_PRO_VISION("gemini-pro-vision"), - GEMINI_PRO("gemini-pro"); + GEMINI_PRO("gemini-pro"), + + GEMINI_PRO_1_5_PRO("gemini-1.5-pro-preview-0514"), + + GEMINI_PRO_1_5_FLASH("gemini-1.5-flash-preview-0514"); ChatModel(String value) { this.value = value; diff --git a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatClientFunctionCallingIT.java b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatClientFunctionCallingIT.java index 4b424db2c58..2026729e3aa 100644 --- a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatClientFunctionCallingIT.java +++ b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatClientFunctionCallingIT.java @@ -68,7 +68,7 @@ public void afterEach() { } @Test - @Disabled("Google Vertex AI degraded support for parallel function calls") + // @Disabled("Google Vertex AI degraded support for parallel function calls") public void functionCallExplicitOpenApiSchema() { UserMessage userMessage = new UserMessage( @@ -98,7 +98,8 @@ public void functionCallExplicitOpenApiSchema() { """; var promptOptions = VertexAiGeminiChatOptions.builder() - .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO.getValue()) + // .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO.getValue()) + .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO_1_5_PRO.getValue()) .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) .withName("get_current_weather") .withDescription("Get the current weather in a given location") @@ -125,7 +126,8 @@ public void functionCallTestInferredOpenApiSchema() { List messages = new ArrayList<>(List.of(userMessage)); var promptOptions = VertexAiGeminiChatOptions.builder() - .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO.getValue()) + .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO_1_5_PRO.getValue()) + // .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO.getValue()) .withFunctionCallbacks(List.of( FunctionCallbackWrapper.builder(new MockWeatherService()) .withSchemaType(SchemaType.OPEN_API_SCHEMA) diff --git a/pom.xml b/pom.xml index 873b77ce210..32af3af00e1 100644 --- a/pom.xml +++ b/pom.xml @@ -129,7 +129,7 @@ 2.16.1 0.26.0 1.17.0 - 26.37.0 + 26.39.0 1.9.1 2.0.5 9.20.0 diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/ollama-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/ollama-chat.adoc index 8071a745d8c..de61f88518b 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/ollama-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/ollama-chat.adoc @@ -249,8 +249,8 @@ Next, create an `OllamaChatClient` instance and use it to text generations reque ---- var ollamaApi = new OllamaApi(); -var chatClient = new OllamaChatClient(ollamaApi).withModel(MODEL) - .withDefaultOptions(OllamaOptions.create() +var chatClient = new OllamaChatClient(ollamaApi, + OllamaOptions.create() .withModel(OllamaOptions.DEFAULT_MODEL) .withTemperature(0.9f)); @@ -274,6 +274,8 @@ image::ollama-chat-completion-api.jpg[OllamaApi Chat Completion API Diagram, 800 Here is a simple snippet showing how to use the API programmatically: +NOTE: The `OllamaApi` is low level api and is not recommended for direct use. Use the `OllamaChatClient` instead. + [source,java] ---- OllamaApi ollamaApi = diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaAutoConfiguration.java index d3378ec88e7..8b788e73bc1 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/ollama/OllamaAutoConfiguration.java @@ -57,9 +57,7 @@ public OllamaApi ollamaApi(OllamaConnectionDetails connectionDetails, RestClient @ConditionalOnProperty(prefix = OllamaChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", matchIfMissing = true) public OllamaChatClient ollamaChatClient(OllamaApi ollamaApi, OllamaChatProperties properties) { - - return new OllamaChatClient(ollamaApi).withModel(properties.getModel()) - .withDefaultOptions(properties.getOptions()); + return new OllamaChatClient(ollamaApi, properties.getOptions()); } @Bean @@ -68,8 +66,7 @@ public OllamaChatClient ollamaChatClient(OllamaApi ollamaApi, OllamaChatProperti matchIfMissing = true) public OllamaEmbeddingClient ollamaEmbeddingClient(OllamaApi ollamaApi, OllamaEmbeddingProperties properties) { - return new OllamaEmbeddingClient(ollamaApi).withModel(properties.getModel()) - .withDefaultOptions(properties.getOptions()); + return new OllamaEmbeddingClient(ollamaApi, properties.getOptions()); } private static class PropertiesOllamaConnectionDetails implements OllamaConnectionDetails { diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/tool/FunctionCallWithFunctionBeanIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/tool/FunctionCallWithFunctionBeanIT.java index d2066f0b2fe..90844567583 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/tool/FunctionCallWithFunctionBeanIT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/vertexai/gemini/tool/FunctionCallWithFunctionBeanIT.java @@ -54,9 +54,10 @@ class FunctionCallWithFunctionBeanIT { @Test void functionCallTest() { - contextRunner - .withPropertyValues("spring.ai.vertex.ai.gemini.chat.options.model=" - + VertexAiGeminiChatClient.ChatModel.GEMINI_PRO.getValue()) + contextRunner.withPropertyValues("spring.ai.vertex.ai.gemini.chat.options.model=" + // + VertexAiGeminiChatClient.ChatModel.GEMINI_PRO.getValue()) + + VertexAiGeminiChatClient.ChatModel.GEMINI_PRO_1_5_PRO.getValue()) + // + VertexAiGeminiChatClient.ChatModel.GEMINI_PRO_1_5_FLASH.getValue()) .run(context -> { VertexAiGeminiChatClient chatClient = context.getBean(VertexAiGeminiChatClient.class); @@ -67,15 +68,22 @@ void functionCallTest() { If the information was not fetched call the function again. Repeat at most 3 times. """); var userMessage = new UserMessage( - "What's the weather like in San Francisco, Paris and in Tokyo (Japan)?"); + // "What's the weather like in San Francisco, Paris and in Tokyo? + // Please let me know how many function calls you've preformed."); + "What's the weather like in San Francisco, Paris and in Tokyo?"); ChatResponse response = chatClient.call(new Prompt(List.of(systemMessage, userMessage), VertexAiGeminiChatOptions.builder().withFunction("weatherFunction").build())); + // ChatResponse response = chatClient.call(new + // Prompt(List.of(userMessage), + // VertexAiGeminiChatOptions.builder().withFunction("weatherFunction").build())); logger.info("Response: {}", response); assertThat(response.getResult().getOutput().getContent()).contains("30", "10", "15"); + Thread.sleep(10000); + response = chatClient.call(new Prompt(List.of(systemMessage, userMessage), VertexAiGeminiChatOptions.builder().withFunction("weatherFunction3").build())); From 7887159e68bc4c29b9ee3ab902a3ce7c5124cdc9 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Fri, 17 May 2024 06:43:47 +0200 Subject: [PATCH 21/39] Improve Gemini antora documentation and fix broken links --- .../gemini/VertexAiGeminiChatClient.java | 2 +- .../gemini/VertexAiGeminiChatOptions.java | 6 ++++++ .../gemini/VertexAiGeminiChatClientIT.java | 2 +- ...texAiGeminiChatClientFunctionCallingIT.java | 10 +++++----- .../images/vertex-ai-gemini-native-api.jpg | Bin 0 -> 457715 bytes .../ROOT/pages/api/chat/anthropic-chat.adoc | 2 +- .../ROOT/pages/api/chat/azure-openai-chat.adoc | 2 +- .../api/chat/bedrock/bedrock-anthropic.adoc | 2 +- .../api/chat/bedrock/bedrock-anthropic3.adoc | 2 +- .../pages/api/chat/bedrock/bedrock-cohere.adoc | 2 +- .../pages/api/chat/bedrock/bedrock-llama.adoc | 2 +- .../pages/api/chat/bedrock/bedrock-titan.adoc | 2 +- .../ROOT/pages/api/chat/mistralai-chat.adoc | 2 +- .../ROOT/pages/api/chat/ollama-chat.adoc | 2 +- .../ROOT/pages/api/chat/openai-chat.adoc | 2 +- .../pages/api/chat/vertexai-gemini-chat.adoc | 12 ++++++++++-- .../pages/api/chat/vertexai-palm2-chat.adoc | 2 +- .../ROOT/pages/api/chat/watsonx-ai-chat.adoc | 2 +- 18 files changed, 35 insertions(+), 21 deletions(-) create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/images/vertex-ai-gemini-native-api.jpg diff --git a/models/spring-ai-vertex-ai-gemini/src/main/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatClient.java b/models/spring-ai-vertex-ai-gemini/src/main/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatClient.java index 4091cdd3699..e30df03cef8 100644 --- a/models/spring-ai-vertex-ai-gemini/src/main/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatClient.java +++ b/models/spring-ai-vertex-ai-gemini/src/main/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatClient.java @@ -120,7 +120,7 @@ public String getValue() { public VertexAiGeminiChatClient(VertexAI vertexAI) { this(vertexAI, VertexAiGeminiChatOptions.builder() - .withModel(ChatModel.GEMINI_PRO_VISION.getValue()) + .withModel(ChatModel.GEMINI_PRO_VISION) .withTemperature(0.8f) .build()); } diff --git a/models/spring-ai-vertex-ai-gemini/src/main/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatOptions.java b/models/spring-ai-vertex-ai-gemini/src/main/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatOptions.java index 7d4e9875a5c..311ff16af60 100644 --- a/models/spring-ai-vertex-ai-gemini/src/main/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatOptions.java +++ b/models/spring-ai-vertex-ai-gemini/src/main/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatOptions.java @@ -28,6 +28,7 @@ import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.model.function.FunctionCallback; import org.springframework.ai.model.function.FunctionCallingOptions; +import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatClient.ChatModel; import org.springframework.boot.context.properties.NestedConfigurationProperty; import org.springframework.util.Assert; @@ -144,6 +145,11 @@ public Builder withModel(String modelName) { return this; } + public Builder withModel(ChatModel model) { + this.options.setModel(model.getValue()); + return this; + } + public Builder withFunctionCallbacks(List functionCallbacks) { this.options.functionCallbacks = functionCallbacks; return this; diff --git a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatClientIT.java b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatClientIT.java index 9eff8981e3f..58f687eb445 100644 --- a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatClientIT.java +++ b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatClientIT.java @@ -234,7 +234,7 @@ public VertexAI vertexAiApi() { public VertexAiGeminiChatClient vertexAiEmbedding(VertexAI vertexAi) { return new VertexAiGeminiChatClient(vertexAi, VertexAiGeminiChatOptions.builder() - .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO_VISION.getValue()) + .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO_VISION) .build()); } diff --git a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatClientFunctionCallingIT.java b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatClientFunctionCallingIT.java index 2026729e3aa..d5209e82e81 100644 --- a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatClientFunctionCallingIT.java +++ b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatClientFunctionCallingIT.java @@ -98,8 +98,8 @@ public void functionCallExplicitOpenApiSchema() { """; var promptOptions = VertexAiGeminiChatOptions.builder() - // .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO.getValue()) - .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO_1_5_PRO.getValue()) + // .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO) + .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO_1_5_PRO) .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) .withName("get_current_weather") .withDescription("Get the current weather in a given location") @@ -126,7 +126,7 @@ public void functionCallTestInferredOpenApiSchema() { List messages = new ArrayList<>(List.of(userMessage)); var promptOptions = VertexAiGeminiChatOptions.builder() - .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO_1_5_PRO.getValue()) + .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO_1_5_PRO) // .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO.getValue()) .withFunctionCallbacks(List.of( FunctionCallbackWrapper.builder(new MockWeatherService()) @@ -168,7 +168,7 @@ public void functionCallTestInferredOpenApiSchemaStream() { List messages = new ArrayList<>(List.of(userMessage)); var promptOptions = VertexAiGeminiChatOptions.builder() - .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO.getValue()) + .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO) .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) .withSchemaType(SchemaType.OPEN_API_SCHEMA) .withName("getCurrentWeather") @@ -227,7 +227,7 @@ public VertexAI vertexAiApi() { public VertexAiGeminiChatClient vertexAiEmbedding(VertexAI vertexAi) { return new VertexAiGeminiChatClient(vertexAi, VertexAiGeminiChatOptions.builder() - .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO.getValue()) + .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO) .withTemperature(0.9f) .build()); } diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/images/vertex-ai-gemini-native-api.jpg b/spring-ai-docs/src/main/antora/modules/ROOT/images/vertex-ai-gemini-native-api.jpg new file mode 100644 index 0000000000000000000000000000000000000000..68b44f1bbb89d9afe31a25982ea8a03e1f73b405 GIT binary patch literal 457715 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 z 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

})LKGKdCZM+gM(>$V{@MY%yG@2l#wQlRzj*L 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 - Offers a pre-configured link:https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/convert/support/GenericConversionService.html[GenericConversionService] for transforming LLM output into the desired format. No default `FormatProvider` implementation is provided. -* `AbstractMessageOutputConverter` - Supplies a pre-configured https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/jms/support/converter/MessageConverter.html[MessageConverter] for converting LLM output into the desired format. No default `FormatProvider` implementation is provided. -* `BeanOutputConverter` - Configured with a designated Java class (e.g., Bean), this converter employs a `FormatProvider` implementation that directs the AI Model to produce a JSON response compliant with a `DRAFT_2020_12`, `JSON Schema` derived from the specified Java class. Subsequently, it utilizes an `ObjectMapper` to deserialize the JSON output into a Java object instance of the target class. +* `AbstractMessageOutputConverter` - Supplies a pre-configured https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/jms/support/converter/MessageConverter.html[MessageConverter] for converting LLM output into the desired format. No default `FormatProvider` implementation is provided. +* `BeanOutputConverter` - Configured with a designated Java class (e.g., Bean) or a link:https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/ParameterizedTypeReference.html[ParameterizedTypeReference], this converter employs a `FormatProvider` implementation that directs the AI Model to produce a JSON response compliant with a `DRAFT_2020_12`, `JSON Schema` derived from the specified Java class. Subsequently, it utilizes an `ObjectMapper` to deserialize the JSON output into a Java object instance of the target class. * `MapOutputConverter` - Extends the functionality of `AbstractMessageOutputConverter` with a `FormatProvider` implementation that guides the AI Model to generate an RFC8259 compliant JSON response. Additionally, it incorporates a converter implementation that utilizes the provided `MessageConverter` to translate the JSON payload into a `java.util.Map` instance. * `ListOutputConverter` - Extends the `AbstractConversionServiceOutputConverter` and includes a `FormatProvider` implementation tailored for comma-delimited list output. The converter implementation employs the provided `ConversionService` to transform the model text output into a `java.util.List`. @@ -129,6 +129,29 @@ Generation generation = chatClient.call( ActorsFilms actorsFilms = beanOutputConverter.convert(generation.getOutput().getContent()); ---- +==== Generic Bean Types + +Use the `ParameterizedTypeReference` constructor to specify a more complex target class structure. +For example, to represent a list of actors and their filmographies: + +[source,java] +---- +BeanOutputConverter> outputConverter = new BeanOutputConverter<>( + new ParameterizedTypeReference>() { }); + +String format = outputConverter.getFormat(); +String template = """ + Generate the filmography of 5 movies for Tom Hanks and Bill Murray. + {format} + """; + +Prompt prompt = new Prompt(new PromptTemplate(template, Map.of("format", format)).createMessage()); + +Generation generation = chatClient.call(prompt).getResult(); + +List actorsFilms = outputConverter.convert(generation.getOutput().getContent()); +---- + === Map Output Converter Following sniped shows how to use `MapOutputConverter` to generate a list of numbers. From 275189970d84e3468083920e69b100c5b7f34129 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Sat, 18 May 2024 06:05:50 +0200 Subject: [PATCH 32/39] BeanOutputConverter: fix javadoc --- .../org/springframework/ai/converter/BeanOutputConverter.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spring-ai-core/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java b/spring-ai-core/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java index aca2902b0bf..04bb6d0d2f6 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java @@ -69,8 +69,8 @@ public class BeanOutputConverter implements StructuredOutputConverter { * Constructor to initialize with the target type's class. * @param clazz The target type's class. */ - public BeanOutputConverter(Class typeClass) { - this(ParameterizedTypeReference.forType(typeClass)); + public BeanOutputConverter(Class clazz) { + this(ParameterizedTypeReference.forType(clazz)); } /** From 09e122de5e0e17ebfddfc86560c71087604ce9a6 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Sat, 18 May 2024 06:03:02 +0200 Subject: [PATCH 33/39] Add Mistral AI tool_call_id to ChatCompletionMessage. The tool call ID that this message is responding to, applicable for the TOOL role. --- .../ai/mistralai/MistralAiChatClient.java | 7 ++++--- .../ai/mistralai/api/MistralAiApi.java | 19 +++++++++++++++++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java index 98a25025d67..9b7e19de097 100644 --- a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java @@ -250,7 +250,8 @@ protected ChatCompletionRequest doCreateToolResponseRequest(ChatCompletionReques // message. for (ToolCall toolCall : responseMessage.toolCalls()) { - var functionName = toolCall.function().name(); + String id = toolCall.id(); + String functionName = toolCall.function().name(); String functionArguments = toolCall.function().arguments(); if (!this.functionCallbackRegister.containsKey(functionName)) { @@ -260,8 +261,8 @@ protected ChatCompletionRequest doCreateToolResponseRequest(ChatCompletionReques String functionResponse = this.functionCallbackRegister.get(functionName).call(functionArguments); // Add the function response to the conversation. - conversationHistory - .add(new ChatCompletionMessage(functionResponse, ChatCompletionMessage.Role.TOOL, functionName, null)); + conversationHistory.add(new ChatCompletionMessage(functionResponse, ChatCompletionMessage.Role.TOOL, + functionName, null, id)); } // Recursively call chatCompletionWithTools until the model doesn't call a diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java index 5732c871a66..16f2465c532 100644 --- a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java @@ -452,6 +452,8 @@ public record ResponseFormat(@JsonProperty("type") String type) { * types. * @param toolCalls The tool calls generated by the model, such as function calls. * Applicable only for {@link Role#ASSISTANT} role and null otherwise. + * @param toolCallId Tool call that this message is responding to. Only applicable for + * the {@link Role#TOOL} role and null otherwise. */ @JsonInclude(Include.NON_NULL) public record ChatCompletionMessage( @@ -459,9 +461,22 @@ public record ChatCompletionMessage( @JsonProperty("content") String content, @JsonProperty("role") Role role, @JsonProperty("name") String name, - @JsonProperty("tool_calls") List toolCalls) { + @JsonProperty("tool_calls") List toolCalls, + @JsonProperty("tool_call_id") String toolCallId) { // @formatter:on + /** + * Message comprising the conversation. + * @param content The contents of the message. + * @param role The role of the messages author. Could be one of the {@link Role} + * types. + * @param toolCalls The tool calls generated by the model, such as function calls. + * Applicable only for {@link Role#ASSISTANT} role and null otherwise. + */ + public ChatCompletionMessage(String content, Role role, String name, List toolCalls) { + this(content, role, name, toolCalls, null); + } + /** * Create a chat completion message with the given content and role. All other * fields are null. @@ -469,7 +484,7 @@ public record ChatCompletionMessage( * @param role The role of the author of this message. */ public ChatCompletionMessage(String content, Role role) { - this(content, role, null, null); + this(content, role, null, null, null); } /** From e8f663d0c85ae4be26bd9dc9d4ee8a6a12a68d26 Mon Sep 17 00:00:00 2001 From: Pablo Sanchidrian <99414391+PabloSanchi@users.noreply.github.com> Date: Sun, 19 May 2024 11:48:09 +0200 Subject: [PATCH 34/39] Fix: watsonx.ai | refresh token issue and implement retry mechanism (#735) * fix: refresh token only when needed and apply retry policy * fix: remove unused import --- .../ai/watsonx/api/WatsonxAiApi.java | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/api/WatsonxAiApi.java b/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/api/WatsonxAiApi.java index 18ae0c4b061..30673ce617b 100644 --- a/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/api/WatsonxAiApi.java +++ b/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/api/WatsonxAiApi.java @@ -20,8 +20,11 @@ import java.util.function.Consumer; import com.ibm.cloud.sdk.core.security.IamAuthenticator; +import com.ibm.cloud.sdk.core.security.IamToken; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; import reactor.core.publisher.Flux; import org.springframework.ai.retry.RetryUtils; @@ -50,6 +53,7 @@ public class WatsonxAiApi { private final String streamEndpoint; private final String textEndpoint; private final String projectId; + private IamToken token; /** * Create a new chat api. @@ -72,6 +76,7 @@ public WatsonxAiApi( this.textEndpoint = textEndpoint; this.projectId = projectId; this.iamAuthenticator = IamAuthenticator.fromConfiguration(Map.of("APIKEY", IAMToken)); + this.token = this.iamAuthenticator.requestToken(); Consumer defaultHeaders = headers -> { headers.setContentType(MediaType.APPLICATION_JSON); @@ -88,27 +93,33 @@ public WatsonxAiApi( .build(); } + @Retryable(retryFor = Exception.class, maxAttempts = 3, backoff = @Backoff(random = true, delay = 1200, maxDelay = 7000, multiplier = 2.5)) public ResponseEntity generate(WatsonxAiRequest watsonxAiRequest) { Assert.notNull(watsonxAiRequest, WATSONX_REQUEST_CANNOT_BE_NULL); - String bearer = this.iamAuthenticator.requestToken().getAccessToken(); + if(this.token.needsRefresh()) { + this.token = this.iamAuthenticator.requestToken(); + } return this.restClient.post() .uri(this.textEndpoint) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + bearer) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + this.token.getAccessToken()) .body(watsonxAiRequest.withProjectId(projectId)) .retrieve() .toEntity(WatsonxAiResponse.class); } + @Retryable(retryFor = Exception.class, maxAttempts = 3, backoff = @Backoff(random = true, delay = 1200, maxDelay = 7000, multiplier = 2.5)) public Flux generateStreaming(WatsonxAiRequest watsonxAiRequest) { Assert.notNull(watsonxAiRequest, WATSONX_REQUEST_CANNOT_BE_NULL); - String bearer = this.iamAuthenticator.requestToken().getAccessToken(); + if(this.token.needsRefresh()) { + this.token = this.iamAuthenticator.requestToken(); + } return this.webClient.post() .uri(this.streamEndpoint) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + bearer) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + this.token.getAccessToken()) .bodyValue(watsonxAiRequest.withProjectId(this.projectId)) .retrieve() .bodyToFlux(WatsonxAiResponse.class) From 6f4ab64f92086a01c879e011b3e639da4c384bee Mon Sep 17 00:00:00 2001 From: Ashif Ismail Date: Sat, 18 May 2024 11:41:04 +0400 Subject: [PATCH 35/39] Update README.md fixed typo :) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1b798a2c0a2..346ff99e4b7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Let's make your `@Beans` intelligent! For further information go to our [Spring AI reference documentation](https://docs.spring.io/spring-ai/reference/). -### Breadking changes +### Breaking changes (15.05.2024) On our march to release 1.0 M1 we have made several breaking changes. Apologies, it is for the best! From 6b674014ed942c14a05bf4603eca14fc192466a1 Mon Sep 17 00:00:00 2001 From: GR Date: Wed, 24 Apr 2024 08:23:40 +0800 Subject: [PATCH 36/39] Add support for the MiniMax Model * See https://minimaxi.com/ --- README.md | 1 + models/spring-ai-minimax/README.md | 3 + models/spring-ai-minimax/pom.xml | 59 ++ .../ai/minimax/MiniMaxChatClient.java | 371 ++++++++ .../ai/minimax/MiniMaxChatOptions.java | 467 +++++++++ .../ai/minimax/MiniMaxEmbeddingClient.java | 146 +++ .../ai/minimax/MiniMaxEmbeddingOptions.java | 70 ++ .../ai/minimax/aot/MiniMaxRuntimeHints.java | 43 + .../ai/minimax/api/ApiUtils.java | 41 + .../ai/minimax/api/MiniMaxApi.java | 891 ++++++++++++++++++ .../MiniMaxStreamFunctionCallingHelper.java | 202 ++++ .../api/common/MiniMaxApiException.java | 16 + .../resources/META-INF/spring/aot.factories | 2 + .../minimax/ChatCompletionRequestTests.java | 144 +++ .../ai/minimax/MiniMaxTestConfiguration.java | 54 ++ .../ai/minimax/api/MiniMaxApiIT.java | 67 ++ .../api/MiniMaxApiToolFunctionCallIT.java | 141 +++ .../ai/minimax/api/MiniMaxRetryTests.java | 178 ++++ .../ai/minimax/api/MockWeatherService.java | 92 ++ .../test/resources/prompts/system-message.st | 3 + pom.xml | 3 + spring-ai-bom/pom.xml | 12 + .../src/main/antora/modules/ROOT/nav.adoc | 3 + .../functions/minimax-chat-functions.adoc | 226 +++++ .../ROOT/pages/api/chat/minimax-chat.adoc | 251 +++++ .../api/embeddings/minimax-embeddings.adoc | 198 ++++ spring-ai-spring-boot-autoconfigure/pom.xml | 7 + .../minimax/MiniMaxAutoConfiguration.java | 104 ++ .../minimax/MiniMaxChatProperties.java | 62 ++ .../minimax/MiniMaxConnectionProperties.java | 31 + .../minimax/MiniMaxEmbeddingProperties.java | 70 ++ .../minimax/MiniMaxParentProperties.java | 43 + .../minimax/FunctionCallbackInPromptIT.java | 113 +++ ...nctionCallbackWithPlainFunctionBeanIT.java | 171 ++++ .../minimax/FunctionCallbackWrapperIT.java | 119 +++ .../minimax/MiniMaxAutoConfigurationIT.java | 93 ++ .../minimax/MiniMaxPropertiesTests.java | 329 +++++++ .../minimax/MockWeatherService.java | 94 ++ .../spring-ai-starter-minimax/pom.xml | 42 + 39 files changed, 4962 insertions(+) create mode 100644 models/spring-ai-minimax/README.md create mode 100644 models/spring-ai-minimax/pom.xml create mode 100644 models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatClient.java create mode 100644 models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatOptions.java create mode 100644 models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxEmbeddingClient.java create mode 100644 models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxEmbeddingOptions.java create mode 100644 models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/aot/MiniMaxRuntimeHints.java create mode 100644 models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/ApiUtils.java create mode 100644 models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java create mode 100644 models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxStreamFunctionCallingHelper.java create mode 100644 models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/common/MiniMaxApiException.java create mode 100644 models/spring-ai-minimax/src/main/resources/META-INF/spring/aot.factories create mode 100644 models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/ChatCompletionRequestTests.java create mode 100644 models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/MiniMaxTestConfiguration.java create mode 100644 models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiIT.java create mode 100644 models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiToolFunctionCallIT.java create mode 100644 models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxRetryTests.java create mode 100644 models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MockWeatherService.java create mode 100644 models/spring-ai-minimax/src/test/resources/prompts/system-message.st create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/functions/minimax-chat-functions.adoc create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/minimax-chat.adoc create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/minimax-embeddings.adoc create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfiguration.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxChatProperties.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxConnectionProperties.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxEmbeddingProperties.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxParentProperties.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackInPromptIT.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackWithPlainFunctionBeanIT.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackWrapperIT.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfigurationIT.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxPropertiesTests.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MockWeatherService.java create mode 100644 spring-ai-spring-boot-starters/spring-ai-starter-minimax/pom.xml diff --git a/README.md b/README.md index 346ff99e4b7..91f62509297 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ Spring AI supports many AI models. For an overview see here. Specific models c * PostgresML * Transformers (ONNX) * Anthropic Claude3 +* MiniMax **Prompts:** Central to AI model interaction is the Prompt, which provides specific instructions for the AI to act upon. diff --git a/models/spring-ai-minimax/README.md b/models/spring-ai-minimax/README.md new file mode 100644 index 00000000000..a300b5b865f --- /dev/null +++ b/models/spring-ai-minimax/README.md @@ -0,0 +1,3 @@ +[MiniMax Chat Documentation](https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/api/chat/minimax-chat.html) + +[MiniMax Embedding Documentation](https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/api/embeddings/minimax-embeddings.html) \ No newline at end of file diff --git a/models/spring-ai-minimax/pom.xml b/models/spring-ai-minimax/pom.xml new file mode 100644 index 00000000000..55d20321ac7 --- /dev/null +++ b/models/spring-ai-minimax/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-minimax + jar + Spring AI MiniMax + MiniMax support + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + + + org.springframework.ai + spring-ai-core + ${project.parent.version} + + + + org.springframework.ai + spring-ai-retry + ${project.parent.version} + + + + + org.springframework + spring-context-support + + + + org.springframework.boot + spring-boot-starter-logging + + + + + org.springframework.ai + spring-ai-test + ${project.version} + test + + + + + diff --git a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatClient.java b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatClient.java new file mode 100644 index 00000000000..560afffed3d --- /dev/null +++ b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatClient.java @@ -0,0 +1,371 @@ +/* + * Copyright 2023 - 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.minimax; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.ChatClient; +import org.springframework.ai.chat.ChatResponse; +import org.springframework.ai.chat.Generation; +import org.springframework.ai.chat.StreamingChatClient; +import org.springframework.ai.chat.metadata.ChatGenerationMetadata; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.minimax.api.MiniMaxApi; +import org.springframework.ai.minimax.api.common.MiniMaxApiException; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.model.function.AbstractFunctionCallSupport; +import org.springframework.ai.model.function.FunctionCallbackContext; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.http.ResponseEntity; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MimeType; +import reactor.core.publisher.Flux; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * {@link ChatClient} and {@link StreamingChatClient} implementation for + * {@literal MiniMax} backed by {@link MiniMaxApi}. + * + * @author Geng Rong + * @see ChatClient + * @see StreamingChatClient + * @see MiniMaxApi + * @since 1.0.0 M1 + */ +public class MiniMaxChatClient extends + AbstractFunctionCallSupport> + implements ChatClient, StreamingChatClient { + + private static final Logger logger = LoggerFactory.getLogger(MiniMaxChatClient.class); + + /** + * The default options used for the chat completion requests. + */ + private MiniMaxChatOptions defaultOptions; + + /** + * The retry template used to retry the MiniMax API calls. + */ + public final RetryTemplate retryTemplate; + + /** + * Low-level access to the MiniMax API. + */ + private final MiniMaxApi miniMaxApi; + + /** + * Creates an instance of the MiniMaxChatClient. + * @param miniMaxApi The MiniMaxApi instance to be used for interacting with the + * MiniMax Chat API. + * @throws IllegalArgumentException if MiniMaxApi is null + */ + public MiniMaxChatClient(MiniMaxApi miniMaxApi) { + this(miniMaxApi, + MiniMaxChatOptions.builder().withModel(MiniMaxApi.DEFAULT_CHAT_MODEL).withTemperature(0.7f).build()); + } + + /** + * Initializes an instance of the MiniMaxChatClient. + * @param miniMaxApi The MiniMaxApi instance to be used for interacting with the + * MiniMax Chat API. + * @param options The MiniMaxChatOptions to configure the chat client. + */ + public MiniMaxChatClient(MiniMaxApi miniMaxApi, MiniMaxChatOptions options) { + this(miniMaxApi, options, null, RetryUtils.DEFAULT_RETRY_TEMPLATE); + } + + /** + * Initializes a new instance of the MiniMaxChatClient. + * @param miniMaxApi The MiniMaxApi instance to be used for interacting with the + * MiniMax Chat API. + * @param options The MiniMaxChatOptions to configure the chat client. + * @param functionCallbackContext The function callback context. + * @param retryTemplate The retry template. + */ + public MiniMaxChatClient(MiniMaxApi miniMaxApi, MiniMaxChatOptions options, + FunctionCallbackContext functionCallbackContext, RetryTemplate retryTemplate) { + super(functionCallbackContext); + Assert.notNull(miniMaxApi, "MiniMaxApi must not be null"); + Assert.notNull(options, "Options must not be null"); + Assert.notNull(retryTemplate, "RetryTemplate must not be null"); + this.miniMaxApi = miniMaxApi; + this.defaultOptions = options; + this.retryTemplate = retryTemplate; + } + + @Override + public ChatResponse call(Prompt prompt) { + + MiniMaxApi.ChatCompletionRequest request = createRequest(prompt, false); + + return this.retryTemplate.execute(ctx -> { + + ResponseEntity completionEntity = this.callWithFunctionSupport(request); + + var chatCompletion = completionEntity.getBody(); + if (chatCompletion == null) { + logger.warn("No chat completion returned for prompt: {}", prompt); + return new ChatResponse(List.of()); + } + + if (chatCompletion.baseResponse() != null && chatCompletion.baseResponse().statusCode() != 0) { + throw new MiniMaxApiException(chatCompletion.baseResponse().message()); + } + + List generations = chatCompletion.choices().stream().map(choice -> { + return new Generation(choice.message().content(), toMap(chatCompletion.id(), choice)) + .withGenerationMetadata(ChatGenerationMetadata.from(choice.finishReason().name(), null)); + }).toList(); + + return new ChatResponse(generations); + }); + } + + private Map toMap(String id, MiniMaxApi.ChatCompletion.Choice choice) { + Map map = new HashMap<>(); + + var message = choice.message(); + if (message.role() != null) { + map.put("role", message.role().name()); + } + if (choice.finishReason() != null) { + map.put("finishReason", choice.finishReason().name()); + } + map.put("id", id); + return map; + } + + @Override + public Flux stream(Prompt prompt) { + + MiniMaxApi.ChatCompletionRequest request = createRequest(prompt, true); + + return this.retryTemplate.execute(ctx -> { + + Flux completionChunks = this.miniMaxApi.chatCompletionStream(request); + + // For chunked responses, only the first chunk contains the choice role. + // The rest of the chunks with same ID share the same role. + ConcurrentHashMap roleMap = new ConcurrentHashMap<>(); + + // Convert the ChatCompletionChunk into a ChatCompletion to be able to reuse + // the function call handling logic. + return completionChunks.map(chunk -> chunkToChatCompletion(chunk)).map(chatCompletion -> { + try { + chatCompletion = handleFunctionCallOrReturn(request, ResponseEntity.of(Optional.of(chatCompletion))) + .getBody(); + + @SuppressWarnings("null") + String id = chatCompletion.id(); + + List generations = chatCompletion.choices().stream().map(choice -> { + if (choice.message().role() != null) { + roleMap.putIfAbsent(id, choice.message().role().name()); + } + String finish = (choice.finishReason() != null ? choice.finishReason().name() : ""); + var generation = new Generation(choice.message().content(), + Map.of("id", id, "role", roleMap.get(id), "finishReason", finish)); + if (choice.finishReason() != null) { + generation = generation.withGenerationMetadata( + ChatGenerationMetadata.from(choice.finishReason().name(), null)); + } + return generation; + }).toList(); + + return new ChatResponse(generations); + } + catch (Exception e) { + logger.error("Error processing chat completion", e); + return new ChatResponse(List.of()); + } + + }); + }); + } + + /** + * Convert the ChatCompletionChunk into a ChatCompletion. The Usage is set to null. + * @param chunk the ChatCompletionChunk to convert + * @return the ChatCompletion + */ + private MiniMaxApi.ChatCompletion chunkToChatCompletion(MiniMaxApi.ChatCompletionChunk chunk) { + List choices = chunk.choices().stream().map(cc -> { + MiniMaxApi.ChatCompletionMessage delta = cc.delta(); + if (delta == null) { + delta = new MiniMaxApi.ChatCompletionMessage("", MiniMaxApi.ChatCompletionMessage.Role.ASSISTANT); + } + return new MiniMaxApi.ChatCompletion.Choice(cc.finishReason(), cc.index(), delta, cc.logprobs()); + }).toList(); + + return new MiniMaxApi.ChatCompletion(chunk.id(), choices, chunk.created(), chunk.model(), + chunk.systemFingerprint(), "chat.completion", null, null); + } + + /** + * Accessible for testing. + */ + MiniMaxApi.ChatCompletionRequest createRequest(Prompt prompt, boolean stream) { + + Set functionsForThisRequest = new HashSet<>(); + + List chatCompletionMessages = prompt.getInstructions() + .stream() + .map(m -> new MiniMaxApi.ChatCompletionMessage(m.getContent(), + MiniMaxApi.ChatCompletionMessage.Role.valueOf(m.getMessageType().name()))) + .toList(); + + MiniMaxApi.ChatCompletionRequest request = new MiniMaxApi.ChatCompletionRequest(chatCompletionMessages, stream); + + if (prompt.getOptions() != null) { + if (prompt.getOptions() instanceof ChatOptions runtimeOptions) { + MiniMaxChatOptions updatedRuntimeOptions = ModelOptionsUtils.copyToTarget(runtimeOptions, + ChatOptions.class, MiniMaxChatOptions.class); + + Set promptEnabledFunctions = this.handleFunctionCallbackConfigurations(updatedRuntimeOptions, + IS_RUNTIME_CALL); + functionsForThisRequest.addAll(promptEnabledFunctions); + + request = ModelOptionsUtils.merge(updatedRuntimeOptions, request, + MiniMaxApi.ChatCompletionRequest.class); + } + else { + throw new IllegalArgumentException("Prompt options are not of type ChatOptions: " + + prompt.getOptions().getClass().getSimpleName()); + } + } + + if (this.defaultOptions != null) { + + Set defaultEnabledFunctions = this.handleFunctionCallbackConfigurations(this.defaultOptions, + !IS_RUNTIME_CALL); + + functionsForThisRequest.addAll(defaultEnabledFunctions); + + request = ModelOptionsUtils.merge(request, this.defaultOptions, MiniMaxApi.ChatCompletionRequest.class); + } + + // Add the enabled functions definitions to the request's tools parameter. + if (!CollectionUtils.isEmpty(functionsForThisRequest)) { + + request = ModelOptionsUtils.merge( + MiniMaxChatOptions.builder().withTools(this.getFunctionTools(functionsForThisRequest)).build(), + request, MiniMaxApi.ChatCompletionRequest.class); + } + + return request; + } + + private String fromMediaData(MimeType mimeType, Object mediaContentData) { + if (mediaContentData instanceof byte[] bytes) { + // Assume the bytes are an image. So, convert the bytes to a base64 encoded + // following the prefix pattern. + return String.format("data:%s;base64,%s", mimeType.toString(), Base64.getEncoder().encodeToString(bytes)); + } + else if (mediaContentData instanceof String text) { + // Assume the text is a URLs or a base64 encoded image prefixed by the user. + return text; + } + else { + throw new IllegalArgumentException( + "Unsupported media data type: " + mediaContentData.getClass().getSimpleName()); + } + } + + private List getFunctionTools(Set functionNames) { + return this.resolveFunctionCallbacks(functionNames).stream().map(functionCallback -> { + var function = new MiniMaxApi.FunctionTool.Function(functionCallback.getDescription(), + functionCallback.getName(), functionCallback.getInputTypeSchema()); + return new MiniMaxApi.FunctionTool(function); + }).toList(); + } + + @Override + protected MiniMaxApi.ChatCompletionRequest doCreateToolResponseRequest( + MiniMaxApi.ChatCompletionRequest previousRequest, MiniMaxApi.ChatCompletionMessage responseMessage, + List conversationHistory) { + + // Every tool-call item requires a separate function call and a response (TOOL) + // message. + for (MiniMaxApi.ChatCompletionMessage.ToolCall toolCall : responseMessage.toolCalls()) { + + var functionName = toolCall.function().name(); + String functionArguments = toolCall.function().arguments(); + + if (!this.functionCallbackRegister.containsKey(functionName)) { + throw new IllegalStateException("No function callback found for function name: " + functionName); + } + + String functionResponse = this.functionCallbackRegister.get(functionName).call(functionArguments); + + // Add the function response to the conversation. + conversationHistory.add(new MiniMaxApi.ChatCompletionMessage(functionResponse, + MiniMaxApi.ChatCompletionMessage.Role.TOOL, functionName, toolCall.id(), null)); + } + + // Recursively call chatCompletionWithTools until the model doesn't call a + // functions anymore. + MiniMaxApi.ChatCompletionRequest newRequest = new MiniMaxApi.ChatCompletionRequest(conversationHistory, false); + newRequest = ModelOptionsUtils.merge(newRequest, previousRequest, MiniMaxApi.ChatCompletionRequest.class); + + return newRequest; + } + + @Override + protected List doGetUserMessages(MiniMaxApi.ChatCompletionRequest request) { + return request.messages(); + } + + @Override + protected MiniMaxApi.ChatCompletionMessage doGetToolResponseMessage( + ResponseEntity chatCompletion) { + return chatCompletion.getBody().choices().iterator().next().message(); + } + + @Override + protected ResponseEntity doChatCompletion(MiniMaxApi.ChatCompletionRequest request) { + return this.miniMaxApi.chatCompletionEntity(request); + } + + @Override + protected Flux> doChatCompletionStream( + MiniMaxApi.ChatCompletionRequest request) { + throw new RuntimeException("Streaming Function calling is not supported"); + } + + @Override + protected boolean isToolFunctionCall(ResponseEntity chatCompletion) { + var body = chatCompletion.getBody(); + if (body == null) { + return false; + } + + var choices = body.choices(); + if (CollectionUtils.isEmpty(choices)) { + return false; + } + + var choice = choices.get(0); + var message = choice.message(); + return message != null && !CollectionUtils.isEmpty(choice.message().toolCalls()) + && choice.finishReason() == MiniMaxApi.ChatCompletionFinishReason.TOOL_CALLS; + } + +} diff --git a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatOptions.java b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatOptions.java new file mode 100644 index 00000000000..bc2b29cda1b --- /dev/null +++ b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatOptions.java @@ -0,0 +1,467 @@ +/* + * Copyright 2023 - 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.minimax; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.minimax.api.MiniMaxApi; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.model.function.FunctionCallingOptions; +import org.springframework.boot.context.properties.NestedConfigurationProperty; +import org.springframework.util.Assert; + +import java.util.*; + +/** + * MiniMaxChatOptions represents the options for performing chat completion using the + * MiniMax API. It provides methods to set and retrieve various options like model, + * frequency penalty, max tokens, etc. + * + * @see FunctionCallingOptions + * @see ChatOptions + * @author Geng Rong + * @since 1.0.0 M1 + */ +@JsonInclude(Include.NON_NULL) +public class MiniMaxChatOptions implements FunctionCallingOptions, ChatOptions { + + // @formatter:off + /** + * ID of the model to use. + */ + private @JsonProperty("model") String model; + /** + * Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing + * frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim. + */ + private @JsonProperty("frequency_penalty") Float frequencyPenalty; + /** + * The maximum number of tokens to generate in the chat completion. The total length of input + * tokens and generated tokens is limited by the model's context length. + */ + private @JsonProperty("max_tokens") Integer maxTokens; + /** + * How many chat completion choices to generate for each input message. Note that you will be charged based + * on the number of generated tokens across all of the choices. Keep n as 1 to minimize costs. + */ + private @JsonProperty("n") Integer n; + /** + * Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they + * appear in the text so far, increasing the model's likelihood to talk about new topics. + */ + private @JsonProperty("presence_penalty") Float presencePenalty; + /** + * An object specifying the format that the model must output. Setting to { "type": + * "json_object" } enables JSON mode, which guarantees the message the model generates is valid JSON. + */ + private @JsonProperty("response_format") MiniMaxApi.ChatCompletionRequest.ResponseFormat responseFormat; + /** + * This feature is in Beta. If specified, our system will make a best effort to sample + * deterministically, such that repeated requests with the same seed and parameters should return the same result. + * Determinism is not guaranteed, and you should refer to the system_fingerprint response parameter to monitor + * changes in the backend. + */ + private @JsonProperty("seed") Integer seed; + /** + * Up to 4 sequences where the API will stop generating further tokens. + */ + @NestedConfigurationProperty + private @JsonProperty("stop") List stop; + /** + * What sampling temperature to use, between 0 and 1. Higher values like 0.8 will make the output + * more random, while lower values like 0.2 will make it more focused and deterministic. We generally recommend + * altering this or top_p but not both. + */ + private @JsonProperty("temperature") Float temperature; + /** + * An alternative to sampling with temperature, called nucleus sampling, where the model considers the + * results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% + * probability mass are considered. We generally recommend altering this or temperature but not both. + */ + private @JsonProperty("top_p") Float topP; + /** + * A list of tools the model may call. Currently, only functions are supported as a tool. Use this to + * provide a list of functions the model may generate JSON inputs for. + */ + @NestedConfigurationProperty + private @JsonProperty("tools") List tools; + /** + * Controls which (if any) function is called by the model. none means the model will not call a + * function and instead generates a message. auto means the model can pick between generating a message or calling a + * function. Specifying a particular function via {"type: "function", "function": {"name": "my_function"}} forces + * the model to call that function. none is the default when no functions are present. auto is the default if + * functions are present. Use the {@link MiniMaxApi.ChatCompletionRequest.ToolChoiceBuilder} to create a tool choice object. + */ + private @JsonProperty("tool_choice") String toolChoice; + + /** + * MiniMax Tool Function Callbacks to register with the ChatClient. + * For Prompt Options the functionCallbacks are automatically enabled for the duration of the prompt execution. + * For Default Options the functionCallbacks are registered but disabled by default. Use the enableFunctions to set the functions + * from the registry to be used by the ChatClient chat completion requests. + */ + @NestedConfigurationProperty + @JsonIgnore + private List functionCallbacks = new ArrayList<>(); + + /** + * List of functions, identified by their names, to configure for function calling in + * the chat completion requests. + * Functions with those names must exist in the functionCallbacks registry. + * The {@link #functionCallbacks} from the PromptOptions are automatically enabled for the duration of the prompt execution. + * + * Note that function enabled with the default options are enabled for all chat completion requests. This could impact the token count and the billing. + * If the functions is set in a prompt options, then the enabled functions are only active for the duration of this prompt execution. + */ + @NestedConfigurationProperty + @JsonIgnore + private Set functions = new HashSet<>(); + // @formatter:on + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + protected MiniMaxChatOptions options; + + public Builder() { + this.options = new MiniMaxChatOptions(); + } + + public Builder(MiniMaxChatOptions options) { + this.options = options; + } + + public Builder withModel(String model) { + this.options.model = model; + return this; + } + + public Builder withFrequencyPenalty(Float frequencyPenalty) { + this.options.frequencyPenalty = frequencyPenalty; + return this; + } + + public Builder withMaxTokens(Integer maxTokens) { + this.options.maxTokens = maxTokens; + return this; + } + + public Builder withN(Integer n) { + this.options.n = n; + return this; + } + + public Builder withPresencePenalty(Float presencePenalty) { + this.options.presencePenalty = presencePenalty; + return this; + } + + public Builder withResponseFormat(MiniMaxApi.ChatCompletionRequest.ResponseFormat responseFormat) { + this.options.responseFormat = responseFormat; + return this; + } + + public Builder withSeed(Integer seed) { + this.options.seed = seed; + return this; + } + + public Builder withStop(List stop) { + this.options.stop = stop; + return this; + } + + public Builder withTemperature(Float temperature) { + this.options.temperature = temperature; + return this; + } + + public Builder withTopP(Float topP) { + this.options.topP = topP; + return this; + } + + public Builder withTools(List tools) { + this.options.tools = tools; + return this; + } + + public Builder withToolChoice(String toolChoice) { + this.options.toolChoice = toolChoice; + return this; + } + + public Builder withFunctionCallbacks(List functionCallbacks) { + this.options.functionCallbacks = functionCallbacks; + return this; + } + + public Builder withFunctions(Set functionNames) { + Assert.notNull(functionNames, "Function names must not be null"); + this.options.functions = functionNames; + return this; + } + + public Builder withFunction(String functionName) { + Assert.hasText(functionName, "Function name must not be empty"); + this.options.functions.add(functionName); + return this; + } + + public MiniMaxChatOptions build() { + return this.options; + } + + } + + public String getModel() { + return this.model; + } + + public void setModel(String model) { + this.model = model; + } + + public Float getFrequencyPenalty() { + return this.frequencyPenalty; + } + + public void setFrequencyPenalty(Float frequencyPenalty) { + this.frequencyPenalty = frequencyPenalty; + } + + public Integer getMaxTokens() { + return this.maxTokens; + } + + public void setMaxTokens(Integer maxTokens) { + this.maxTokens = maxTokens; + } + + public Integer getN() { + return this.n; + } + + public void setN(Integer n) { + this.n = n; + } + + public Float getPresencePenalty() { + return this.presencePenalty; + } + + public void setPresencePenalty(Float presencePenalty) { + this.presencePenalty = presencePenalty; + } + + public MiniMaxApi.ChatCompletionRequest.ResponseFormat getResponseFormat() { + return this.responseFormat; + } + + public void setResponseFormat(MiniMaxApi.ChatCompletionRequest.ResponseFormat responseFormat) { + this.responseFormat = responseFormat; + } + + public Integer getSeed() { + return this.seed; + } + + public void setSeed(Integer seed) { + this.seed = seed; + } + + public List getStop() { + return this.stop; + } + + public void setStop(List stop) { + this.stop = stop; + } + + @Override + public Float getTemperature() { + return this.temperature; + } + + public void setTemperature(Float temperature) { + this.temperature = temperature; + } + + @Override + public Float getTopP() { + return this.topP; + } + + public void setTopP(Float topP) { + this.topP = topP; + } + + public List getTools() { + return this.tools; + } + + public void setTools(List tools) { + this.tools = tools; + } + + public String getToolChoice() { + return this.toolChoice; + } + + public void setToolChoice(String toolChoice) { + this.toolChoice = toolChoice; + } + + @Override + public List getFunctionCallbacks() { + return this.functionCallbacks; + } + + @Override + public void setFunctionCallbacks(List functionCallbacks) { + this.functionCallbacks = functionCallbacks; + } + + @Override + public Set getFunctions() { + return functions; + } + + public void setFunctions(Set functionNames) { + this.functions = functionNames; + } + + @Override + @JsonIgnore + public Integer getTopK() { + throw new UnsupportedOperationException("Unimplemented method 'getTopK'"); + } + + @JsonIgnore + public void setTopK(Integer topK) { + throw new UnsupportedOperationException("Unimplemented method 'setTopK'"); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((model == null) ? 0 : model.hashCode()); + result = prime * result + ((frequencyPenalty == null) ? 0 : frequencyPenalty.hashCode()); + result = prime * result + ((maxTokens == null) ? 0 : maxTokens.hashCode()); + result = prime * result + ((n == null) ? 0 : n.hashCode()); + result = prime * result + ((presencePenalty == null) ? 0 : presencePenalty.hashCode()); + result = prime * result + ((responseFormat == null) ? 0 : responseFormat.hashCode()); + result = prime * result + ((seed == null) ? 0 : seed.hashCode()); + result = prime * result + ((stop == null) ? 0 : stop.hashCode()); + result = prime * result + ((temperature == null) ? 0 : temperature.hashCode()); + result = prime * result + ((topP == null) ? 0 : topP.hashCode()); + result = prime * result + ((tools == null) ? 0 : tools.hashCode()); + result = prime * result + ((toolChoice == null) ? 0 : toolChoice.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + MiniMaxChatOptions other = (MiniMaxChatOptions) obj; + if (this.model == null) { + if (other.model != null) + return false; + } + else if (!model.equals(other.model)) + return false; + if (this.frequencyPenalty == null) { + if (other.frequencyPenalty != null) + return false; + } + else if (!this.frequencyPenalty.equals(other.frequencyPenalty)) + return false; + if (this.maxTokens == null) { + if (other.maxTokens != null) + return false; + } + else if (!this.maxTokens.equals(other.maxTokens)) + return false; + if (this.n == null) { + if (other.n != null) + return false; + } + else if (!this.n.equals(other.n)) + return false; + if (this.presencePenalty == null) { + if (other.presencePenalty != null) + return false; + } + else if (!this.presencePenalty.equals(other.presencePenalty)) + return false; + if (this.responseFormat == null) { + if (other.responseFormat != null) + return false; + } + else if (!this.responseFormat.equals(other.responseFormat)) + return false; + if (this.seed == null) { + if (other.seed != null) + return false; + } + else if (!this.seed.equals(other.seed)) + return false; + if (this.stop == null) { + if (other.stop != null) + return false; + } + else if (!stop.equals(other.stop)) + return false; + if (this.temperature == null) { + if (other.temperature != null) + return false; + } + else if (!this.temperature.equals(other.temperature)) + return false; + if (this.topP == null) { + if (other.topP != null) + return false; + } + else if (!topP.equals(other.topP)) + return false; + if (this.tools == null) { + if (other.tools != null) + return false; + } + else if (!tools.equals(other.tools)) + return false; + if (this.toolChoice == null) { + if (other.toolChoice != null) + return false; + } + else if (!toolChoice.equals(other.toolChoice)) + return false; + return true; + } + +} diff --git a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxEmbeddingClient.java b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxEmbeddingClient.java new file mode 100644 index 00000000000..74dc5e4e79a --- /dev/null +++ b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxEmbeddingClient.java @@ -0,0 +1,146 @@ +/* + * Copyright 2023 - 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.minimax; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.document.Document; +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.embedding.*; +import org.springframework.ai.minimax.api.MiniMaxApi; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; + +import java.util.ArrayList; +import java.util.List; + +/** + * MiniMax Embedding Client implementation. + * + * @author Geng Rong + * @since 1.0.0 M1 + */ +public class MiniMaxEmbeddingClient extends AbstractEmbeddingClient { + + private static final Logger logger = LoggerFactory.getLogger(MiniMaxEmbeddingClient.class); + + private final MiniMaxEmbeddingOptions defaultOptions; + + private final RetryTemplate retryTemplate; + + private final MiniMaxApi miniMaxApi; + + private final MetadataMode metadataMode; + + /** + * Constructor for the MiniMaxEmbeddingClient class. + * @param miniMaxApi The MiniMaxApi instance to use for making API requests. + */ + public MiniMaxEmbeddingClient(MiniMaxApi miniMaxApi) { + this(miniMaxApi, MetadataMode.EMBED); + } + + /** + * Initializes a new instance of the MiniMaxEmbeddingClient class. + * @param miniMaxApi The MiniMaxApi instance to use for making API requests. + * @param metadataMode The mode for generating metadata. + */ + public MiniMaxEmbeddingClient(MiniMaxApi miniMaxApi, MetadataMode metadataMode) { + this(miniMaxApi, metadataMode, + MiniMaxEmbeddingOptions.builder().withModel(MiniMaxApi.DEFAULT_EMBEDDING_MODEL).build(), + RetryUtils.DEFAULT_RETRY_TEMPLATE); + } + + /** + * Initializes a new instance of the MiniMaxEmbeddingClient class. + * @param miniMaxApi The MiniMaxApi instance to use for making API requests. + * @param metadataMode The mode for generating metadata. + * @param miniMaxEmbeddingOptions The options for MiniMax embedding. + */ + public MiniMaxEmbeddingClient(MiniMaxApi miniMaxApi, MetadataMode metadataMode, + MiniMaxEmbeddingOptions miniMaxEmbeddingOptions) { + this(miniMaxApi, metadataMode, miniMaxEmbeddingOptions, RetryUtils.DEFAULT_RETRY_TEMPLATE); + } + + /** + * Initializes a new instance of the MiniMaxEmbeddingClient class. + * @param miniMaxApi - The MiniMaxApi instance to use for making API requests. + * @param metadataMode - The mode for generating metadata. + * @param options - The options for MiniMax embedding. + * @param retryTemplate - The RetryTemplate for retrying failed API requests. + */ + public MiniMaxEmbeddingClient(MiniMaxApi miniMaxApi, MetadataMode metadataMode, MiniMaxEmbeddingOptions options, + RetryTemplate retryTemplate) { + Assert.notNull(miniMaxApi, "MiniMaxApi must not be null"); + Assert.notNull(metadataMode, "metadataMode must not be null"); + Assert.notNull(options, "options must not be null"); + Assert.notNull(retryTemplate, "retryTemplate must not be null"); + + this.miniMaxApi = miniMaxApi; + this.metadataMode = metadataMode; + this.defaultOptions = options; + this.retryTemplate = retryTemplate; + } + + @Override + public List embed(Document document) { + Assert.notNull(document, "Document must not be null"); + return this.embed(document.getFormattedContent(this.metadataMode)); + } + + @SuppressWarnings("unchecked") + @Override + public EmbeddingResponse call(EmbeddingRequest request) { + + return this.retryTemplate.execute(ctx -> { + + MiniMaxApi.EmbeddingRequest apiRequest = (this.defaultOptions != null) + ? new MiniMaxApi.EmbeddingRequest(request.getInstructions(), this.defaultOptions.getModel()) + : new MiniMaxApi.EmbeddingRequest(request.getInstructions(), MiniMaxApi.DEFAULT_EMBEDDING_MODEL); + + if (request.getOptions() != null && !EmbeddingOptions.EMPTY.equals(request.getOptions())) { + apiRequest = ModelOptionsUtils.merge(request.getOptions(), apiRequest, + MiniMaxApi.EmbeddingRequest.class); + } + + MiniMaxApi.EmbeddingList apiEmbeddingResponse = this.miniMaxApi.embeddings(apiRequest).getBody(); + + if (apiEmbeddingResponse == null) { + logger.warn("No embeddings returned for request: {}", request); + return new EmbeddingResponse(List.of()); + } + + var metadata = generateResponseMetadata(apiEmbeddingResponse.model(), apiEmbeddingResponse.totalTokens()); + + List embeddings = new ArrayList<>(); + for (int i = 0; i < apiEmbeddingResponse.vectors().size(); i++) { + List vector = apiEmbeddingResponse.vectors().get(i); + embeddings.add(new Embedding(vector, i)); + } + return new EmbeddingResponse(embeddings, metadata); + }); + } + + private EmbeddingResponseMetadata generateResponseMetadata(String model, Integer totalTokens) { + EmbeddingResponseMetadata metadata = new EmbeddingResponseMetadata(); + metadata.put("model", model); + metadata.put("total-tokens", totalTokens); + return metadata; + } + +} diff --git a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxEmbeddingOptions.java b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxEmbeddingOptions.java new file mode 100644 index 00000000000..37a043fc315 --- /dev/null +++ b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxEmbeddingOptions.java @@ -0,0 +1,70 @@ +/* + * Copyright 2023 - 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.minimax; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.ai.embedding.EmbeddingOptions; + +/** + * This class represents the options for MiniMax embedding. + * + * @author Geng Rong + * @since 1.0.0 M1 + */ +@JsonInclude(Include.NON_NULL) +public class MiniMaxEmbeddingOptions implements EmbeddingOptions { + + // @formatter:off + /** + * ID of the model to use. + */ + private @JsonProperty("model") String model; + // @formatter:on + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + protected MiniMaxEmbeddingOptions options; + + public Builder() { + this.options = new MiniMaxEmbeddingOptions(); + } + + public Builder withModel(String model) { + this.options.setModel(model); + return this; + } + + public MiniMaxEmbeddingOptions build() { + return this.options; + } + + } + + public String getModel() { + return this.model; + } + + public void setModel(String model) { + this.model = model; + } + +} diff --git a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/aot/MiniMaxRuntimeHints.java b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/aot/MiniMaxRuntimeHints.java new file mode 100644 index 00000000000..129eb2d76c7 --- /dev/null +++ b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/aot/MiniMaxRuntimeHints.java @@ -0,0 +1,43 @@ +/* + * Copyright 2023 - 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.minimax.aot; + +import org.springframework.ai.minimax.api.MiniMaxApi; +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; + +import static org.springframework.ai.aot.AiRuntimeHints.findJsonAnnotatedClassesInPackage; + +/** + * The MiniMaxRuntimeHints class is responsible for registering runtime hints for MiniMax + * API classes. + * + * @author Geng Rong + * @since 1.0.0 M1 + */ +public class MiniMaxRuntimeHints implements RuntimeHintsRegistrar { + + @Override + public void registerHints(@NonNull RuntimeHints hints, @Nullable ClassLoader classLoader) { + var mcs = MemberCategory.values(); + for (var tr : findJsonAnnotatedClassesInPackage(MiniMaxApi.class)) + hints.reflection().registerType(tr, mcs); + } + +} diff --git a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/ApiUtils.java b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/ApiUtils.java new file mode 100644 index 00000000000..101b26e036e --- /dev/null +++ b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/ApiUtils.java @@ -0,0 +1,41 @@ +/* + * Copyright 2023 - 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.minimax.api; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +import java.util.function.Consumer; + +/** + * The ApiUtils class provides utility methods for working with API requests and + * responses. + * + * @author Geng Rong + * @since 1.0.0 M1 + */ +public class ApiUtils { + + public static final String DEFAULT_BASE_URL = "https://api.minimax.chat"; + + public static Consumer getJsonContentHeaders(String apiKey) { + return (headers) -> { + headers.setBearerAuth(apiKey); + headers.setContentType(MediaType.APPLICATION_JSON); + }; + }; + +} diff --git a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java new file mode 100644 index 00000000000..f86206e247d --- /dev/null +++ b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java @@ -0,0 +1,891 @@ +/* + * Copyright 2023 - 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.minimax.api; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonValue; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.boot.context.properties.bind.ConstructorBinding; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Predicate; + +// @formatter:off +/** + * Single class implementation of the MiniMax Chat Completion API: https://www.minimaxi.com/document/guides/chat-model/V2 and + * MiniMax Embedding API: https://www.minimaxi.com/document/guides/Embeddings. + * + * @author Geng Rong + * @since 1.0.0 M1 + */ +public class MiniMaxApi { + + public static final String DEFAULT_CHAT_MODEL = ChatModel.ABAB_5_5_Chat.getValue(); + public static final String DEFAULT_EMBEDDING_MODEL = EmbeddingModel.Embo_01.getValue(); + private static final Predicate SSE_DONE_PREDICATE = "[DONE]"::equals; + + private final RestClient restClient; + + private final WebClient webClient; + + /** + * Create a new chat completion api with default base URL. + * + * @param miniMaxToken MiniMax apiKey. + */ + public MiniMaxApi(String miniMaxToken) { + this(ApiUtils.DEFAULT_BASE_URL, miniMaxToken); + } + + /** + * Create a new chat completion api. + * + * @param baseUrl api base URL. + * @param miniMaxToken MiniMax apiKey. + */ + public MiniMaxApi(String baseUrl, String miniMaxToken) { + this(baseUrl, miniMaxToken, RestClient.builder()); + } + + /** + * Create a new chat completion api. + * + * @param baseUrl api base URL. + * @param miniMaxToken MiniMax apiKey. + * @param restClientBuilder RestClient builder. + */ + public MiniMaxApi(String baseUrl, String miniMaxToken, RestClient.Builder restClientBuilder) { + this(baseUrl, miniMaxToken, restClientBuilder, RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER); + } + + /** + * Create a new chat completion api. + * + * @param baseUrl api base URL. + * @param miniMaxToken MiniMax apiKey. + * @param restClientBuilder RestClient builder. + * @param responseErrorHandler Response error handler. + */ + public MiniMaxApi(String baseUrl, String miniMaxToken, RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { + + this.restClient = restClientBuilder + .baseUrl(baseUrl) + .defaultHeaders(ApiUtils.getJsonContentHeaders(miniMaxToken)) + .defaultStatusHandler(responseErrorHandler) + .build(); + + this.webClient = WebClient.builder() + .baseUrl(baseUrl) + .defaultHeaders(ApiUtils.getJsonContentHeaders(miniMaxToken)) + .build(); + } + + /** + * MiniMax Chat Completion Models: + * MiniMax Model. + */ + public enum ChatModel { + ABAB_6_Chat("abab6-chat"), + ABAB_5_5_Chat("abab5.5-chat"), + ABAB_5_5_S_Chat("abab5.5s-chat"); + + public final String value; + + ChatModel(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + /** + * Represents a tool the model may call. Currently, only functions are supported as a tool. + * + * @param type The type of the tool. Currently, only 'function' is supported. + * @param function The function definition. + */ + @JsonInclude(Include.NON_NULL) + public record FunctionTool( + @JsonProperty("type") Type type, + @JsonProperty("function") Function function) { + + /** + * Create a tool of type 'function' and the given function definition. + * @param function function definition. + */ + @ConstructorBinding + public FunctionTool(Function function) { + this(Type.FUNCTION, function); + } + + /** + * Create a tool of type 'function' and the given function definition. + */ + public enum Type { + /** + * Function tool type. + */ + @JsonProperty("function") FUNCTION + } + + /** + * Function definition. + * + * @param description A description of what the function does, used by the model to choose when and how to call + * the function. + * @param name The name of the function to be called. Must be a-z, A-Z, 0-9, or contain underscores and dashes, + * with a maximum length of 64. + * @param parameters The parameters the functions accepts, described as a JSON Schema object. To describe a + * function that accepts no parameters, provide the value {"type": "object", "properties": {}}. + */ + public record Function( + @JsonProperty("description") String description, + @JsonProperty("name") String name, + @JsonProperty("parameters") String parameters) { + + /** + * Create tool function definition. + * + * @param description tool function description. + * @param name tool function name. + * @param parameters tool function schema. + */ + @ConstructorBinding + public Function(String description, String name, Map parameters) { + this(description, name, ModelOptionsUtils.toJsonString(parameters)); + } + } + } + + /** + * Creates a model response for the given chat conversation. + * + * @param messages A list of messages comprising the conversation so far. + * @param model ID of the model to use. + * @param frequencyPenalty Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing + * frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim. + * @param maxTokens The maximum number of tokens to generate in the chat completion. The total length of input + * tokens and generated tokens is limited by the model's context length. + * @param n How many chat completion choices to generate for each input message. Note that you will be charged based + * on the number of generated tokens across all of the choices. Keep n as 1 to minimize costs. + * @param presencePenalty Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they + * appear in the text so far, increasing the model's likelihood to talk about new topics. + * @param responseFormat An object specifying the format that the model must output. Setting to { "type": + * "json_object" } enables JSON mode, which guarantees the message the model generates is valid JSON. + * @param seed This feature is in Beta. If specified, our system will make a best effort to sample + * deterministically, such that repeated requests with the same seed and parameters should return the same result. + * Determinism is not guaranteed, and you should refer to the system_fingerprint response parameter to monitor + * changes in the backend. + * @param stop Up to 4 sequences where the API will stop generating further tokens. + * @param stream If set, partial message deltas will be sent.Tokens will be sent as data-only server-sent events as + * they become available, with the stream terminated by a data: [DONE] message. + * @param temperature What sampling temperature to use, between 0 and 1. Higher values like 0.8 will make the output + * more random, while lower values like 0.2 will make it more focused and deterministic. We generally recommend + * altering this or top_p but not both. + * @param topP An alternative to sampling with temperature, called nucleus sampling, where the model considers the + * results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% + * probability mass are considered. We generally recommend altering this or temperature but not both. + * @param tools A list of tools the model may call. Currently, only functions are supported as a tool. Use this to + * provide a list of functions the model may generate JSON inputs for. + * @param toolChoice Controls which (if any) function is called by the model. none means the model will not call a + * function and instead generates a message. auto means the model can pick between generating a message or calling a + * function. Specifying a particular function via {"type: "function", "function": {"name": "my_function"}} forces + * the model to call that function. none is the default when no functions are present. auto is the default if + * functions are present. Use the {@link ToolChoiceBuilder} to create the tool choice value. + */ + @JsonInclude(Include.NON_NULL) + public record ChatCompletionRequest ( + @JsonProperty("messages") List messages, + @JsonProperty("model") String model, + @JsonProperty("frequency_penalty") Float frequencyPenalty, + @JsonProperty("max_tokens") Integer maxTokens, + @JsonProperty("n") Integer n, + @JsonProperty("presence_penalty") Float presencePenalty, + @JsonProperty("response_format") ResponseFormat responseFormat, + @JsonProperty("seed") Integer seed, + @JsonProperty("stop") List stop, + @JsonProperty("stream") Boolean stream, + @JsonProperty("temperature") Float temperature, + @JsonProperty("top_p") Float topP, + @JsonProperty("tools") List tools, + @JsonProperty("tool_choice") Object toolChoice) { + + /** + * Shortcut constructor for a chat completion request with the given messages and model. + * + * @param messages A list of messages comprising the conversation so far. + * @param model ID of the model to use. + * @param temperature What sampling temperature to use, between 0 and 1. + */ + public ChatCompletionRequest(List messages, String model, Float temperature) { + this(messages, model, null, null, null, null, + null, null, null, false, temperature, null, + null, null); + } + + /** + * Shortcut constructor for a chat completion request with the given messages, model and control for streaming. + * + * @param messages A list of messages comprising the conversation so far. + * @param model ID of the model to use. + * @param temperature What sampling temperature to use, between 0 and 1. + * @param stream If set, partial message deltas will be sent.Tokens will be sent as data-only server-sent events + * as they become available, with the stream terminated by a data: [DONE] message. + */ + public ChatCompletionRequest(List messages, String model, Float temperature, boolean stream) { + this(messages, model, null, null, null, null, + null, null, null, stream, temperature, null, + null, null); + } + + /** + * Shortcut constructor for a chat completion request with the given messages, model, tools and tool choice. + * Streaming is set to false, temperature to 0.8 and all other parameters are null. + * + * @param messages A list of messages comprising the conversation so far. + * @param model ID of the model to use. + * @param tools A list of tools the model may call. Currently, only functions are supported as a tool. + * @param toolChoice Controls which (if any) function is called by the model. + */ + public ChatCompletionRequest(List messages, String model, + List tools, Object toolChoice) { + this(messages, model, null, null, null, null, + null, null, null, false, 0.8f, null, + tools, toolChoice); + } + + /** + * Shortcut constructor for a chat completion request with the given messages, model, tools and tool choice. + * Streaming is set to false, temperature to 0.8 and all other parameters are null. + * + * @param messages A list of messages comprising the conversation so far. + * @param stream If set, partial message deltas will be sent.Tokens will be sent as data-only server-sent events + * as they become available, with the stream terminated by a data: [DONE] message. + */ + public ChatCompletionRequest(List messages, Boolean stream) { + this(messages, null, null, null, null, null, + null, null, null, stream, null, null, + null, null); + } + + /** + * Helper factory that creates a tool_choice of type 'none', 'auto' or selected function by name. + */ + public static class ToolChoiceBuilder { + /** + * Model can pick between generating a message or calling a function. + */ + public static final String AUTO = "auto"; + /** + * Model will not call a function and instead generates a message + */ + public static final String NONE = "none"; + + /** + * Specifying a particular function forces the model to call that function. + */ + public static Object FUNCTION(String functionName) { + return Map.of("type", "function", "function", Map.of("name", functionName)); + } + } + + /** + * An object specifying the format that the model must output. + * @param type Must be one of 'text' or 'json_object'. + */ + @JsonInclude(Include.NON_NULL) + public record ResponseFormat( + @JsonProperty("type") String type) { + } + } + + /** + * Message comprising the conversation. + * + * @param rawContent The contents of the message. Can be either a {@link MediaContent} or a {@link String}. + * The response message content is always a {@link String}. + * @param role The role of the messages author. Could be one of the {@link Role} types. + * @param name An optional name for the participant. Provides the model information to differentiate between + * participants of the same role. In case of Function calling, the name is the function name that the message is + * responding to. + * @param toolCallId Tool call that this message is responding to. Only applicable for the {@link Role#TOOL} role + * and null otherwise. + * @param toolCalls The tool calls generated by the model, such as function calls. Applicable only for + * {@link Role#ASSISTANT} role and null otherwise. + */ + @JsonInclude(Include.NON_NULL) + public record ChatCompletionMessage( + @JsonProperty("content") Object rawContent, + @JsonProperty("role") Role role, + @JsonProperty("name") String name, + @JsonProperty("tool_call_id") String toolCallId, + @JsonProperty("tool_calls") List toolCalls) { + + /** + * Get message content as String. + */ + public String content() { + if (this.rawContent == null) { + return null; + } + if (this.rawContent instanceof String text) { + return text; + } + throw new IllegalStateException("The content is not a string!"); + } + + /** + * Create a chat completion message with the given content and role. All other fields are null. + * @param content The contents of the message. + * @param role The role of the author of this message. + */ + public ChatCompletionMessage(Object content, Role role) { + this(content, role, null, null, null); + } + + /** + * The role of the author of this message. + */ + public enum Role { + /** + * System message. + */ + @JsonProperty("system") SYSTEM, + /** + * User message. + */ + @JsonProperty("user") USER, + /** + * Assistant message. + */ + @JsonProperty("assistant") ASSISTANT, + /** + * Tool message. + */ + @JsonProperty("tool") TOOL + } + + /** + * An array of content parts with a defined type. + * Each MediaContent can be of either "text" or "image_url" type. Not both. + * + * @param type Content type, each can be of type text or image_url. + * @param text The text content of the message. + * @param imageUrl The image content of the message. You can pass multiple + * images by adding multiple image_url content parts. Image input is only + * supported when using the glm-4v model. + */ + @JsonInclude(Include.NON_NULL) + public record MediaContent( + @JsonProperty("type") String type, + @JsonProperty("text") String text, + @JsonProperty("image_url") ImageUrl imageUrl) { + + /** + * @param url Either a URL of the image or the base64 encoded image data. + * The base64 encoded image data must have a special prefix in the following format: + * "data:{mimetype};base64,{base64-encoded-image-data}". + * @param detail Specifies the detail level of the image. + */ + @JsonInclude(Include.NON_NULL) + public record ImageUrl( + @JsonProperty("url") String url, + @JsonProperty("detail") String detail) { + + public ImageUrl(String url) { + this(url, null); + } + } + + /** + * Shortcut constructor for a text content. + * @param text The text content of the message. + */ + public MediaContent(String text) { + this("text", text, null); + } + + /** + * Shortcut constructor for an image content. + * @param imageUrl The image content of the message. + */ + public MediaContent(ImageUrl imageUrl) { + this("image_url", null, imageUrl); + } + } + /** + * The relevant tool call. + * + * @param id The ID of the tool call. This ID must be referenced when you submit the tool outputs in using the + * Submit tool outputs to run endpoint. + * @param type The type of tool call the output is required for. For now, this is always function. + * @param function The function definition. + */ + @JsonInclude(Include.NON_NULL) + public record ToolCall( + @JsonProperty("id") String id, + @JsonProperty("type") String type, + @JsonProperty("function") ChatCompletionFunction function) { + } + + /** + * The function definition. + * + * @param name The name of the function. + * @param arguments The arguments that the model expects you to pass to the function. + */ + @JsonInclude(Include.NON_NULL) + public record ChatCompletionFunction( + @JsonProperty("name") String name, + @JsonProperty("arguments") String arguments) { + } + } + + public static String getTextContent(List content) { + return content.stream() + .filter(c -> "text".equals(c.type())) + .map(ChatCompletionMessage.MediaContent::text) + .reduce("", (a, b) -> a + b); + } + + /** + * The reason the model stopped generating tokens. + */ + public enum ChatCompletionFinishReason { + /** + * The model hit a natural stop point or a provided stop sequence. + */ + @JsonProperty("stop") STOP, + /** + * The maximum number of tokens specified in the request was reached. + */ + @JsonProperty("length") LENGTH, + /** + * The content was omitted due to a flag from our content filters. + */ + @JsonProperty("content_filter") CONTENT_FILTER, + /** + * The model called a tool. + */ + @JsonProperty("tool_calls") TOOL_CALLS, + /** + * (deprecated) The model called a function. + */ + @JsonProperty("function_call") FUNCTION_CALL, + /** + * Only for compatibility with Mistral AI API. + */ + @JsonProperty("tool_call") TOOL_CALL + } + + /** + * Represents a chat completion response returned by model, based on the provided input. + * + * @param id A unique identifier for the chat completion. + * @param choices A list of chat completion choices. Can be more than one if n is greater than 1. + * @param created The Unix timestamp (in seconds) of when the chat completion was created. + * @param model The model used for the chat completion. + * @param systemFingerprint This fingerprint represents the backend configuration that the model runs with. Can be + * used in conjunction with the seed request parameter to understand when backend changes have been made that might + * impact determinism. + * @param object The object type, which is always chat.completion. + * @param usage Usage statistics for the completion request. + */ + @JsonInclude(Include.NON_NULL) + public record ChatCompletion( + @JsonProperty("id") String id, + @JsonProperty("choices") List choices, + @JsonProperty("created") Long created, + @JsonProperty("model") String model, + @JsonProperty("system_fingerprint") String systemFingerprint, + @JsonProperty("object") String object, + + @JsonProperty("base_resp") BaseResponse baseResponse, + @JsonProperty("usage") Usage usage) { + + /** + * Chat completion choice. + * + * @param finishReason The reason the model stopped generating tokens. + * @param index The index of the choice in the list of choices. + * @param message A chat completion message generated by the model. + * @param logprobs Log probability information for the choice. + */ + @JsonInclude(Include.NON_NULL) + public record Choice( + @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason, + @JsonProperty("index") Integer index, + @JsonProperty("message") ChatCompletionMessage message, + @JsonProperty("logprobs") LogProbs logprobs) { + } + + + public record BaseResponse( + @JsonProperty("status_code") Long statusCode, + @JsonProperty("status_msg") String message + ){} + } + + /** + * Log probability information for the choice. + * + * @param content A list of message content tokens with log probability information. + */ + @JsonInclude(Include.NON_NULL) + public record LogProbs( + @JsonProperty("content") List content) { + + /** + * Message content tokens with log probability information. + * + * @param token The token. + * @param logprob The log probability of the token. + * @param probBytes A list of integers representing the UTF-8 bytes representation + * of the token. Useful in instances where characters are represented by multiple + * tokens and their byte representations must be combined to generate the correct + * text representation. Can be null if there is no bytes representation for the token. + * @param topLogprobs List of the most likely tokens and their log probability, + * at this token position. In rare cases, there may be fewer than the number of + * requested top_logprobs returned. + */ + @JsonInclude(Include.NON_NULL) + public record Content( + @JsonProperty("token") String token, + @JsonProperty("logprob") Float logprob, + @JsonProperty("bytes") List probBytes, + @JsonProperty("top_logprobs") List topLogprobs) { + + /** + * The most likely tokens and their log probability, at this token position. + * + * @param token The token. + * @param logprob The log probability of the token. + * @param probBytes A list of integers representing the UTF-8 bytes representation + * of the token. Useful in instances where characters are represented by multiple + * tokens and their byte representations must be combined to generate the correct + * text representation. Can be null if there is no bytes representation for the token. + */ + @JsonInclude(Include.NON_NULL) + public record TopLogProbs( + @JsonProperty("token") String token, + @JsonProperty("logprob") Float logprob, + @JsonProperty("bytes") List probBytes) { + } + } + } + + /** + * Usage statistics for the completion request. + * + * @param completionTokens Number of tokens in the generated completion. Only applicable for completion requests. + * @param promptTokens Number of tokens in the prompt. + * @param totalTokens Total number of tokens used in the request (prompt + completion). + */ + @JsonInclude(Include.NON_NULL) + public record Usage( + @JsonProperty("completion_tokens") Integer completionTokens, + @JsonProperty("prompt_tokens") Integer promptTokens, + @JsonProperty("total_tokens") Integer totalTokens) { + + } + + /** + * Represents a streamed chunk of a chat completion response returned by model, based on the provided input. + * + * @param id A unique identifier for the chat completion. Each chunk has the same ID. + * @param choices A list of chat completion choices. Can be more than one if n is greater than 1. + * @param created The Unix timestamp (in seconds) of when the chat completion was created. Each chunk has the same + * timestamp. + * @param model The model used for the chat completion. + * @param systemFingerprint This fingerprint represents the backend configuration that the model runs with. Can be + * used in conjunction with the seed request parameter to understand when backend changes have been made that might + * impact determinism. + * @param object The object type, which is always 'chat.completion.chunk'. + */ + @JsonInclude(Include.NON_NULL) + public record ChatCompletionChunk( + @JsonProperty("id") String id, + @JsonProperty("choices") List choices, + @JsonProperty("created") Long created, + @JsonProperty("model") String model, + @JsonProperty("system_fingerprint") String systemFingerprint, + @JsonProperty("object") String object) { + + /** + * Chat completion choice. + * + * @param finishReason The reason the model stopped generating tokens. + * @param index The index of the choice in the list of choices. + * @param delta A chat completion delta generated by streamed model responses. + * @param logprobs Log probability information for the choice. + */ + @JsonInclude(Include.NON_NULL) + public record ChunkChoice( + @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason, + @JsonProperty("index") Integer index, + @JsonProperty("delta") ChatCompletionMessage delta, + @JsonProperty("logprobs") LogProbs logprobs) { + } + } + + /** + * Creates a model response for the given chat conversation. + * + * @param chatRequest The chat completion request. + * @return Entity response with {@link ChatCompletion} as a body and HTTP status code and headers. + */ + public ResponseEntity chatCompletionEntity(ChatCompletionRequest chatRequest) { + + Assert.notNull(chatRequest, "The request body can not be null."); + Assert.isTrue(!chatRequest.stream(), "Request must set the steam property to false."); + + return this.restClient.post() + .uri("/v1/text/chatcompletion_v2") + .body(chatRequest) + .retrieve() + .toEntity(ChatCompletion.class); + } + + private final MiniMaxStreamFunctionCallingHelper chunkMerger = new MiniMaxStreamFunctionCallingHelper(); + + /** + * Creates a streaming chat response for the given chat conversation. + * + * @param chatRequest The chat completion request. Must have the stream property set to true. + * @return Returns a {@link Flux} stream from chat completion chunks. + */ + public Flux chatCompletionStream(ChatCompletionRequest chatRequest) { + + Assert.notNull(chatRequest, "The request body can not be null."); + Assert.isTrue(chatRequest.stream(), "Request must set the steam property to true."); + + AtomicBoolean isInsideTool = new AtomicBoolean(false); + + return this.webClient.post() + .uri("/v1/text/chatcompletion_v2") + .body(Mono.just(chatRequest), ChatCompletionRequest.class) + .retrieve() + .bodyToFlux(String.class) + .takeUntil(SSE_DONE_PREDICATE) + .filter(SSE_DONE_PREDICATE.negate()) + .map(content -> ModelOptionsUtils.jsonToObject(content, ChatCompletionChunk.class)) + .map(chunk -> { + if (this.chunkMerger.isStreamingToolFunctionCall(chunk)) { + isInsideTool.set(true); + } + return chunk; + }) + .windowUntil(chunk -> { + if (isInsideTool.get() && this.chunkMerger.isStreamingToolFunctionCallFinish(chunk)) { + isInsideTool.set(false); + return true; + } + return !isInsideTool.get(); + }) + .concatMapIterable(window -> { + Mono monoChunk = window.reduce( + new ChatCompletionChunk(null, null, null, null, null, null), + this.chunkMerger::merge); + return List.of(monoChunk); + }) + .flatMap(mono -> mono); + } + + /** + * MiniMax Embeddings Models: + * Embeddings. + */ + public enum EmbeddingModel { + + /** + * DIMENSION: 1536 + */ + Embo_01("embo-01"); + + public final String value; + + EmbeddingModel(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + /** + * MiniMax Embeddings Types + */ + public enum EmbeddingType { + + /** + * DB, used to generate vectors and store them in the library (as retrieved text) + */ + DB("db"), + + /** + * Query, used to generate vectors for queries (when used as retrieval text) + */ + Query("query"); + + @JsonValue + public final String value; + + EmbeddingType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + /** + * Creates an embedding vector representing the input text. + * + * @param texts Input text to embed, encoded as a string or array of tokens. + * @param model ID of the model to use. + */ + @JsonInclude(Include.NON_NULL) + public record EmbeddingRequest( + @JsonProperty("texts") List texts, + @JsonProperty("model") String model, + @JsonProperty("type") String type + ) { + + + + /** + * Create an embedding request with the given input. + * Embedding model is set to 'embo-01'. + * Embedding type is set to 'db'. + * @param text Input text to embed. + */ + public EmbeddingRequest(String text) { + this(List.of(text), DEFAULT_EMBEDDING_MODEL, EmbeddingType.DB.value); + } + + /** + * Create an embedding request with the given input. + * @param text Input text to embed. + * @param model Embedding model. + */ + public EmbeddingRequest(String text, String model) { + this(List.of(text), model, "db"); + } + + /** + * Create an embedding request with the given input. + * Embedding model is set to 'embo-01'. + * @param text Input text to embed. + * @param type Embedding type. + */ + public EmbeddingRequest(String text, EmbeddingType type) { + this(List.of(text), DEFAULT_EMBEDDING_MODEL, type.value); + } + + /** + * Create an embedding request with the given input. + * Embedding model is set to 'embo-01'. + * Embedding type is set to 'db'. + * @param texts Input text to embed. + */ + public EmbeddingRequest(List texts) { + this(texts, DEFAULT_EMBEDDING_MODEL, EmbeddingType.DB.value); + } + + /** + * Create an embedding request with the given input. + * Embedding type is set to 'db'. + * @param texts Input text to embed. + * @param model Embedding model. + */ + public EmbeddingRequest(List texts, String model) { + this(texts, model, "db"); + } + + /** + * Create an embedding request with the given input. + * Embedding model is set to 'embo-01'. + * @param texts Input text to embed. + * @param type Embedding type. + */ + public EmbeddingRequest(List texts, EmbeddingType type) { + this(texts, DEFAULT_EMBEDDING_MODEL, type.value); + } + } + + /** + * List of multiple embedding responses. + * + * @param vectors List of entities. + * @param model ID of the model to use. + * @param totalTokens Usage tokens the request. + */ + @JsonInclude(Include.NON_NULL) + public record EmbeddingList( + @JsonProperty("vectors") List> vectors, + @JsonProperty("model") String model, + @JsonProperty("total_tokens") Integer totalTokens) { + } + + /** + * Creates an embedding vector representing the input text or token array. + * + * @param embeddingRequest The embedding request. + * @return Returns {@link EmbeddingList}. + * @param Type of the entity in the data list. Can be a {@link String} or {@link List} of tokens (e.g. + * Integers). For embedding multiple inputs in a single request, You can pass a {@link List} of {@link String} or + * {@link List} of {@link List} of tokens. For example: + * + *

{@code List.of("text1", "text2", "text3") or List.of(List.of(1, 2, 3), List.of(3, 4, 5))} 
+ */ + public ResponseEntity embeddings(EmbeddingRequest embeddingRequest) { + + Assert.notNull(embeddingRequest, "The request body can not be null."); + + // Input text to embed, encoded as a string or array of tokens. To embed multiple inputs in a single + // request, pass an array of strings or array of token arrays. + Assert.notNull(embeddingRequest.texts(), "The input can not be null."); + + Assert.isTrue(!CollectionUtils.isEmpty(embeddingRequest.texts()), "The input list can not be empty."); + + return this.restClient.post() + .uri("/v1/embeddings") + .body(embeddingRequest) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + } + +} +// @formatter:on diff --git a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxStreamFunctionCallingHelper.java b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxStreamFunctionCallingHelper.java new file mode 100644 index 00000000000..ceb92d03d8b --- /dev/null +++ b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxStreamFunctionCallingHelper.java @@ -0,0 +1,202 @@ +/* + * Copyright 2023 - 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.minimax.api; + +import org.springframework.util.CollectionUtils; + +import java.util.ArrayList; +import java.util.List; + +/** + * Helper class to support Streaming function calling. It can merge the streamed + * ChatCompletionChunk in case of function calling message. + * + * @author Geng Rong + * @since 1.0.0 M1 + */ +public class MiniMaxStreamFunctionCallingHelper { + + /** + * Merge the previous and current ChatCompletionChunk into a single one. + * @param previous the previous ChatCompletionChunk + * @param current the current ChatCompletionChunk + * @return the merged ChatCompletionChunk + */ + public MiniMaxApi.ChatCompletionChunk merge(MiniMaxApi.ChatCompletionChunk previous, + MiniMaxApi.ChatCompletionChunk current) { + + if (previous == null) { + return current; + } + + String id = (current.id() != null ? current.id() : previous.id()); + Long created = (current.created() != null ? current.created() : previous.created()); + String model = (current.model() != null ? current.model() : previous.model()); + String systemFingerprint = (current.systemFingerprint() != null ? current.systemFingerprint() + : previous.systemFingerprint()); + String object = (current.object() != null ? current.object() : previous.object()); + + MiniMaxApi.ChatCompletionChunk.ChunkChoice previousChoice0 = (CollectionUtils.isEmpty(previous.choices()) ? null + : previous.choices().get(0)); + MiniMaxApi.ChatCompletionChunk.ChunkChoice currentChoice0 = (CollectionUtils.isEmpty(current.choices()) ? null + : current.choices().get(0)); + + MiniMaxApi.ChatCompletionChunk.ChunkChoice choice = merge(previousChoice0, currentChoice0); + List chunkChoices = choice == null ? List.of() : List.of(choice); + return new MiniMaxApi.ChatCompletionChunk(id, chunkChoices, created, model, systemFingerprint, object); + } + + private MiniMaxApi.ChatCompletionChunk.ChunkChoice merge(MiniMaxApi.ChatCompletionChunk.ChunkChoice previous, + MiniMaxApi.ChatCompletionChunk.ChunkChoice current) { + if (previous == null) { + return current; + } + + MiniMaxApi.ChatCompletionFinishReason finishReason = (current.finishReason() != null ? current.finishReason() + : previous.finishReason()); + Integer index = (current.index() != null ? current.index() : previous.index()); + + MiniMaxApi.ChatCompletionMessage message = merge(previous.delta(), current.delta()); + + MiniMaxApi.LogProbs logprobs = (current.logprobs() != null ? current.logprobs() : previous.logprobs()); + return new MiniMaxApi.ChatCompletionChunk.ChunkChoice(finishReason, index, message, logprobs); + } + + private MiniMaxApi.ChatCompletionMessage merge(MiniMaxApi.ChatCompletionMessage previous, + MiniMaxApi.ChatCompletionMessage current) { + String content = (current.content() != null ? current.content() + : (previous.content() != null) ? previous.content() : ""); + MiniMaxApi.ChatCompletionMessage.Role role = (current.role() != null ? current.role() : previous.role()); + role = (role != null ? role : MiniMaxApi.ChatCompletionMessage.Role.ASSISTANT); // default + // to + // ASSISTANT + // (if + // null + String name = (current.name() != null ? current.name() : previous.name()); + String toolCallId = (current.toolCallId() != null ? current.toolCallId() : previous.toolCallId()); + + List toolCalls = new ArrayList<>(); + MiniMaxApi.ChatCompletionMessage.ToolCall lastPreviousTooCall = null; + if (previous.toolCalls() != null) { + lastPreviousTooCall = previous.toolCalls().get(previous.toolCalls().size() - 1); + if (previous.toolCalls().size() > 1) { + toolCalls.addAll(previous.toolCalls().subList(0, previous.toolCalls().size() - 1)); + } + } + if (current.toolCalls() != null) { + if (current.toolCalls().size() > 1) { + throw new IllegalStateException("Currently only one tool call is supported per message!"); + } + var currentToolCall = current.toolCalls().iterator().next(); + if (currentToolCall.id() != null) { + if (lastPreviousTooCall != null) { + toolCalls.add(lastPreviousTooCall); + } + toolCalls.add(currentToolCall); + } + else { + toolCalls.add(merge(lastPreviousTooCall, currentToolCall)); + } + } + else { + if (lastPreviousTooCall != null) { + toolCalls.add(lastPreviousTooCall); + } + } + return new MiniMaxApi.ChatCompletionMessage(content, role, name, toolCallId, toolCalls); + } + + private MiniMaxApi.ChatCompletionMessage.ToolCall merge(MiniMaxApi.ChatCompletionMessage.ToolCall previous, + MiniMaxApi.ChatCompletionMessage.ToolCall current) { + if (previous == null) { + return current; + } + String id = (current.id() != null ? current.id() : previous.id()); + String type = (current.type() != null ? current.type() : previous.type()); + MiniMaxApi.ChatCompletionMessage.ChatCompletionFunction function = merge(previous.function(), + current.function()); + return new MiniMaxApi.ChatCompletionMessage.ToolCall(id, type, function); + } + + private MiniMaxApi.ChatCompletionMessage.ChatCompletionFunction merge( + MiniMaxApi.ChatCompletionMessage.ChatCompletionFunction previous, + MiniMaxApi.ChatCompletionMessage.ChatCompletionFunction current) { + if (previous == null) { + return current; + } + String name = (current.name() != null ? current.name() : previous.name()); + StringBuilder arguments = new StringBuilder(); + if (previous.arguments() != null) { + arguments.append(previous.arguments()); + } + if (current.arguments() != null) { + arguments.append(current.arguments()); + } + return new MiniMaxApi.ChatCompletionMessage.ChatCompletionFunction(name, arguments.toString()); + } + + /** + * @param chatCompletion the ChatCompletionChunk to check + * @return true if the ChatCompletionChunk is a streaming tool function call. + */ + public boolean isStreamingToolFunctionCall(MiniMaxApi.ChatCompletionChunk chatCompletion) { + + if (chatCompletion == null || CollectionUtils.isEmpty(chatCompletion.choices())) { + return false; + } + + var choice = chatCompletion.choices().get(0); + if (choice == null || choice.delta() == null) { + return false; + } + return !CollectionUtils.isEmpty(choice.delta().toolCalls()); + } + + /** + * @param chatCompletion the ChatCompletionChunk to check + * @return true if the ChatCompletionChunk is a streaming tool function call and it is + * the last one. + */ + public boolean isStreamingToolFunctionCallFinish(MiniMaxApi.ChatCompletionChunk chatCompletion) { + + if (chatCompletion == null || CollectionUtils.isEmpty(chatCompletion.choices())) { + return false; + } + + var choice = chatCompletion.choices().get(0); + if (choice == null || choice.delta() == null) { + return false; + } + return choice.finishReason() == MiniMaxApi.ChatCompletionFinishReason.TOOL_CALLS; + } + + /** + * Convert the ChatCompletionChunk into a ChatCompletion. The Usage is set to null. + * @param chunk the ChatCompletionChunk to convert + * @return the ChatCompletion + */ + public MiniMaxApi.ChatCompletion chunkToChatCompletion(MiniMaxApi.ChatCompletionChunk chunk) { + List choices = chunk.choices() + .stream() + .map(chunkChoice -> new MiniMaxApi.ChatCompletion.Choice(chunkChoice.finishReason(), chunkChoice.index(), + chunkChoice.delta(), chunkChoice.logprobs())) + .toList(); + + return new MiniMaxApi.ChatCompletion(chunk.id(), choices, chunk.created(), chunk.model(), + chunk.systemFingerprint(), "chat.completion", null, null); + } + +} diff --git a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/common/MiniMaxApiException.java b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/common/MiniMaxApiException.java new file mode 100644 index 00000000000..5351bd3c116 --- /dev/null +++ b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/common/MiniMaxApiException.java @@ -0,0 +1,16 @@ +package org.springframework.ai.minimax.api.common; + +/** + * @author Geng Rong + */ +public class MiniMaxApiException extends RuntimeException { + + public MiniMaxApiException(String message) { + super(message); + } + + public MiniMaxApiException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/models/spring-ai-minimax/src/main/resources/META-INF/spring/aot.factories b/models/spring-ai-minimax/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 00000000000..56d21def4f2 --- /dev/null +++ b/models/spring-ai-minimax/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1,2 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=\ + org.springframework.ai.minimax.aot.MiniMaxRuntimeHints \ No newline at end of file diff --git a/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/ChatCompletionRequestTests.java b/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/ChatCompletionRequestTests.java new file mode 100644 index 00000000000..9adf803a456 --- /dev/null +++ b/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/ChatCompletionRequestTests.java @@ -0,0 +1,144 @@ +/* + * Copyright 2023 - 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.minimax; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.minimax.api.MiniMaxApi; +import org.springframework.ai.minimax.api.MockWeatherService; +import org.springframework.ai.model.function.FunctionCallbackWrapper; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +public class ChatCompletionRequestTests { + + @Test + public void createRequestWithChatOptions() { + + var client = new MiniMaxChatClient(new MiniMaxApi("TEST"), + MiniMaxChatOptions.builder().withModel("DEFAULT_MODEL").withTemperature(66.6f).build()); + + var request = client.createRequest(new Prompt("Test message content"), false); + + assertThat(request.messages()).hasSize(1); + assertThat(request.stream()).isFalse(); + + assertThat(request.model()).isEqualTo("DEFAULT_MODEL"); + assertThat(request.temperature()).isEqualTo(66.6f); + + request = client.createRequest(new Prompt("Test message content", + MiniMaxChatOptions.builder().withModel("PROMPT_MODEL").withTemperature(99.9f).build()), true); + + assertThat(request.messages()).hasSize(1); + assertThat(request.stream()).isTrue(); + + assertThat(request.model()).isEqualTo("PROMPT_MODEL"); + assertThat(request.temperature()).isEqualTo(99.9f); + } + + @Test + public void promptOptionsTools() { + + final String TOOL_FUNCTION_NAME = "CurrentWeather"; + + var client = new MiniMaxChatClient(new MiniMaxApi("TEST"), + MiniMaxChatOptions.builder().withModel("DEFAULT_MODEL").build()); + + var request = client.createRequest(new Prompt("Test message content", + MiniMaxChatOptions.builder() + .withModel("PROMPT_MODEL") + .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) + .withName(TOOL_FUNCTION_NAME) + .withDescription("Get the weather in location") + .withResponseConverter((response) -> "" + response.temp() + response.unit()) + .build())) + .build()), + false); + + assertThat(client.getFunctionCallbackRegister()).hasSize(1); + assertThat(client.getFunctionCallbackRegister()).containsKeys(TOOL_FUNCTION_NAME); + + assertThat(request.messages()).hasSize(1); + assertThat(request.stream()).isFalse(); + assertThat(request.model()).isEqualTo("PROMPT_MODEL"); + + assertThat(request.tools()).hasSize(1); + assertThat(request.tools().get(0).function().name()).isEqualTo(TOOL_FUNCTION_NAME); + } + + @Test + public void defaultOptionsTools() { + + final String TOOL_FUNCTION_NAME = "CurrentWeather"; + + var client = new MiniMaxChatClient(new MiniMaxApi("TEST"), + MiniMaxChatOptions.builder() + .withModel("DEFAULT_MODEL") + .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) + .withName(TOOL_FUNCTION_NAME) + .withDescription("Get the weather in location") + .withResponseConverter((response) -> "" + response.temp() + response.unit()) + .build())) + .build()); + + var request = client.createRequest(new Prompt("Test message content"), false); + + assertThat(client.getFunctionCallbackRegister()).hasSize(1); + assertThat(client.getFunctionCallbackRegister()).containsKeys(TOOL_FUNCTION_NAME); + assertThat(client.getFunctionCallbackRegister().get(TOOL_FUNCTION_NAME).getDescription()) + .isEqualTo("Get the weather in location"); + + assertThat(request.messages()).hasSize(1); + assertThat(request.stream()).isFalse(); + assertThat(request.model()).isEqualTo("DEFAULT_MODEL"); + + assertThat(request.tools()).as("Default Options callback functions are not automatically enabled!") + .isNullOrEmpty(); + + // Explicitly enable the function + request = client.createRequest(new Prompt("Test message content", + MiniMaxChatOptions.builder().withFunction(TOOL_FUNCTION_NAME).build()), false); + + assertThat(request.tools()).hasSize(1); + assertThat(request.tools().get(0).function().name()).as("Explicitly enabled function") + .isEqualTo(TOOL_FUNCTION_NAME); + + // Override the default options function with one from the prompt + request = client.createRequest(new Prompt("Test message content", + MiniMaxChatOptions.builder() + .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) + .withName(TOOL_FUNCTION_NAME) + .withDescription("Overridden function description") + .build())) + .build()), + false); + + assertThat(request.tools()).hasSize(1); + assertThat(request.tools().get(0).function().name()).as("Explicitly enabled function") + .isEqualTo(TOOL_FUNCTION_NAME); + + assertThat(client.getFunctionCallbackRegister()).hasSize(1); + assertThat(client.getFunctionCallbackRegister()).containsKeys(TOOL_FUNCTION_NAME); + assertThat(client.getFunctionCallbackRegister().get(TOOL_FUNCTION_NAME).getDescription()) + .isEqualTo("Overridden function description"); + } + +} diff --git a/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/MiniMaxTestConfiguration.java b/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/MiniMaxTestConfiguration.java new file mode 100644 index 00000000000..f544b4896c6 --- /dev/null +++ b/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/MiniMaxTestConfiguration.java @@ -0,0 +1,54 @@ +/* + * Copyright 2023 - 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.minimax; + +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.minimax.api.MiniMaxApi; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.util.StringUtils; + +/** + * @author Geng Rong + */ +@SpringBootConfiguration +public class MiniMaxTestConfiguration { + + @Bean + public MiniMaxApi miniMaxApi() { + return new MiniMaxApi(getApiKey()); + } + + private String getApiKey() { + String apiKey = System.getenv("MINIMAX_API_KEY"); + if (!StringUtils.hasText(apiKey)) { + throw new IllegalArgumentException( + "You must provide an API key. Put it in an environment variable under the name MINIMAX_API_KEY"); + } + return apiKey; + } + + @Bean + public MiniMaxChatClient miniMaxChatClient(MiniMaxApi api) { + return new MiniMaxChatClient(api); + } + + @Bean + public EmbeddingClient miniMaxEmbeddingClient(MiniMaxApi api) { + return new MiniMaxEmbeddingClient(api); + } + +} diff --git a/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiIT.java b/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiIT.java new file mode 100644 index 00000000000..431d20ff2f3 --- /dev/null +++ b/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiIT.java @@ -0,0 +1,67 @@ +/* + * Copyright 2023 - 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.minimax.api; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.ai.minimax.api.MiniMaxApi.*; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionMessage.Role; +import org.springframework.http.ResponseEntity; +import reactor.core.publisher.Flux; + +import java.util.List; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "MINIMAX_API_KEY", matches = ".+") +public class MiniMaxApiIT { + + MiniMaxApi miniMaxApi = new MiniMaxApi(System.getenv("MINIMAX_API_KEY")); + + @Test + void chatCompletionEntity() { + ChatCompletionMessage chatCompletionMessage = new ChatCompletionMessage("Hello world", Role.USER); + ResponseEntity response = miniMaxApi.chatCompletionEntity( + new ChatCompletionRequest(List.of(chatCompletionMessage), "glm-3-turbo", 0.7f, false)); + + assertThat(response).isNotNull(); + assertThat(response.getBody()).isNotNull(); + } + + @Test + void chatCompletionStream() { + ChatCompletionMessage chatCompletionMessage = new ChatCompletionMessage("Hello world", Role.USER); + Flux response = miniMaxApi + .chatCompletionStream(new ChatCompletionRequest(List.of(chatCompletionMessage), "glm-3-turbo", 0.7f, true)); + + assertThat(response).isNotNull(); + assertThat(response.collectList().block()).isNotNull(); + } + + @Test + void embeddings() { + ResponseEntity response = miniMaxApi.embeddings(new MiniMaxApi.EmbeddingRequest("Hello world")); + + assertThat(response).isNotNull(); + assertThat(Objects.requireNonNull(response.getBody()).vectors()).hasSize(1); + assertThat(response.getBody().vectors().get(0)).hasSize(1536); + } + +} diff --git a/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiToolFunctionCallIT.java b/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiToolFunctionCallIT.java new file mode 100644 index 00000000000..cfcd2fa0a7d --- /dev/null +++ b/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiToolFunctionCallIT.java @@ -0,0 +1,141 @@ +/* + * Copyright 2023 - 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.minimax.api; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletion; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionMessage; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionMessage.Role; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionMessage.ToolCall; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionRequest; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionRequest.ToolChoiceBuilder; +import org.springframework.ai.minimax.api.MiniMaxApi.FunctionTool.Type; +import org.springframework.http.ResponseEntity; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.ai.minimax.api.MiniMaxApi.ChatModel.*; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "MINIMAX_API_KEY", matches = ".+") +public class MiniMaxApiToolFunctionCallIT { + + private final Logger logger = LoggerFactory.getLogger(MiniMaxApiToolFunctionCallIT.class); + + MockWeatherService weatherService = new MockWeatherService(); + + MiniMaxApi miniMaxApi = new MiniMaxApi(System.getenv("MINIMAX_API_KEY")); + + @SuppressWarnings("null") + @Test + public void toolFunctionCall() { + + // Step 1: send the conversation and available functions to the model + var message = new ChatCompletionMessage("What's the weather like in San Francisco?", Role.USER); + + var functionTool = new MiniMaxApi.FunctionTool(Type.FUNCTION, new MiniMaxApi.FunctionTool.Function( + "Get the weather in location. Return temperature in 30°F or 30°C format.", "getCurrentWeather", """ + { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state e.g. San Francisco, CA" + }, + "lat": { + "type": "number", + "description": "The city latitude" + }, + "lon": { + "type": "number", + "description": "The city longitude" + }, + "unit": { + "type": "string", + "enum": ["C", "F"] + } + }, + "required": ["location", "lat", "lon", "unit"] + } + """)); + + List messages = new ArrayList<>(List.of(message)); + + ChatCompletionRequest chatCompletionRequest = new ChatCompletionRequest(messages, ABAB_6_Chat.value, + List.of(functionTool), ToolChoiceBuilder.AUTO); + + ResponseEntity chatCompletion = miniMaxApi.chatCompletionEntity(chatCompletionRequest); + + assertThat(chatCompletion.getBody()).isNotNull(); + assertThat(chatCompletion.getBody().choices()).isNotEmpty(); + + ChatCompletionMessage responseMessage = chatCompletion.getBody().choices().get(0).message(); + + assertThat(responseMessage.role()).isEqualTo(Role.ASSISTANT); + assertThat(responseMessage.toolCalls()).isNotNull(); + + messages.add(responseMessage); + + // Send the info for each function call and function response to the model. + for (ToolCall toolCall : responseMessage.toolCalls()) { + var functionName = toolCall.function().name(); + if ("getCurrentWeather".equals(functionName)) { + MockWeatherService.Request weatherRequest = fromJson(toolCall.function().arguments(), + MockWeatherService.Request.class); + + MockWeatherService.Response weatherResponse = weatherService.apply(weatherRequest); + + // extend conversation with function response. + messages.add(new ChatCompletionMessage("" + weatherResponse.temp() + weatherRequest.unit(), Role.TOOL, + functionName, toolCall.id(), null)); + } + } + + var functionResponseRequest = new ChatCompletionRequest(messages, ABAB_6_Chat.value, 0.5F); + + ResponseEntity chatCompletion2 = miniMaxApi.chatCompletionEntity(functionResponseRequest); + + logger.info("Final response: " + chatCompletion2.getBody()); + + assertThat(Objects.requireNonNull(chatCompletion2.getBody()).choices()).isNotEmpty(); + + assertThat(chatCompletion2.getBody().choices().get(0).message().role()).isEqualTo(Role.ASSISTANT); + assertThat(chatCompletion2.getBody().choices().get(0).message().content()).contains("San Francisco") + .containsAnyOf("30.0°C", "30°C", "30.0°F", "30°F"); + } + + private static T fromJson(String json, Class targetClass) { + try { + return new ObjectMapper().readValue(json, targetClass); + } + catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + +} \ No newline at end of file diff --git a/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxRetryTests.java b/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxRetryTests.java new file mode 100644 index 00000000000..4310b87fbb4 --- /dev/null +++ b/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxRetryTests.java @@ -0,0 +1,178 @@ +/* + * Copyright 2023 - 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.minimax.api; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.minimax.MiniMaxChatClient; +import org.springframework.ai.minimax.MiniMaxChatOptions; +import org.springframework.ai.minimax.MiniMaxEmbeddingClient; +import org.springframework.ai.minimax.MiniMaxEmbeddingOptions; +import org.springframework.ai.minimax.api.MiniMaxApi.*; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionMessage.Role; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.ai.retry.TransientAiException; +import org.springframework.http.ResponseEntity; +import org.springframework.retry.RetryCallback; +import org.springframework.retry.RetryContext; +import org.springframework.retry.RetryListener; +import org.springframework.retry.support.RetryTemplate; +import reactor.core.publisher.Flux; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.when; + +/** + * @author Geng Rong + */ +@SuppressWarnings("unchecked") +@ExtendWith(MockitoExtension.class) +public class MiniMaxRetryTests { + + private class TestRetryListener implements RetryListener { + + int onErrorRetryCount = 0; + + int onSuccessRetryCount = 0; + + @Override + public void onSuccess(RetryContext context, RetryCallback callback, T result) { + onSuccessRetryCount = context.getRetryCount(); + } + + @Override + public void onError(RetryContext context, RetryCallback callback, + Throwable throwable) { + onErrorRetryCount = context.getRetryCount(); + } + + } + + private TestRetryListener retryListener; + + private RetryTemplate retryTemplate; + + private @Mock MiniMaxApi miniMaxApi; + + private MiniMaxChatClient chatClient; + + private MiniMaxEmbeddingClient embeddingClient; + + @BeforeEach + public void beforeEach() { + retryTemplate = RetryUtils.DEFAULT_RETRY_TEMPLATE; + retryListener = new TestRetryListener(); + retryTemplate.registerListener(retryListener); + + chatClient = new MiniMaxChatClient(miniMaxApi, MiniMaxChatOptions.builder().build(), null, retryTemplate); + embeddingClient = new MiniMaxEmbeddingClient(miniMaxApi, MetadataMode.EMBED, + MiniMaxEmbeddingOptions.builder().build(), retryTemplate); + } + + @Test + public void miniMaxChatTransientError() { + + var choice = new ChatCompletion.Choice(ChatCompletionFinishReason.STOP, 0, + new ChatCompletionMessage("Response", Role.ASSISTANT), null); + ChatCompletion expectedChatCompletion = new ChatCompletion("id", List.of(choice), 666l, "model", null, null, + null, new MiniMaxApi.Usage(10, 10, 10)); + + when(miniMaxApi.chatCompletionEntity(isA(ChatCompletionRequest.class))) + .thenThrow(new TransientAiException("Transient Error 1")) + .thenThrow(new TransientAiException("Transient Error 2")) + .thenReturn(ResponseEntity.of(Optional.of(expectedChatCompletion))); + + var result = chatClient.call(new Prompt("text")); + + assertThat(result).isNotNull(); + assertThat(result.getResult().getOutput().getContent()).isSameAs("Response"); + assertThat(retryListener.onSuccessRetryCount).isEqualTo(2); + assertThat(retryListener.onErrorRetryCount).isEqualTo(2); + } + + @Test + public void miniMaxChatNonTransientError() { + when(miniMaxApi.chatCompletionEntity(isA(ChatCompletionRequest.class))) + .thenThrow(new RuntimeException("Non Transient Error")); + assertThrows(RuntimeException.class, () -> chatClient.call(new Prompt("text"))); + } + + @Test + public void miniMaxChatStreamTransientError() { + + var choice = new ChatCompletionChunk.ChunkChoice(ChatCompletionFinishReason.STOP, 0, + new ChatCompletionMessage("Response", Role.ASSISTANT), null); + ChatCompletionChunk expectedChatCompletion = new ChatCompletionChunk("id", List.of(choice), 666l, "model", null, + null); + + when(miniMaxApi.chatCompletionStream(isA(ChatCompletionRequest.class))) + .thenThrow(new TransientAiException("Transient Error 1")) + .thenThrow(new TransientAiException("Transient Error 2")) + .thenReturn(Flux.just(expectedChatCompletion)); + + var result = chatClient.stream(new Prompt("text")); + + assertThat(result).isNotNull(); + assertThat(result.collectList().block().get(0).getResult().getOutput().getContent()).isSameAs("Response"); + assertThat(retryListener.onSuccessRetryCount).isEqualTo(2); + assertThat(retryListener.onErrorRetryCount).isEqualTo(2); + } + + @Test + public void miniMaxChatStreamNonTransientError() { + when(miniMaxApi.chatCompletionStream(isA(ChatCompletionRequest.class))) + .thenThrow(new RuntimeException("Non Transient Error")); + assertThrows(RuntimeException.class, () -> chatClient.stream(new Prompt("text"))); + } + + @Test + public void miniMaxEmbeddingTransientError() { + + EmbeddingList expectedEmbeddings = new EmbeddingList(List.of(List.of(9.9, 8.8)), "model", 10); + + when(miniMaxApi.embeddings(isA(EmbeddingRequest.class))) + .thenThrow(new TransientAiException("Transient Error 1")) + .thenThrow(new TransientAiException("Transient Error 2")) + .thenReturn(ResponseEntity.of(Optional.of(expectedEmbeddings))); + + var result = embeddingClient + .call(new org.springframework.ai.embedding.EmbeddingRequest(List.of("text1", "text2"), null)); + + assertThat(result).isNotNull(); + assertThat(result.getResult().getOutput()).isEqualTo(List.of(9.9, 8.8)); + assertThat(retryListener.onSuccessRetryCount).isEqualTo(2); + assertThat(retryListener.onErrorRetryCount).isEqualTo(2); + } + + @Test + public void miniMaxEmbeddingNonTransientError() { + when(miniMaxApi.embeddings(isA(EmbeddingRequest.class))) + .thenThrow(new RuntimeException("Non Transient Error")); + assertThrows(RuntimeException.class, () -> embeddingClient + .call(new org.springframework.ai.embedding.EmbeddingRequest(List.of("text1", "text2"), null))); + } + +} diff --git a/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MockWeatherService.java b/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MockWeatherService.java new file mode 100644 index 00000000000..d2f4a9e53d0 --- /dev/null +++ b/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MockWeatherService.java @@ -0,0 +1,92 @@ +/* + * Copyright 2023 - 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.minimax.api; + +import com.fasterxml.jackson.annotation.JsonClassDescription; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +import java.util.function.Function; + +/** + * @author Geng Rong + */ +public class MockWeatherService implements Function { + + /** + * Weather Function request. + */ + @JsonInclude(Include.NON_NULL) + @JsonClassDescription("Weather API request") + public record Request(@JsonProperty(required = true, + value = "location") @JsonPropertyDescription("The city and state e.g. San Francisco, CA") String location, + @JsonProperty(required = true, value = "lat") @JsonPropertyDescription("The city latitude") double lat, + @JsonProperty(required = true, value = "lon") @JsonPropertyDescription("The city longitude") double lon, + @JsonProperty(required = true, value = "unit") @JsonPropertyDescription("Temperature unit") Unit unit) { + } + + /** + * Temperature units. + */ + public enum Unit { + + /** + * Celsius. + */ + C("metric"), + /** + * Fahrenheit. + */ + F("imperial"); + + /** + * Human readable unit name. + */ + public final String unitName; + + private Unit(String text) { + this.unitName = text; + } + + } + + /** + * Weather Function response. + */ + public record Response(double temp, double feels_like, double temp_min, double temp_max, int pressure, int humidity, + Unit unit) { + } + + @Override + public Response apply(Request request) { + + double temperature = 0; + if (request.location().contains("Paris")) { + temperature = 15; + } + else if (request.location().contains("Tokyo")) { + temperature = 10; + } + else if (request.location().contains("San Francisco")) { + temperature = 30; + } + + return new Response(temperature, 15, 20, 2, 53, 45, request.unit); + } + +} \ No newline at end of file diff --git a/models/spring-ai-minimax/src/test/resources/prompts/system-message.st b/models/spring-ai-minimax/src/test/resources/prompts/system-message.st new file mode 100644 index 00000000000..579febd8d9b --- /dev/null +++ b/models/spring-ai-minimax/src/test/resources/prompts/system-message.st @@ -0,0 +1,3 @@ +You are an AI assistant that helps people find information. +Your name is {name}. +You should reply to the user's request with your name and also in the style of a {voice}. \ No newline at end of file diff --git a/pom.xml b/pom.xml index c5e2e767d89..42f645f3de2 100644 --- a/pom.xml +++ b/pom.xml @@ -57,6 +57,7 @@ models/spring-ai-azure-openai models/spring-ai-bedrock models/spring-ai-huggingface + models/spring-ai-minimax models/spring-ai-mistral-ai models/spring-ai-ollama models/spring-ai-openai @@ -70,6 +71,7 @@ spring-ai-spring-boot-starters/spring-ai-starter-anthropic spring-ai-spring-boot-starters/spring-ai-starter-azure-openai spring-ai-spring-boot-starters/spring-ai-starter-bedrock-ai + spring-ai-spring-boot-starters/spring-ai-starter-minimax spring-ai-spring-boot-starters/spring-ai-starter-mistral-ai spring-ai-spring-boot-starters/spring-ai-starter-ollama spring-ai-spring-boot-starters/spring-ai-starter-openai @@ -80,6 +82,7 @@ spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-palm2 spring-ai-spring-boot-starters/spring-ai-starter-watsonx-ai + diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index 0c651b56ecf..55684225237 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -124,6 +124,12 @@ ${project.version} + + org.springframework.ai + spring-ai-minimax + ${project.version} + + @@ -384,6 +390,12 @@ spring-ai-elasticsearch-store-spring-boot-starter ${project.version} + + + org.springframework.ai + spring-ai-minimax-spring-boot-starter + ${project.version} + 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 ce729e71452..e8d73576549 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc @@ -25,6 +25,8 @@ *** xref:api/chat/anthropic-chat.adoc[Anthropic 3] **** xref:api/chat/functions/anthropic-chat-functions.adoc[Function Calling] *** xref:api/chat/watsonx-ai-chat.adoc[Watsonx.AI] +*** xref:api/chat/minimax-chat.adoc[MiniMax] +**** xref:api/chat/functions/minimax-chat-functions.adoc[Function Calling] ** xref:api/embeddings.adoc[] *** xref:api/embeddings/openai-embeddings.adoc[OpenAI] *** xref:api/embeddings/ollama-embeddings.adoc[Ollama] @@ -36,6 +38,7 @@ **** xref:api/embeddings/bedrock-titan-embedding.adoc[Titan] *** xref:api/embeddings/onnx.adoc[Transformers (ONNX)] *** xref:api/embeddings/mistralai-embeddings.adoc[Mistral AI] +*** xref:api/embeddings/minimax-embeddings.adoc[MiniMax] ** xref:api/imageclient.adoc[] *** xref:api/image/openai-image.adoc[OpenAI] *** xref:api/image/stabilityai-image.adoc[Stability] diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/functions/minimax-chat-functions.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/functions/minimax-chat-functions.adoc new file mode 100644 index 00000000000..473595ca2b8 --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/functions/minimax-chat-functions.adoc @@ -0,0 +1,226 @@ += Function Calling + +You can register custom Java functions with the `MiniMaxChatClient` and have the MiniMax model intelligently choose to output a JSON object containing arguments to call one or many of the registered functions. +This allows you to connect the LLM capabilities with external tools and APIs. +The MiniMax models are trained to detect when a function should be called and to respond with JSON that adheres to the function signature. + +The MiniMax API does not call the function directly; instead, the model generates JSON that you can use to call the function in your code and return the result back to the model to complete the conversation. + +Spring AI provides flexible and user-friendly ways to register and call custom functions. +In general, the custom functions need to provide a function `name`, `description`, and the function call `signature` (as JSON schema) to let the model know what arguments the function expects. The `description` helps the model to understand when to call the function. + +As a developer, you need to implement a functions that takes the function call arguments sent from the AI model, and respond with the result back to the model. Your function can in turn invoke other 3rd party services to provide the results. + +Spring AI makes this as easy as defining a `@Bean` definition that returns a `java.util.Function` and supplying the bean name as an option when invoking the `ChatClient`. + +Under the hood, Spring wraps your POJO (the function) with the appropriate adapter code that enables interaction with the AI Model, saving you from writing tedious boilerplate code. +The basis of the underlying infrastructure is the link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/model/function/FunctionCallback.java[FunctionCallback.java] interface and the companion link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/model/function/FunctionCallbackWrapper.java[FunctionCallbackWrapper.java] utility class to simplify the implementation and registration of Java callback functions. + +// Additionally, the Auto-Configuration provides a way to auto-register any Function beans definition as function calling candidates in the `ChatClient`. + + +== How it works + +Suppose we want the AI model to respond with information that it does not have, for example the current temperature at a given location. + +We can provide the AI model with metadata about our own functions that it can use to retrieve that information as it processes your prompt. + +For example, if during the processing of a prompt, the AI Model determines that it needs additional information about the temperature in a given location, it will start a server side generated request/response interaction. The AI Model invokes a client side function. +The AI Model provides method invocation details as JSON and it is the responsibility of the client to execute that function and return the response. + +The model-client interaction is illustrated in the <> diagram. + +Spring AI greatly simplifies code you need to write to support function invocation. +It brokers the function invocation conversation for you. +You can simply provide your function definition as a `@Bean` and then provide the bean name of the function in your prompt options. +You can also reference multiple function bean names in your prompt. + +== Quick Start + +Let's create a chatbot that answer questions by calling our own function. +To support the response of the chatbot, we will register our own function that takes a location and returns the current weather in that location. + +When the response to the prompt to the model needs to answer a question such as `"What’s the weather like in Boston?"` the AI model will invoke the client providing the location value as an argument to be passed to the function. This RPC-like data is passed as JSON. + +Our function calls some SaaS based weather service API and returns the weather response back to the model to complete the conversation. In this example we will use a simple implementation named `MockWeatherService` that hard codes the temperature for various locations. + +The following `MockWeatherService.java` represents the weather service API: + +[source,java] +---- +public class MockWeatherService implements Function { + + public enum Unit { C, F } + public record Request(String location, Unit unit) {} + public record Response(double temp, Unit unit) {} + + public Response apply(Request request) { + return new Response(30.0, Unit.C); + } +} +---- + +=== Registering Functions as Beans + +With the link:../minimax-chat.html#_auto_configuration[MiniMaxChatClient Auto-Configuration] you have multiple ways to register custom functions as beans in the Spring context. + +We start with describing the most POJO friendly options. + + +==== Plain Java Functions + +In this approach you define `@Beans` in your application context as you would any other Spring managed object. + +Internally, Spring AI `ChatClient` will create an instance of a `FunctionCallbackWrapper` wrapper that adds the logic for it being invoked via the AI model. +The name of the `@Bean` is passed as a `ChatOption`. + + +[source,java] +---- +@Configuration +static class Config { + + @Bean + @Description("Get the weather in location") // function description + public Function weatherFunction1() { + return new MockWeatherService(); + } + ... +} +---- + +The `@Description` annotation is optional and provides a function description (2) that helps the model to understand when to call the function. It is an important property to set to help the AI model determine what client side function to invoke. + +Another option to provide the description of the function is to the `@JacksonDescription` annotation on the `MockWeatherService.Request` to provide the function description: + +[source,java] +---- + +@Configuration +static class Config { + + @Bean + public Function currentWeather3() { // (1) bean name as function name. + return new MockWeatherService(); + } + ... +} + +@JsonClassDescription("Get the weather in location") // (2) function description +public record Request(String location, Unit unit) {} +---- + +It is a best practice to annotate the request object with information such that the generates JSON schema of that function is as descriptive as possible to help the AI model pick the correct function to invoke. + +The link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/tool/FunctionCallbackWithPlainFunctionBeanIT.java[FunctionCallbackWithPlainFunctionBeanIT.java] demonstrates this approach. + + +==== FunctionCallback Wrapper + +Another way register a function is to create `FunctionCallbackWrapper` wrapper like this: + +[source,java] +---- +@Configuration +static class Config { + + @Bean + public FunctionCallback weatherFunctionInfo() { + + return new FunctionCallbackWrapper<>("CurrentWeather", // (1) function name + "Get the weather in location", // (2) function description + (response) -> "" + response.temp() + response.unit(), // (3) Response Converter + new MockWeatherService()); // function code + } + ... +} +---- + +It wraps the 3rd party, `MockWeatherService` function and registers it as a `CurrentWeather` function with the `MiniMaxChatClient`. +It also provides a description (2) and an optional response converter (3) to convert the response into a text as expected by the model. + +NOTE: By default, the response converter does a JSON serialization of the Response object. + +NOTE: The `FunctionCallbackWrapper` internally resolves the function call signature based on the `MockWeatherService.Request` class. + +=== Specifying functions in Chat Options + +To let the model know and call your `CurrentWeather` function you need to enable it in your prompt requests: + +[source,java] +---- +MiniMaxChatClient chatClient = ... + +UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + +ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), + MiniMaxChatOptions.builder().withFunction("CurrentWeather").build())); // (1) Enable the function + +logger.info("Response: {}", response); +---- + +// NOTE: You can can have multiple functions registered in your `ChatClient` but only those enabled in the prompt request will be considered for the function calling. + +Above user question will trigger 3 calls to `CurrentWeather` function (one for each city) and the final response will be something like this: + +---- +Here is the current weather for the requested cities: +- San Francisco, CA: 30.0°C +- Tokyo, Japan: 10.0°C +- Paris, France: 15.0°C +---- + +The link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/tool/FunctionCallbackWrapperIT.java[FunctionCallbackWrapperIT.java] test demo this approach. + + +=== Register/Call Functions with Prompt Options + +In addition to the auto-configuration you can register callback functions, dynamically, with your Prompt requests: + +[source,java] +---- +MiniMaxChatClient chatClient = ... + +UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + +var promptOptions = MiniMaxChatOptions.builder() + .withFunctionCallbacks(List.of(new FunctionCallbackWrapper<>( + "CurrentWeather", // name + "Get the weather in location", // function description + new MockWeatherService()))) // function code + .build(); + +ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), promptOptions)); +---- + +NOTE: The in-prompt registered functions are enabled by default for the duration of this request. + +This approach allows to dynamically chose different functions to be called based on the user input. + +The https://github.com/spring-projects/spring-ai/blob/main/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/tool/FunctionCallbackInPromptIT.java[FunctionCallbackInPromptIT.java] integration test provides a complete example of how to register a function with the `MiniMaxChatClient` and use it in a prompt request. +// +// === Register Functions with Default Options +// +// You can programmatically register functions with the `MiniMaxChatClient` using the `MiniMaxChatOptions#withFunctionCallbacks`: +// +// [source,java] +// ---- +// +// MiniMaxApi miniMaxApi = new MiniMaxApi(apiKey); +// +// var defaultOptions = MiniMaxChatOptions.builder() +// .withFunctionCallbacks(List.of(new FunctionCallbackWrapper<>( +// "CurrentWeather", // name +// "Get the weather in location", // function description +// new MockWeatherService()))) // function code +// .build(); +// +// MiniMaxChatClient chatClient = new MiniMaxChatClient(miniMaxApi, defaultOptions); +// +// UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); +// +// ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), +// MiniMaxChatOptions.builder().withFunction("CurrentWeather").build())); // Enable the function +// ---- +// +// NOTE: Functions are registered when MiniMaxChatClient is created, by you must enable in the Prompt the functions to be used in the request. \ No newline at end of file diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/minimax-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/minimax-chat.adoc new file mode 100644 index 00000000000..29c7ff35e83 --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/minimax-chat.adoc @@ -0,0 +1,251 @@ += MiniMax Chat + +Spring AI supports the various AI language models from MiniMax. You can interact with MiniMax language models and create a multilingual conversational assistant based on MiniMax models. + +== Prerequisites + +You will need to create an API with MiniMax to access MiniMax language models. + +Create an account at https://www.minimaxi.com/login[MiniMax registration page] and generate the token on the https://www.minimaxi.com/user-center/basic-information/interface-key[API Keys page]. +The Spring AI project defines a configuration property named `spring.ai.minimax.api-key` that you should set to the value of the `API Key` obtained from https://www.minimaxi.com/user-center/basic-information/interface-key[API Keys page]. +Exporting an environment variable is one way to set that configuration property: + +[source,shell] +---- +export SPRING_AI_MINIMAX_API_KEY= +---- + +=== Add Repositories and BOM + +Spring AI artifacts are published in Spring Milestone and Snapshot repositories. +Refer to the xref:getting-started.adoc#repositories[Repositories] section to add these repositories to your build system. + +To help with dependency management, Spring AI provides a BOM (bill of materials) to ensure that a consistent version of Spring AI is used throughout the entire project. Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build system. + + + +== Auto-configuration + +Spring AI provides Spring Boot auto-configuration for the MiniMax Chat Client. +To enable it add the following dependency to your project's Maven `pom.xml` file: + +[source, xml] +---- + + org.springframework.ai + spring-ai-minimax-spring-boot-starter + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-minimax-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. + +=== Chat Properties + +==== Retry Properties + +The prefix `spring.ai.retry` is used as the property prefix that lets you configure the retry mechanism for the MiniMax Chat client. + +[cols="3,5,1"] +|==== +| Property | Description | Default + +| spring.ai.retry.max-attempts | Maximum number of retry attempts. | 10 +| spring.ai.retry.backoff.initial-interval | Initial sleep duration for the exponential backoff policy. | 2 sec. +| spring.ai.retry.backoff.multiplier | Backoff interval multiplier. | 5 +| spring.ai.retry.backoff.max-interval | Maximum backoff duration. | 3 min. +| spring.ai.retry.on-client-errors | If false, throw a NonTransientAiException, and do not attempt retry for `4xx` client error codes | false +| spring.ai.retry.exclude-on-http-codes | List of HTTP status codes that should not trigger a retry (e.g. to throw NonTransientAiException). | empty +| spring.ai.retry.on-http-codes | List of HTTP status codes that should trigger a retry (e.g. to throw TransientAiException). | empty +|==== + +==== Connection Properties + +The prefix `spring.ai.minimax` is used as the property prefix that lets you connect to MiniMax. + +[cols="3,5,1"] +|==== +| Property | Description | Default + +| spring.ai.minimax.base-url | The URL to connect to | https://api.minimax.chat +| spring.ai.minimax.api-key | The API Key | - +|==== + +==== Configuration Properties + +The prefix `spring.ai.minimax.chat` is the property prefix that lets you configure the chat client implementation for MiniMax. + +[cols="3,5,1"] +|==== +| Property | Description | Default + +| spring.ai.minimax.chat.enabled | Enable MiniMax chat client. | true +| spring.ai.minimax.chat.base-url | Optional overrides the spring.ai.minimax.base-url to provide chat specific url | https://api.minimax.chat +| spring.ai.minimax.chat.api-key | Optional overrides the spring.ai.minimax.api-key to provide chat specific api-key | - +| spring.ai.minimax.chat.options.model | This is the MiniMax Chat model to use | `abab5.5-chat` (the `abab5.5s-chat`, `abab5.5-chat`, and `abab6-chat` point to the latest model versions) +| spring.ai.minimax.chat.options.maxTokens | The maximum number of tokens to generate in the chat completion. The total length of input tokens and generated tokens is limited by the model's context length. | - +| spring.ai.minimax.chat.options.temperature | The sampling temperature to use that controls the apparent creativity of generated completions. Higher values will make output more random while lower values will make results more focused and deterministic. It is not recommended to modify temperature and top_p for the same completions request as the interaction of these two settings is difficult to predict. | 0.7 +| spring.ai.minimax.chat.options.topP | An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both. | 1.0 +| spring.ai.minimax.chat.options.n | How many chat completion choices to generate for each input message. Note that you will be charged based on the number of generated tokens across all of the choices. Default value is 1 and cannot be greater than 5. Specifically, when the temperature is very small and close to 0, we can only return 1 result. If n is already set and>1 at this time, service will return an illegal input parameter (invalid_request_error) | 1 +| spring.ai.minimax.chat.options.presencePenalty | Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics. | 0.0f +| spring.ai.minimax.chat.options.frequencyPenalty | Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim. | 0.0f +| spring.ai.minimax.chat.options.stop | The model will stop generating characters specified by stop, and currently only supports a single stop word in the format of ["stop_word1"] | - +|==== + +NOTE: You can override the common `spring.ai.minimax.base-url` and `spring.ai.minimax.api-key` for the `ChatClient` implementations. +The `spring.ai.minimax.chat.base-url` and `spring.ai.minimax.chat.api-key` properties if set take precedence over the common properties. +This is useful if you want to use different MiniMax accounts for different models and different model endpoints. + +TIP: All properties prefixed with `spring.ai.minimax.chat.options` can be overridden at runtime by adding a request specific <> to the `Prompt` call. + +== Runtime Options [[chat-options]] + +The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatOptions.java[MiniMaxChatOptions.java] provides model configurations, such as the model to use, the temperature, the frequency penalty, etc. + +On start-up, the default options can be configured with the `MiniMaxChatClient(api, options)` constructor or the `spring.ai.minimax.chat.options.*` properties. + +At run-time you can override the default options by adding new, request specific, options to the `Prompt` call. +For example to override the default model and temperature for a specific request: + +[source,java] +---- +ChatResponse response = chatClient.call( + new Prompt( + "Generate the names of 5 famous pirates.", + MiniMaxChatOptions.builder() + .withModel(MiniMaxApi.ChatModel.GLM_3_Turbo.getValue()) + .withTemperature(0.5f) + .build() + )); +---- + +TIP: In addition to the model specific link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatOptions.java[MiniMaxChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. + +== Sample Controller + +https://start.spring.io/[Create] a new Spring Boot project and add the `spring-ai-minimax-spring-boot-starter` to your pom (or gradle) dependencies. + +Add a `application.properties` file, under the `src/main/resources` directory, to enable and configure the MiniMax Chat client: + +[source,application.properties] +---- +spring.ai.minimax.api-key=YOUR_API_KEY +spring.ai.minimax.chat.options.model=glm-3-turbo +spring.ai.minimax.chat.options.temperature=0.7 +---- + +TIP: replace the `api-key` with your MiniMax credentials. + +This will create a `MiniMaxChatClient` implementation that you can inject into your class. +Here is an example of a simple `@Controller` class that uses the chat client for text generations. + +[source,java] +---- +@RestController +public class ChatController { + + private final MiniMaxChatClient chatClient; + + @Autowired + public ChatController(MiniMaxChatClient chatClient) { + this.chatClient = chatClient; + } + + @GetMapping("/ai/generate") + public Map generate(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { + return Map.of("generation", chatClient.call(message)); + } + + @GetMapping("/ai/generateStream") + public Flux generateStream(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { + var prompt = new Prompt(new UserMessage(message)); + return chatClient.stream(prompt); + } +} +---- + +== Manual Configuration + +The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatClient.java[MiniMaxChatClient] implements the `ChatClient` and `StreamingChatClient` and uses the <> to connect to the MiniMax service. + +Add the `spring-ai-minimax` dependency to your project's Maven `pom.xml` file: + +[source, xml] +---- + + org.springframework.ai + spring-ai-minimax + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-minimax' +} +---- + +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 a `MiniMaxChatClient` and use it for text generations: + +[source,java] +---- +var miniMaxApi = new MiniMaxApi(System.getenv("MINIMAX_API_KEY")); + +var chatClient = new MiniMaxChatClient(miniMaxApi, MiniMaxChatOptions.builder() + .withModel(MiniMaxApi.ChatModel.GLM_3_Turbo.getValue()) + .withTemperature(0.4f) + .withMaxTokens(200) + .build()); + +ChatResponse response = chatClient.call( + new Prompt("Generate the names of 5 famous pirates.")); + +// Or with streaming responses +Flux streamResponse = chatClient.stream( + new Prompt("Generate the names of 5 famous pirates.")); +---- + +The `MiniMaxChatOptions` provides the configuration information for the chat requests. +The `MiniMaxChatOptions.Builder` is fluent options builder. + +=== Low-level MiniMaxApi Client [[low-level-api]] + +The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java[MiniMaxApi] provides is lightweight Java client for link:https://www.minimaxi.com/document/guides/chat-model/V2[MiniMax API]. + +Here is a simple snippet how to use the api programmatically: + +[source,java] +---- +MiniMaxApi miniMaxApi = + new MiniMaxApi(System.getenv("MINIMAX_API_KEY")); + +ChatCompletionMessage chatCompletionMessage = + new ChatCompletionMessage("Hello world", Role.USER); + +// Sync request +ResponseEntity response = miniMaxApi.chatCompletionEntity( + new ChatCompletionRequest(List.of(chatCompletionMessage), MiniMaxApi.ChatModel.GLM_3_Turbo.getValue(), 0.7f, false)); + +// Streaming request +Flux streamResponse = miniMaxApi.chatCompletionStream( + new ChatCompletionRequest(List.of(chatCompletionMessage), MiniMaxApi.ChatModel.GLM_3_Turbo.getValue(), 0.7f, true)); +---- + +Follow the https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java[MiniMaxApi.java]'s JavaDoc for further information. + +==== MiniMaxApi Samples +* The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiIT.java[MiniMaxApiIT.java] test provides some general examples how to use the lightweight library. + +* The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiToolFunctionCallIT.java.java[MiniMaxApiToolFunctionCallIT.java] test shows how to use the low-level API to call tool functions. \ No newline at end of file diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/minimax-embeddings.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/minimax-embeddings.adoc new file mode 100644 index 00000000000..da452c69dd8 --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/minimax-embeddings.adoc @@ -0,0 +1,198 @@ += MiniMax Chat + +Spring AI supports the various AI language models from MiniMax. You can interact with MiniMax language models and create a multilingual conversational assistant based on MiniMax models. + +== Prerequisites + +You will need to create an API with MiniMax to access MiniMax language models. + +Create an account at https://www.minimaxi.com/login[MiniMax registration page] and generate the token on the https://www.minimaxi.com/user-center/basic-information/interface-key[API Keys page]. +The Spring AI project defines a configuration property named `spring.ai.minimax.api-key` that you should set to the value of the `API Key` obtained from https://www.minimaxi.com/user-center/basic-information/interface-key[API Keys page]. +Exporting an environment variable is one way to set that configuration property: + +[source,shell] +---- +export SPRING_AI_MINIMAX_API_KEY= +---- + +=== Add Repositories and BOM + +Spring AI artifacts are published in Spring Milestone and Snapshot repositories. +Refer to the xref:getting-started.adoc#repositories[Repositories] section to add these repositories to your build system. + +To help with dependency management, Spring AI provides a BOM (bill of materials) to ensure that a consistent version of Spring AI is used throughout the entire project. Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build system. + + + +== Auto-configuration + +Spring AI provides Spring Boot auto-configuration for the Azure MiniMax Embedding Client. +To enable it add the following dependency to your project's Maven `pom.xml` file: + +[source, xml] +---- + + org.springframework.ai + spring-ai-minimax-spring-boot-starter + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-minimax-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. + +=== Embedding Properties + +==== Retry Properties + +The prefix `spring.ai.retry` is used as the property prefix that lets you configure the retry mechanism for the MiniMax Embedding client. + +[cols="3,5,1"] +|==== +| Property | Description | Default + +| spring.ai.retry.max-attempts | Maximum number of retry attempts. | 10 +| spring.ai.retry.backoff.initial-interval | Initial sleep duration for the exponential backoff policy. | 2 sec. +| spring.ai.retry.backoff.multiplier | Backoff interval multiplier. | 5 +| spring.ai.retry.backoff.max-interval | Maximum backoff duration. | 3 min. +| spring.ai.retry.on-client-errors | If false, throw a NonTransientAiException, and do not attempt retry for `4xx` client error codes | false +| spring.ai.retry.exclude-on-http-codes | List of HTTP status codes that should not trigger a retry (e.g. to throw NonTransientAiException). | empty +| spring.ai.retry.on-http-codes | List of HTTP status codes that should trigger a retry (e.g. to throw TransientAiException). | empty +|==== + +==== Connection Properties + +The prefix `spring.ai.minimax` is used as the property prefix that lets you connect to MiniMax. + +[cols="3,5,1"] +|==== +| Property | Description | Default + +| spring.ai.minimax.base-url | The URL to connect to | https://api.minimax.chat +| spring.ai.minimax.api-key | The API Key | - +|==== + +==== Configuration Properties + +The prefix `spring.ai.minimax.embedding` is property prefix that configures the `EmbeddingClient` implementation for MiniMax. + +[cols="3,5,1"] +|==== +| Property | Description | Default + +| spring.ai.minimax.embedding.enabled | Enable MiniMax embedding client. | true +| spring.ai.minimax.embedding.base-url | Optional overrides the spring.ai.minimax.base-url to provide embedding specific url | - +| spring.ai.minimax.embedding.api-key | Optional overrides the spring.ai.minimax.api-key to provide embedding specific api-key | - +| spring.ai.minimax.embedding.options.model | The model to use | embo-01 +|==== + +NOTE: You can override the common `spring.ai.minimax.base-url` and `spring.ai.minimax.api-key` for the `ChatClient` and `EmbeddingClient` implementations. +The `spring.ai.minimax.embedding.base-url` and `spring.ai.minimax.embedding.api-key` properties if set take precedence over the common properties. +Similarly, the `spring.ai.minimax.embedding.base-url` and `spring.ai.minimax.embedding.api-key` properties if set take precedence over the common properties. +This is useful if you want to use different MiniMax accounts for different models and different model endpoints. + +TIP: All properties prefixed with `spring.ai.minimax.embedding.options` can be overridden at runtime by adding a request specific <> to the `EmbeddingRequest` call. + +== Runtime Options [[embedding-options]] + +The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxEmbeddingOptions.java[MiniMaxEmbeddingOptions.java] provides the MiniMax configurations, such as the model to use and etc. + +The default options can be configured using the `spring.ai.minimax.embedding.options` properties as well. + +At start-time use the `MiniMaxEmbeddingClient` constructor to set the default options used for all embedding requests. +At run-time you can override the default options, using a `MiniMaxEmbeddingOptions` instance as part of your `EmbeddingRequest`. + +For example to override the default model name for a specific request: + +[source,java] +---- +EmbeddingResponse embeddingResponse = embeddingClient.call( + new EmbeddingRequest(List.of("Hello World", "World is big and salvation is near"), + MiniMaxEmbeddingOptions.builder() + .withModel("Different-Embedding-Model-Deployment-Name") + .build())); +---- + +== Sample Controller + +This will create a `EmbeddingClient` implementation that you can inject into your class. +Here is an example of a simple `@Controller` class that uses the `EmbeddingClient` implementation. + +[source,application.properties] +---- +spring.ai.minimax.api-key=YOUR_API_KEY +spring.ai.minimax.embedding.options.model=embo-01 +---- + +[source,java] +---- +@RestController +public class EmbeddingController { + + private final EmbeddingClient embeddingClient; + + @Autowired + public EmbeddingController(EmbeddingClient embeddingClient) { + this.embeddingClient = embeddingClient; + } + + @GetMapping("/ai/embedding") + public Map embed(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { + EmbeddingResponse embeddingResponse = this.embeddingClient.embedForResponse(List.of(message)); + return Map.of("embedding", embeddingResponse); + } +} +---- + +== Manual Configuration + +If you are not using Spring Boot, you can manually configure the MiniMax Embedding Client. +For this add the `spring-ai-minimax` dependency to your project's Maven `pom.xml` file: +[source, xml] +---- + + org.springframework.ai + spring-ai-minimax + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-minimax' +} +---- + +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. + +NOTE: The `spring-ai-minimax` dependency provides access also to the `MiniMaxChatClient`. +For more information about the `MiniMaxChatClient` refer to the link:../chat/minimax-chat.html[MiniMax Chat Client] section. + +Next, create an `MiniMaxEmbeddingClient` instance and use it to compute the similarity between two input texts: + +[source,java] +---- +var miniMaxApi = new MiniMaxApi(System.getenv("MINIMAX_API_KEY")); + +var embeddingClient = new MiniMaxEmbeddingClient(miniMaxApi) + .withDefaultOptions(MiniMaxChatOptions.build() + .withModel("embo-01") + .build()); + +EmbeddingResponse embeddingResponse = embeddingClient + .embedForResponse(List.of("Hello World", "World is big and salvation is near")); +---- + +The `MiniMaxEmbeddingOptions` provides the configuration information for the embedding requests. +The options class offers a `builder()` for easy options creation. + + diff --git a/spring-ai-spring-boot-autoconfigure/pom.xml b/spring-ai-spring-boot-autoconfigure/pom.xml index 09b48fc7189..0d05d589c23 100644 --- a/spring-ai-spring-boot-autoconfigure/pom.xml +++ b/spring-ai-spring-boot-autoconfigure/pom.xml @@ -267,6 +267,13 @@ true + + org.springframework.ai + spring-ai-minimax + ${project.parent.version} + true + + diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfiguration.java new file mode 100644 index 00000000000..f3c805db2ce --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfiguration.java @@ -0,0 +1,104 @@ +/* + * Copyright 2023 - 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.minimax; + +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.minimax.MiniMaxChatClient; +import org.springframework.ai.minimax.MiniMaxEmbeddingClient; +import org.springframework.ai.minimax.api.MiniMaxApi; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.model.function.FunctionCallbackContext; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; + +import java.util.List; + +/** + * @author Geng Rong + */ +@AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class }) +@ConditionalOnClass(MiniMaxApi.class) +@EnableConfigurationProperties({ MiniMaxConnectionProperties.class, MiniMaxChatProperties.class, + MiniMaxEmbeddingProperties.class }) +public class MiniMaxAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = MiniMaxChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public MiniMaxChatClient miniMaxChatClient(MiniMaxConnectionProperties commonProperties, + MiniMaxChatProperties chatProperties, RestClient.Builder restClientBuilder, + List toolFunctionCallbacks, FunctionCallbackContext functionCallbackContext, + RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler) { + + var miniMaxApi = miniMaxApi(chatProperties.getBaseUrl(), commonProperties.getBaseUrl(), + chatProperties.getApiKey(), commonProperties.getApiKey(), restClientBuilder, responseErrorHandler); + + if (!CollectionUtils.isEmpty(toolFunctionCallbacks)) { + chatProperties.getOptions().getFunctionCallbacks().addAll(toolFunctionCallbacks); + } + + return new MiniMaxChatClient(miniMaxApi, chatProperties.getOptions(), functionCallbackContext, retryTemplate); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = MiniMaxEmbeddingProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public MiniMaxEmbeddingClient miniMaxEmbeddingClient(MiniMaxConnectionProperties commonProperties, + MiniMaxEmbeddingProperties embeddingProperties, RestClient.Builder restClientBuilder, + RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler) { + + var miniMaxApi = miniMaxApi(embeddingProperties.getBaseUrl(), commonProperties.getBaseUrl(), + embeddingProperties.getApiKey(), commonProperties.getApiKey(), restClientBuilder, responseErrorHandler); + + return new MiniMaxEmbeddingClient(miniMaxApi, embeddingProperties.getMetadataMode(), + embeddingProperties.getOptions(), retryTemplate); + } + + private MiniMaxApi miniMaxApi(String baseUrl, String commonBaseUrl, String apiKey, String commonApiKey, + RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { + + String resolvedBaseUrl = StringUtils.hasText(baseUrl) ? baseUrl : commonBaseUrl; + Assert.hasText(resolvedBaseUrl, "MiniMax base URL must be set"); + + String resolvedApiKey = StringUtils.hasText(apiKey) ? apiKey : commonApiKey; + Assert.hasText(resolvedApiKey, "MiniMax API key must be set"); + + return new MiniMaxApi(resolvedBaseUrl, resolvedApiKey, restClientBuilder, responseErrorHandler); + } + + @Bean + @ConditionalOnMissingBean + public FunctionCallbackContext springAiFunctionManager(ApplicationContext context) { + FunctionCallbackContext manager = new FunctionCallbackContext(); + manager.setApplicationContext(context); + return manager; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxChatProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxChatProperties.java new file mode 100644 index 00000000000..c7f3716f386 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxChatProperties.java @@ -0,0 +1,62 @@ +/* + * Copyright 2023 - 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.minimax; + +import org.springframework.ai.minimax.MiniMaxChatOptions; +import org.springframework.ai.minimax.api.MiniMaxApi; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * @author Geng Rong + */ +@ConfigurationProperties(MiniMaxChatProperties.CONFIG_PREFIX) +public class MiniMaxChatProperties extends MiniMaxParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.minimax.chat"; + + public static final String DEFAULT_CHAT_MODEL = MiniMaxApi.ChatModel.ABAB_5_5_Chat.value; + + private static final Double DEFAULT_TEMPERATURE = 0.7; + + /** + * Enable MiniMax chat client. + */ + private boolean enabled = true; + + @NestedConfigurationProperty + private MiniMaxChatOptions options = MiniMaxChatOptions.builder() + .withModel(DEFAULT_CHAT_MODEL) + .withTemperature(DEFAULT_TEMPERATURE.floatValue()) + .build(); + + public MiniMaxChatOptions getOptions() { + return options; + } + + public void setOptions(MiniMaxChatOptions options) { + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxConnectionProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxConnectionProperties.java new file mode 100644 index 00000000000..1019e849949 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxConnectionProperties.java @@ -0,0 +1,31 @@ +/* + * Copyright 2023 - 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.minimax; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(MiniMaxConnectionProperties.CONFIG_PREFIX) +public class MiniMaxConnectionProperties extends MiniMaxParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.minimax"; + + public static final String DEFAULT_BASE_URL = "https://api.minimax.chat"; + + public MiniMaxConnectionProperties() { + super.setBaseUrl(DEFAULT_BASE_URL); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxEmbeddingProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxEmbeddingProperties.java new file mode 100644 index 00000000000..21fbb752e90 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxEmbeddingProperties.java @@ -0,0 +1,70 @@ +/* + * Copyright 2023 - 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.minimax; + +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.minimax.MiniMaxEmbeddingOptions; +import org.springframework.ai.minimax.api.MiniMaxApi; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * @author Geng Rong + */ +@ConfigurationProperties(MiniMaxEmbeddingProperties.CONFIG_PREFIX) +public class MiniMaxEmbeddingProperties extends MiniMaxParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.minimax.embedding"; + + public static final String DEFAULT_EMBEDDING_MODEL = MiniMaxApi.EmbeddingModel.Embo_01.value; + + /** + * Enable MiniMax embedding client. + */ + private boolean enabled = true; + + private MetadataMode metadataMode = MetadataMode.EMBED; + + @NestedConfigurationProperty + private MiniMaxEmbeddingOptions options = MiniMaxEmbeddingOptions.builder() + .withModel(DEFAULT_EMBEDDING_MODEL) + .build(); + + public MiniMaxEmbeddingOptions getOptions() { + return this.options; + } + + public void setOptions(MiniMaxEmbeddingOptions options) { + this.options = options; + } + + public MetadataMode getMetadataMode() { + return this.metadataMode; + } + + public void setMetadataMode(MetadataMode metadataMode) { + this.metadataMode = metadataMode; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxParentProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxParentProperties.java new file mode 100644 index 00000000000..1f8f9f6b722 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/minimax/MiniMaxParentProperties.java @@ -0,0 +1,43 @@ +/* + * Copyright 2023 - 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.minimax; + +/** + * @author Geng Rong + */ +class MiniMaxParentProperties { + + private String apiKey; + + private String baseUrl; + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackInPromptIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackInPromptIT.java new file mode 100644 index 00000000000..7026812a6ed --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackInPromptIT.java @@ -0,0 +1,113 @@ +/* + * Copyright 2023 - 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.minimax; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.ChatResponse; +import org.springframework.ai.chat.Generation; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.minimax.MiniMaxChatClient; +import org.springframework.ai.minimax.MiniMaxChatOptions; +import org.springframework.ai.model.function.FunctionCallbackWrapper; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import reactor.core.publisher.Flux; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "MINIMAX_API_KEY", matches = ".*") +public class FunctionCallbackInPromptIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallbackInPromptIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.minimax.apiKey=" + System.getenv("MINIMAX_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)); + + @Test + void functionCallTest() { + contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6-chat").run(context -> { + + MiniMaxChatClient chatClient = context.getBean(MiniMaxChatClient.class); + + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + + var promptOptions = MiniMaxChatOptions.builder() + .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) + .withName("CurrentWeatherService") + .withDescription("Get the weather in location") + .withResponseConverter((response) -> "" + response.temp() + response.unit()) + .build())) + .build(); + + ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), promptOptions)); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getContent()).contains("30.0", "10.0", "15.0"); + }); + } + + @Test + void streamingFunctionCallTest() { + + contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6-chat").run(context -> { + + MiniMaxChatClient chatClient = context.getBean(MiniMaxChatClient.class); + + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + + var promptOptions = MiniMaxChatOptions.builder() + .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) + .withName("CurrentWeatherService") + .withDescription("Get the weather in location") + .withResponseConverter((response) -> "" + response.temp() + response.unit()) + .build())) + .build(); + + Flux response = chatClient.stream(new Prompt(List.of(userMessage), promptOptions)); + + String content = response.collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getContent) + .collect(Collectors.joining()); + logger.info("Response: {}", content); + + assertThat(content).containsAnyOf("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + }); + } + +} \ No newline at end of file diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackWithPlainFunctionBeanIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackWithPlainFunctionBeanIT.java new file mode 100644 index 00000000000..1c1492e65f9 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackWithPlainFunctionBeanIT.java @@ -0,0 +1,171 @@ +/* + * Copyright 2023 - 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.minimax; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.ChatResponse; +import org.springframework.ai.chat.Generation; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.minimax.MiniMaxChatClient; +import org.springframework.ai.minimax.MiniMaxChatOptions; +import org.springframework.ai.model.function.FunctionCallingOptions; +import org.springframework.ai.model.function.FunctionCallingOptionsBuilder.PortableFunctionCallingOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; +import reactor.core.publisher.Flux; + +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "MINIMAX_API_KEY", matches = ".*") +class FunctionCallbackWithPlainFunctionBeanIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallbackWithPlainFunctionBeanIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.minimax.apiKey=" + System.getenv("MINIMAX_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6-chat").run(context -> { + + MiniMaxChatClient chatClient = context.getBean(MiniMaxChatClient.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + + ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), + MiniMaxChatOptions.builder().withFunction("weatherFunction").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getContent()).contains("30", "10", "15"); + + // Test weatherFunctionTwo + response = chatClient.call(new Prompt(List.of(userMessage), + MiniMaxChatOptions.builder().withFunction("weatherFunctionTwo").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getContent()).contains("30", "10", "15"); + + }); + } + + @Test + void functionCallWithPortableFunctionCallingOptions() { + contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6-chat").run(context -> { + + MiniMaxChatClient chatClient = context.getBean(MiniMaxChatClient.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + + PortableFunctionCallingOptions functionOptions = FunctionCallingOptions.builder() + .withFunction("weatherFunction") + .build(); + + ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), functionOptions)); + + logger.info("Response: {}", response); + }); + } + + @Test + void streamFunctionCallTest() { + contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6-chat").run(context -> { + + MiniMaxChatClient chatClient = context.getBean(MiniMaxChatClient.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + + Flux response = chatClient.stream(new Prompt(List.of(userMessage), + MiniMaxChatOptions.builder().withFunction("weatherFunction").build())); + + String content = response.collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getContent) + .collect(Collectors.joining()); + logger.info("Response: {}", content); + + assertThat(content).containsAnyOf("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + + // Test weatherFunctionTwo + response = chatClient.stream(new Prompt(List.of(userMessage), + MiniMaxChatOptions.builder().withFunction("weatherFunctionTwo").build())); + + content = response.collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getContent) + .collect(Collectors.joining()); + logger.info("Response: {}", content); + + assertThat(content).containsAnyOf("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + }); + } + + @Configuration + static class Config { + + @Bean + @Description("Get the weather in location") + public Function weatherFunction() { + return new MockWeatherService(); + } + + // Relies on the Request's JsonClassDescription annotation to provide the + // function description. + @Bean + public Function weatherFunctionTwo() { + MockWeatherService weatherService = new MockWeatherService(); + return (weatherService::apply); + } + + } + +} \ No newline at end of file diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackWrapperIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackWrapperIT.java new file mode 100644 index 00000000000..75530376fc2 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/FunctionCallbackWrapperIT.java @@ -0,0 +1,119 @@ +/* + * Copyright 2023 - 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.minimax; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.ChatResponse; +import org.springframework.ai.chat.Generation; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.minimax.MiniMaxChatClient; +import org.springframework.ai.minimax.MiniMaxChatOptions; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.model.function.FunctionCallbackWrapper; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import reactor.core.publisher.Flux; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "MINIMAX_API_KEY", matches = ".*") +public class FunctionCallbackWrapperIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallbackWrapperIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.minimax.apiKey=" + System.getenv("MINIMAX_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6-chat").run(context -> { + + MiniMaxChatClient chatClient = context.getBean(MiniMaxChatClient.class); + + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + + ChatResponse response = chatClient.call( + new Prompt(List.of(userMessage), MiniMaxChatOptions.builder().withFunction("WeatherInfo").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getContent()).contains("30.0", "10.0", "15.0"); + + }); + } + + @Test + void streamFunctionCallTest() { + contextRunner.withPropertyValues("spring.ai.minimax.chat.options.model=abab6-chat").run(context -> { + + MiniMaxChatClient chatClient = context.getBean(MiniMaxChatClient.class); + + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + + Flux response = chatClient.stream( + new Prompt(List.of(userMessage), MiniMaxChatOptions.builder().withFunction("WeatherInfo").build())); + + String content = response.collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getContent) + .collect(Collectors.joining()); + logger.info("Response: {}", content); + + assertThat(content).containsAnyOf("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + + }); + } + + @Configuration + static class Config { + + @Bean + public FunctionCallback weatherFunctionInfo() { + + return FunctionCallbackWrapper.builder(new MockWeatherService()) + .withName("WeatherInfo") + .withDescription("Get the weather in location") + .withResponseConverter((response) -> "" + response.temp() + response.unit()) + .build(); + } + + } + +} \ No newline at end of file diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfigurationIT.java new file mode 100644 index 00000000000..d400b2c4703 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxAutoConfigurationIT.java @@ -0,0 +1,93 @@ +/* + * Copyright 2023 - 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.minimax; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.ChatResponse; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.minimax.MiniMaxChatClient; +import org.springframework.ai.minimax.MiniMaxEmbeddingClient; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import reactor.core.publisher.Flux; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "MINIMAX_API_KEY", matches = ".*") +public class MiniMaxAutoConfigurationIT { + + private static final Log logger = LogFactory.getLog(MiniMaxAutoConfigurationIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.minimax.apiKey=" + System.getenv("MINIMAX_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)); + + @Test + void generate() { + contextRunner.run(context -> { + MiniMaxChatClient client = context.getBean(MiniMaxChatClient.class); + String response = client.call("Hello"); + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void generateStreaming() { + contextRunner.run(context -> { + MiniMaxChatClient client = context.getBean(MiniMaxChatClient.class); + Flux responseFlux = client.stream(new Prompt(new UserMessage("Hello"))); + String response = responseFlux.collectList().block().stream().map(chatResponse -> { + return chatResponse.getResults().get(0).getOutput().getContent(); + }).collect(Collectors.joining()); + + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void embedding() { + contextRunner.run(context -> { + MiniMaxEmbeddingClient embeddingClient = context.getBean(MiniMaxEmbeddingClient.class); + + EmbeddingResponse embeddingResponse = embeddingClient + .embedForResponse(List.of("Hello World", "World is big and salvation is near")); + assertThat(embeddingResponse.getResults()).hasSize(2); + assertThat(embeddingResponse.getResults().get(0).getOutput()).isNotEmpty(); + assertThat(embeddingResponse.getResults().get(0).getIndex()).isEqualTo(0); + assertThat(embeddingResponse.getResults().get(1).getOutput()).isNotEmpty(); + assertThat(embeddingResponse.getResults().get(1).getIndex()).isEqualTo(1); + + assertThat(embeddingClient.dimensions()).isEqualTo(1536); + }); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxPropertiesTests.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxPropertiesTests.java new file mode 100644 index 00000000000..31e1035081b --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MiniMaxPropertiesTests.java @@ -0,0 +1,329 @@ +/* + * Copyright 2023 - 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.minimax; + +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import org.skyscreamer.jsonassert.JSONCompareMode; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.minimax.MiniMaxChatClient; +import org.springframework.ai.minimax.MiniMaxEmbeddingClient; +import org.springframework.ai.minimax.api.MiniMaxApi; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit Tests for + * {@link org.springframework.ai.autoconfigure.minimax.MiniMaxConnectionProperties}, + * {@link org.springframework.ai.autoconfigure.minimax.MiniMaxChatProperties} and + * {@link org.springframework.ai.autoconfigure.minimax.MiniMaxEmbeddingProperties}. + * + * @author Geng Rong + */ +public class MiniMaxPropertiesTests { + + @Test + public void chatProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.minimax.base-url=TEST_BASE_URL", + "spring.ai.minimax.api-key=abc123", + "spring.ai.minimax.chat.options.model=MODEL_XYZ", + "spring.ai.minimax.chat.options.temperature=0.55") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(MiniMaxChatProperties.class); + var connectionProperties = context.getBean(MiniMaxConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(chatProperties.getApiKey()).isNull(); + assertThat(chatProperties.getBaseUrl()).isNull(); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55f); + }); + } + + @Test + public void chatOverrideConnectionProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.minimax.base-url=TEST_BASE_URL", + "spring.ai.minimax.api-key=abc123", + "spring.ai.minimax.chat.base-url=TEST_BASE_URL2", + "spring.ai.minimax.chat.api-key=456", + "spring.ai.minimax.chat.options.model=MODEL_XYZ", + "spring.ai.minimax.chat.options.temperature=0.55") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(MiniMaxChatProperties.class); + var connectionProperties = context.getBean(MiniMaxConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(chatProperties.getApiKey()).isEqualTo("456"); + assertThat(chatProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2"); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55f); + }); + } + + @Test + public void embeddingProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.minimax.base-url=TEST_BASE_URL", + "spring.ai.minimax.api-key=abc123", + "spring.ai.minimax.embedding.options.model=MODEL_XYZ") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(MiniMaxEmbeddingProperties.class); + var connectionProperties = context.getBean(MiniMaxConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(embeddingProperties.getApiKey()).isNull(); + assertThat(embeddingProperties.getBaseUrl()).isNull(); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + public void embeddingOverrideConnectionProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.minimax.base-url=TEST_BASE_URL", + "spring.ai.minimax.api-key=abc123", + "spring.ai.minimax.embedding.base-url=TEST_BASE_URL2", + "spring.ai.minimax.embedding.api-key=456", + "spring.ai.minimax.embedding.options.model=MODEL_XYZ") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(MiniMaxEmbeddingProperties.class); + var connectionProperties = context.getBean(MiniMaxConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(embeddingProperties.getApiKey()).isEqualTo("456"); + assertThat(embeddingProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + public void chatOptionsTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.minimax.api-key=API_KEY", + "spring.ai.minimax.base-url=TEST_BASE_URL", + + "spring.ai.minimax.chat.options.model=MODEL_XYZ", + "spring.ai.minimax.chat.options.frequencyPenalty=-1.5", + "spring.ai.minimax.chat.options.logitBias.myTokenId=-5", + "spring.ai.minimax.chat.options.maxTokens=123", + "spring.ai.minimax.chat.options.n=10", + "spring.ai.minimax.chat.options.presencePenalty=0", + "spring.ai.minimax.chat.options.responseFormat.type=json", + "spring.ai.minimax.chat.options.seed=66", + "spring.ai.minimax.chat.options.stop=boza,koza", + "spring.ai.minimax.chat.options.temperature=0.55", + "spring.ai.minimax.chat.options.topP=0.56", + + // "spring.ai.minimax.chat.options.toolChoice.functionName=toolChoiceFunctionName", + "spring.ai.minimax.chat.options.toolChoice=" + ModelOptionsUtils.toJsonString(MiniMaxApi.ChatCompletionRequest.ToolChoiceBuilder.FUNCTION("toolChoiceFunctionName")), + + "spring.ai.minimax.chat.options.tools[0].function.name=myFunction1", + "spring.ai.minimax.chat.options.tools[0].function.description=function description", + "spring.ai.minimax.chat.options.tools[0].function.jsonSchema=" + """ + { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state e.g. San Francisco, CA" + }, + "lat": { + "type": "number", + "description": "The city latitude" + }, + "lon": { + "type": "number", + "description": "The city longitude" + }, + "unit": { + "type": "string", + "enum": ["c", "f"] + } + }, + "required": ["location", "lat", "lon", "unit"] + } + """ + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(MiniMaxChatProperties.class); + var connectionProperties = context.getBean(MiniMaxConnectionProperties.class); + var embeddingProperties = context.getBean(MiniMaxEmbeddingProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("embo-01"); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getFrequencyPenalty()).isEqualTo(-1.5f); + assertThat(chatProperties.getOptions().getMaxTokens()).isEqualTo(123); + assertThat(chatProperties.getOptions().getN()).isEqualTo(10); + assertThat(chatProperties.getOptions().getPresencePenalty()).isEqualTo(0); + assertThat(chatProperties.getOptions().getResponseFormat()) + .isEqualTo(new MiniMaxApi.ChatCompletionRequest.ResponseFormat("json")); + assertThat(chatProperties.getOptions().getSeed()).isEqualTo(66); + assertThat(chatProperties.getOptions().getStop()).contains("boza", "koza"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55f); + assertThat(chatProperties.getOptions().getTopP()).isEqualTo(0.56f); + + JSONAssert.assertEquals("{\"type\":\"function\",\"function\":{\"name\":\"toolChoiceFunctionName\"}}", + chatProperties.getOptions().getToolChoice(), JSONCompareMode.LENIENT); + + assertThat(chatProperties.getOptions().getTools()).hasSize(1); + var tool = chatProperties.getOptions().getTools().get(0); + assertThat(tool.type()).isEqualTo(MiniMaxApi.FunctionTool.Type.FUNCTION); + var function = tool.function(); + assertThat(function.name()).isEqualTo("myFunction1"); + assertThat(function.description()).isEqualTo("function description"); + assertThat(function.parameters()).isNotEmpty(); + }); + } + + @Test + public void embeddingOptionsTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.minimax.api-key=API_KEY", + "spring.ai.minimax.base-url=TEST_BASE_URL", + + "spring.ai.minimax.embedding.options.model=MODEL_XYZ", + "spring.ai.minimax.embedding.options.encodingFormat=MyEncodingFormat" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + var connectionProperties = context.getBean(MiniMaxConnectionProperties.class); + var embeddingProperties = context.getBean(MiniMaxEmbeddingProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + void embeddingActivation() { + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.minimax.api-key=API_KEY", "spring.ai.minimax.base-url=TEST_BASE_URL", + "spring.ai.minimax.embedding.enabled=false") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(MiniMaxEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(MiniMaxEmbeddingClient.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.minimax.api-key=API_KEY", "spring.ai.minimax.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(MiniMaxEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(MiniMaxEmbeddingClient.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.minimax.api-key=API_KEY", "spring.ai.minimax.base-url=TEST_BASE_URL", + "spring.ai.minimax.embedding.enabled=true") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(MiniMaxEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(MiniMaxEmbeddingClient.class)).isNotEmpty(); + }); + } + + @Test + void chatActivation() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.minimax.api-key=API_KEY", "spring.ai.minimax.base-url=TEST_BASE_URL", + "spring.ai.minimax.chat.enabled=false") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(MiniMaxChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(MiniMaxChatClient.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.minimax.api-key=API_KEY", "spring.ai.minimax.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(MiniMaxChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(MiniMaxChatClient.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.minimax.api-key=API_KEY", "spring.ai.minimax.base-url=TEST_BASE_URL", + "spring.ai.minimax.chat.enabled=true") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, MiniMaxAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(MiniMaxChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(MiniMaxChatClient.class)).isNotEmpty(); + }); + + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MockWeatherService.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MockWeatherService.java new file mode 100644 index 00000000000..29967264ea7 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/minimax/MockWeatherService.java @@ -0,0 +1,94 @@ +/* + * Copyright 2023 - 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.minimax; + +import com.fasterxml.jackson.annotation.JsonClassDescription; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +import java.util.function.Function; + +/** + * Mock 3rd party weather service. + * + * @author Geng Rong + */ +public class MockWeatherService implements Function { + + /** + * Weather Function request. + */ + @JsonInclude(Include.NON_NULL) + @JsonClassDescription("Weather API request") + public record Request(@JsonProperty(required = true, + value = "location") @JsonPropertyDescription("The city and state e.g. San Francisco, CA") String location, + @JsonProperty(required = true, value = "lat") @JsonPropertyDescription("The city latitude") double lat, + @JsonProperty(required = true, value = "lon") @JsonPropertyDescription("The city longitude") double lon, + @JsonProperty(required = true, value = "unit") @JsonPropertyDescription("Temperature unit") Unit unit) { + } + + /** + * Temperature units. + */ + public enum Unit { + + /** + * Celsius. + */ + C("metric"), + /** + * Fahrenheit. + */ + F("imperial"); + + /** + * Human readable unit name. + */ + public final String unitName; + + private Unit(String text) { + this.unitName = text; + } + + } + + /** + * Weather Function response. + */ + public record Response(double temp, double feels_like, double temp_min, double temp_max, int pressure, int humidity, + Unit unit) { + } + + @Override + public Response apply(Request request) { + + double temperature = 0; + if (request.location().contains("Paris")) { + temperature = 15; + } + else if (request.location().contains("Tokyo")) { + temperature = 10; + } + else if (request.location().contains("San Francisco")) { + temperature = 30; + } + + return new Response(temperature, 15, 20, 2, 53, 45, Unit.C); + } + +} \ No newline at end of file diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-minimax/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-minimax/pom.xml new file mode 100644 index 00000000000..3004b42a9db --- /dev/null +++ b/spring-ai-spring-boot-starters/spring-ai-starter-minimax/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-minimax-spring-boot-starter + jar + Spring AI Starter - MiniMax + Spring AI MiniMax Auto Configuration + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.ai + spring-ai-spring-boot-autoconfigure + ${project.parent.version} + + + + org.springframework.ai + spring-ai-minimax + ${project.parent.version} + + + + From 99c385778829c00141d066c6d8d5b2ed5b858bb2 Mon Sep 17 00:00:00 2001 From: GR Date: Tue, 23 Apr 2024 09:26:32 +0800 Subject: [PATCH 37/39] Add Support for ZhiPu AI model * See https://www.zhipuai.cn/ --- models/spring-ai-zhipuai/README.md | 5 + models/spring-ai-zhipuai/pom.xml | 59 ++ .../ai/zhipuai/ZhiPuAiChatClient.java | 375 ++++++++ .../ai/zhipuai/ZhiPuAiChatOptions.java | 490 ++++++++++ .../ai/zhipuai/ZhiPuAiEmbeddingClient.java | 149 ++++ .../ai/zhipuai/ZhiPuAiEmbeddingOptions.java | 70 ++ .../ai/zhipuai/ZhiPuAiImageClient.java | 129 +++ .../ai/zhipuai/ZhiPuAiImageOptions.java | 146 +++ .../ai/zhipuai/aot/ZhiPuAiRuntimeHints.java | 46 + .../ai/zhipuai/api/ApiUtils.java | 37 + .../ai/zhipuai/api/ZhiPuAiApi.java | 839 ++++++++++++++++++ .../ai/zhipuai/api/ZhiPuAiImageApi.java | 130 +++ .../ZhiPuAiStreamFunctionCallingHelper.java | 195 ++++ .../ai/zhipuai/metadata/ZhiPuAiUsage.java | 65 ++ .../resources/META-INF/spring/aot.factories | 2 + .../zhipuai/ChatCompletionRequestTests.java | 144 +++ .../ai/zhipuai/ZhiPuAiTestConfiguration.java | 65 ++ .../ai/zhipuai/api/MockWeatherService.java | 92 ++ .../ai/zhipuai/api/ZhiPuAiApiIT.java | 68 ++ .../api/ZhiPuAiApiToolFunctionCallIT.java | 148 +++ .../ai/zhipuai/api/ZhiPuAiRetryTests.java | 212 +++++ .../zhipuai/image/ZhiPuAiImageClientIT.java | 56 ++ .../test/resources/prompts/system-message.st | 3 + pom.xml | 4 +- spring-ai-bom/pom.xml | 12 + .../src/main/antora/modules/ROOT/nav.adoc | 4 + .../functions/zhipuai-chat-functions.adoc | 226 +++++ .../ROOT/pages/api/chat/zhipuai-chat.adoc | 250 ++++++ .../api/embeddings/zhipuai-embeddings.adoc | 198 +++++ .../ROOT/pages/api/image/zhipuai-image.adoc | 117 +++ spring-ai-spring-boot-autoconfigure/pom.xml | 7 + .../zhipuai/ZhiPuAiAutoConfiguration.java | 128 +++ .../zhipuai/ZhiPuAiChatProperties.java | 62 ++ .../zhipuai/ZhiPuAiConnectionProperties.java | 31 + .../zhipuai/ZhiPuAiEmbeddingProperties.java | 70 ++ .../zhipuai/ZhiPuAiImageProperties.java | 57 ++ .../zhipuai/ZhiPuAiParentProperties.java | 43 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../zhipuai/ZhiPuAiAutoConfigurationIT.java | 107 +++ .../zhipuai/ZhiPuAiPropertiesTests.java | 438 +++++++++ .../tool/FunctionCallbackInPromptIT.java | 114 +++ ...nctionCallbackWithPlainFunctionBeanIT.java | 172 ++++ .../tool/FunctionCallbackWrapperIT.java | 120 +++ .../zhipuai/tool/MockWeatherService.java | 94 ++ .../spring-ai-starter-zhipuai/pom.xml | 42 + 45 files changed, 5820 insertions(+), 2 deletions(-) create mode 100644 models/spring-ai-zhipuai/README.md create mode 100644 models/spring-ai-zhipuai/pom.xml create mode 100644 models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiChatClient.java create mode 100644 models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiChatOptions.java create mode 100644 models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiEmbeddingClient.java create mode 100644 models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiEmbeddingOptions.java create mode 100644 models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiImageClient.java create mode 100644 models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiImageOptions.java create mode 100644 models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/aot/ZhiPuAiRuntimeHints.java create mode 100644 models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ApiUtils.java create mode 100644 models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiApi.java create mode 100644 models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiImageApi.java create mode 100644 models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiStreamFunctionCallingHelper.java create mode 100644 models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/metadata/ZhiPuAiUsage.java create mode 100644 models/spring-ai-zhipuai/src/main/resources/META-INF/spring/aot.factories create mode 100644 models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/ChatCompletionRequestTests.java create mode 100644 models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/ZhiPuAiTestConfiguration.java create mode 100644 models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/MockWeatherService.java create mode 100644 models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiApiIT.java create mode 100644 models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiApiToolFunctionCallIT.java create mode 100644 models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiRetryTests.java create mode 100644 models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/image/ZhiPuAiImageClientIT.java create mode 100644 models/spring-ai-zhipuai/src/test/resources/prompts/system-message.st create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/functions/zhipuai-chat-functions.adoc create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/zhipuai-chat.adoc create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/zhipuai-embeddings.adoc create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/zhipuai-image.adoc create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiAutoConfiguration.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiChatProperties.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiConnectionProperties.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiEmbeddingProperties.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiImageProperties.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiParentProperties.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiAutoConfigurationIT.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiPropertiesTests.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackInPromptIT.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackWithPlainFunctionBeanIT.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackWrapperIT.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/MockWeatherService.java create mode 100644 spring-ai-spring-boot-starters/spring-ai-starter-zhipuai/pom.xml diff --git a/models/spring-ai-zhipuai/README.md b/models/spring-ai-zhipuai/README.md new file mode 100644 index 00000000000..167295b8b49 --- /dev/null +++ b/models/spring-ai-zhipuai/README.md @@ -0,0 +1,5 @@ +[ZhiPu AI Chat Documentation](https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/api/chat/zhipuai-chat.html) + +[ZhiPu AI Embedding Documentation](https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/api/embeddings/zhipuai-embeddings.html) + +[ZhiPu AI Image Documentation](https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/api/image/zhipuai-image.html) \ No newline at end of file diff --git a/models/spring-ai-zhipuai/pom.xml b/models/spring-ai-zhipuai/pom.xml new file mode 100644 index 00000000000..0f5d84a1d6c --- /dev/null +++ b/models/spring-ai-zhipuai/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-zhipuai + jar + Spring AI Mistral AI + Mistral AI support + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + + + org.springframework.ai + spring-ai-core + ${project.parent.version} + + + + org.springframework.ai + spring-ai-retry + ${project.parent.version} + + + + + org.springframework + spring-context-support + + + + org.springframework.boot + spring-boot-starter-logging + + + + + org.springframework.ai + spring-ai-test + ${project.version} + test + + + + + diff --git a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiChatClient.java b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiChatClient.java new file mode 100644 index 00000000000..eeafbb4ea63 --- /dev/null +++ b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiChatClient.java @@ -0,0 +1,375 @@ +/* + * 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.zhipuai; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.ChatClient; +import org.springframework.ai.chat.ChatResponse; +import org.springframework.ai.chat.Generation; +import org.springframework.ai.chat.StreamingChatClient; +import org.springframework.ai.chat.metadata.ChatGenerationMetadata; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.model.function.AbstractFunctionCallSupport; +import org.springframework.ai.model.function.FunctionCallbackContext; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletion; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletion.Choice; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionFinishReason; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage.MediaContent; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage.Role; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage.ToolCall; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionRequest; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.http.ResponseEntity; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MimeType; +import reactor.core.publisher.Flux; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * {@link ChatClient} and {@link StreamingChatClient} implementation for + * {@literal ZhiPuAI} backed by {@link ZhiPuAiApi}. + * + * @author Geng Rong + * @see ChatClient + * @see StreamingChatClient + * @see ZhiPuAiApi + * @since 1.0.0 M1 + */ +public class ZhiPuAiChatClient extends + AbstractFunctionCallSupport> + implements ChatClient, StreamingChatClient { + + private static final Logger logger = LoggerFactory.getLogger(ZhiPuAiChatClient.class); + + /** + * The default options used for the chat completion requests. + */ + private ZhiPuAiChatOptions defaultOptions; + + /** + * The retry template used to retry the ZhiPuAI API calls. + */ + public final RetryTemplate retryTemplate; + + /** + * Low-level access to the ZhiPuAI API. + */ + private final ZhiPuAiApi zhiPuAiApi; + + /** + * Creates an instance of the ZhiPuAiChatClient. + * @param zhiPuAiApi The ZhiPuAiApi instance to be used for interacting with the + * ZhiPuAI Chat API. + * @throws IllegalArgumentException if zhiPuAiApi is null + */ + public ZhiPuAiChatClient(ZhiPuAiApi zhiPuAiApi) { + this(zhiPuAiApi, + ZhiPuAiChatOptions.builder().withModel(ZhiPuAiApi.DEFAULT_CHAT_MODEL).withTemperature(0.7f).build()); + } + + /** + * Initializes an instance of the ZhiPuAiChatClient. + * @param zhiPuAiApi The ZhiPuAiApi instance to be used for interacting with the + * ZhiPuAI Chat API. + * @param options The ZhiPuAiChatOptions to configure the chat client. + */ + public ZhiPuAiChatClient(ZhiPuAiApi zhiPuAiApi, ZhiPuAiChatOptions options) { + this(zhiPuAiApi, options, null, RetryUtils.DEFAULT_RETRY_TEMPLATE); + } + + /** + * Initializes a new instance of the ZhiPuAiChatClient. + * @param zhiPuAiApi The ZhiPuAiApi instance to be used for interacting with the + * ZhiPuAI Chat API. + * @param options The ZhiPuAiChatOptions to configure the chat client. + * @param functionCallbackContext The function callback context. + * @param retryTemplate The retry template. + */ + public ZhiPuAiChatClient(ZhiPuAiApi zhiPuAiApi, ZhiPuAiChatOptions options, + FunctionCallbackContext functionCallbackContext, RetryTemplate retryTemplate) { + super(functionCallbackContext); + Assert.notNull(zhiPuAiApi, "ZhiPuAiApi must not be null"); + Assert.notNull(options, "Options must not be null"); + Assert.notNull(retryTemplate, "RetryTemplate must not be null"); + this.zhiPuAiApi = zhiPuAiApi; + this.defaultOptions = options; + this.retryTemplate = retryTemplate; + } + + @Override + public ChatResponse call(Prompt prompt) { + + ChatCompletionRequest request = createRequest(prompt, false); + + return this.retryTemplate.execute(ctx -> { + + ResponseEntity completionEntity = this.callWithFunctionSupport(request); + + var chatCompletion = completionEntity.getBody(); + if (chatCompletion == null) { + logger.warn("No chat completion returned for prompt: {}", prompt); + return new ChatResponse(List.of()); + } + + List generations = chatCompletion.choices().stream().map(choice -> { + return new Generation(choice.message().content(), toMap(chatCompletion.id(), choice)) + .withGenerationMetadata(ChatGenerationMetadata.from(choice.finishReason().name(), null)); + }).toList(); + + return new ChatResponse(generations); + }); + } + + private Map toMap(String id, ChatCompletion.Choice choice) { + Map map = new HashMap<>(); + + var message = choice.message(); + if (message.role() != null) { + map.put("role", message.role().name()); + } + if (choice.finishReason() != null) { + map.put("finishReason", choice.finishReason().name()); + } + map.put("id", id); + return map; + } + + @Override + public Flux stream(Prompt prompt) { + + ChatCompletionRequest request = createRequest(prompt, true); + + return this.retryTemplate.execute(ctx -> { + + Flux completionChunks = this.zhiPuAiApi.chatCompletionStream(request); + + // For chunked responses, only the first chunk contains the choice role. + // The rest of the chunks with same ID share the same role. + ConcurrentHashMap roleMap = new ConcurrentHashMap<>(); + + // Convert the ChatCompletionChunk into a ChatCompletion to be able to reuse + // the function call handling logic. + return completionChunks.map(chunk -> chunkToChatCompletion(chunk)).map(chatCompletion -> { + try { + chatCompletion = handleFunctionCallOrReturn(request, ResponseEntity.of(Optional.of(chatCompletion))) + .getBody(); + + @SuppressWarnings("null") + String id = chatCompletion.id(); + + List generations = chatCompletion.choices().stream().map(choice -> { + if (choice.message().role() != null) { + roleMap.putIfAbsent(id, choice.message().role().name()); + } + String finish = (choice.finishReason() != null ? choice.finishReason().name() : ""); + var generation = new Generation(choice.message().content(), + Map.of("id", id, "role", roleMap.get(id), "finishReason", finish)); + if (choice.finishReason() != null) { + generation = generation.withGenerationMetadata( + ChatGenerationMetadata.from(choice.finishReason().name(), null)); + } + return generation; + }).toList(); + + return new ChatResponse(generations); + } + catch (Exception e) { + logger.error("Error processing chat completion", e); + return new ChatResponse(List.of()); + } + + }); + }); + } + + /** + * Convert the ChatCompletionChunk into a ChatCompletion. The Usage is set to null. + * @param chunk the ChatCompletionChunk to convert + * @return the ChatCompletion + */ + private ZhiPuAiApi.ChatCompletion chunkToChatCompletion(ZhiPuAiApi.ChatCompletionChunk chunk) { + List choices = chunk.choices() + .stream() + .map(cc -> new Choice(cc.finishReason(), cc.index(), cc.delta(), cc.logprobs())) + .toList(); + + return new ZhiPuAiApi.ChatCompletion(chunk.id(), choices, chunk.created(), chunk.model(), + chunk.systemFingerprint(), "chat.completion", null); + } + + /** + * Accessible for testing. + */ + ChatCompletionRequest createRequest(Prompt prompt, boolean stream) { + + Set functionsForThisRequest = new HashSet<>(); + + List chatCompletionMessages = prompt.getInstructions().stream().map(m -> { + // Add text content. + List contents = new ArrayList<>(List.of(new MediaContent(m.getContent()))); + if (!CollectionUtils.isEmpty(m.getMedia())) { + // Add media content. + contents.addAll(m.getMedia() + .stream() + .map(media -> new MediaContent( + new MediaContent.ImageUrl(this.fromMediaData(media.getMimeType(), media.getData())))) + .toList()); + } + + return new ChatCompletionMessage(contents, ChatCompletionMessage.Role.valueOf(m.getMessageType().name())); + }).toList(); + + ChatCompletionRequest request = new ChatCompletionRequest(chatCompletionMessages, stream); + + if (prompt.getOptions() != null) { + if (prompt.getOptions() instanceof ChatOptions runtimeOptions) { + ZhiPuAiChatOptions updatedRuntimeOptions = ModelOptionsUtils.copyToTarget(runtimeOptions, + ChatOptions.class, ZhiPuAiChatOptions.class); + + Set promptEnabledFunctions = this.handleFunctionCallbackConfigurations(updatedRuntimeOptions, + IS_RUNTIME_CALL); + functionsForThisRequest.addAll(promptEnabledFunctions); + + request = ModelOptionsUtils.merge(updatedRuntimeOptions, request, ChatCompletionRequest.class); + } + else { + throw new IllegalArgumentException("Prompt options are not of type ChatOptions: " + + prompt.getOptions().getClass().getSimpleName()); + } + } + + if (this.defaultOptions != null) { + + Set defaultEnabledFunctions = this.handleFunctionCallbackConfigurations(this.defaultOptions, + !IS_RUNTIME_CALL); + + functionsForThisRequest.addAll(defaultEnabledFunctions); + + request = ModelOptionsUtils.merge(request, this.defaultOptions, ChatCompletionRequest.class); + } + + // Add the enabled functions definitions to the request's tools parameter. + if (!CollectionUtils.isEmpty(functionsForThisRequest)) { + + request = ModelOptionsUtils.merge( + ZhiPuAiChatOptions.builder().withTools(this.getFunctionTools(functionsForThisRequest)).build(), + request, ChatCompletionRequest.class); + } + + return request; + } + + private String fromMediaData(MimeType mimeType, Object mediaContentData) { + if (mediaContentData instanceof byte[] bytes) { + // Assume the bytes are an image. So, convert the bytes to a base64 encoded + // following the prefix pattern. + return String.format("data:%s;base64,%s", mimeType.toString(), Base64.getEncoder().encodeToString(bytes)); + } + else if (mediaContentData instanceof String text) { + // Assume the text is a URLs or a base64 encoded image prefixed by the user. + return text; + } + else { + throw new IllegalArgumentException( + "Unsupported media data type: " + mediaContentData.getClass().getSimpleName()); + } + } + + private List getFunctionTools(Set functionNames) { + return this.resolveFunctionCallbacks(functionNames).stream().map(functionCallback -> { + var function = new ZhiPuAiApi.FunctionTool.Function(functionCallback.getDescription(), + functionCallback.getName(), functionCallback.getInputTypeSchema()); + return new ZhiPuAiApi.FunctionTool(function); + }).toList(); + } + + @Override + protected ChatCompletionRequest doCreateToolResponseRequest(ChatCompletionRequest previousRequest, + ChatCompletionMessage responseMessage, List conversationHistory) { + + // Every tool-call item requires a separate function call and a response (TOOL) + // message. + for (ToolCall toolCall : responseMessage.toolCalls()) { + + var functionName = toolCall.function().name(); + String functionArguments = toolCall.function().arguments(); + + if (!this.functionCallbackRegister.containsKey(functionName)) { + throw new IllegalStateException("No function callback found for function name: " + functionName); + } + + String functionResponse = this.functionCallbackRegister.get(functionName).call(functionArguments); + + // Add the function response to the conversation. + conversationHistory + .add(new ChatCompletionMessage(functionResponse, Role.TOOL, functionName, toolCall.id(), null)); + } + + // Recursively call chatCompletionWithTools until the model doesn't call a + // functions anymore. + ChatCompletionRequest newRequest = new ChatCompletionRequest(conversationHistory, false); + newRequest = ModelOptionsUtils.merge(newRequest, previousRequest, ChatCompletionRequest.class); + + return newRequest; + } + + @Override + protected List doGetUserMessages(ChatCompletionRequest request) { + return request.messages(); + } + + @Override + protected ChatCompletionMessage doGetToolResponseMessage(ResponseEntity chatCompletion) { + return chatCompletion.getBody().choices().iterator().next().message(); + } + + @Override + protected ResponseEntity doChatCompletion(ChatCompletionRequest request) { + return this.zhiPuAiApi.chatCompletionEntity(request); + } + + @Override + protected Flux> doChatCompletionStream(ChatCompletionRequest request) { + throw new RuntimeException("Streaming Function calling is not supported"); + } + + @Override + protected boolean isToolFunctionCall(ResponseEntity chatCompletion) { + var body = chatCompletion.getBody(); + if (body == null) { + return false; + } + + var choices = body.choices(); + if (CollectionUtils.isEmpty(choices)) { + return false; + } + + var choice = choices.get(0); + return !CollectionUtils.isEmpty(choice.message().toolCalls()) + && choice.finishReason() == ChatCompletionFinishReason.TOOL_CALLS; + } + +} diff --git a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiChatOptions.java b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiChatOptions.java new file mode 100644 index 00000000000..dc9d7345474 --- /dev/null +++ b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiChatOptions.java @@ -0,0 +1,490 @@ +/* + * 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.zhipuai; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.model.function.FunctionCallingOptions; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionRequest.ResponseFormat; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.FunctionTool; +import org.springframework.boot.context.properties.NestedConfigurationProperty; +import org.springframework.util.Assert; + +import java.util.*; + +/** + * ZhiPuAiChatOptions represents the options for the ZhiPuAiChat model. + * + * @author Geng Rong + * @since 1.0.0 M1 + */ +@JsonInclude(Include.NON_NULL) +public class ZhiPuAiChatOptions implements FunctionCallingOptions, ChatOptions { + + // @formatter:off + /** + * ID of the model to use. + */ + private @JsonProperty("model") String model; + /** + * Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing + * frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim. + */ + private @JsonProperty("frequency_penalty") Float frequencyPenalty; + /** + * The maximum number of tokens to generate in the chat completion. The total length of input + * tokens and generated tokens is limited by the model's context length. + */ + private @JsonProperty("max_tokens") Integer maxTokens; + /** + * How many chat completion choices to generate for each input message. Note that you will be charged based + * on the number of generated tokens across all of the choices. Keep n as 1 to minimize costs. + */ + private @JsonProperty("n") Integer n; + /** + * Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they + * appear in the text so far, increasing the model's likelihood to talk about new topics. + */ + private @JsonProperty("presence_penalty") Float presencePenalty; + /** + * An object specifying the format that the model must output. Setting to { "type": + * "json_object" } enables JSON mode, which guarantees the message the model generates is valid JSON. + */ + private @JsonProperty("response_format") ResponseFormat responseFormat; + /** + * This feature is in Beta. If specified, our system will make a best effort to sample + * deterministically, such that repeated requests with the same seed and parameters should return the same result. + * Determinism is not guaranteed, and you should refer to the system_fingerprint response parameter to monitor + * changes in the backend. + */ + private @JsonProperty("seed") Integer seed; + /** + * Up to 4 sequences where the API will stop generating further tokens. + */ + @NestedConfigurationProperty + private @JsonProperty("stop") List stop; + /** + * What sampling temperature to use, between 0 and 1. Higher values like 0.8 will make the output + * more random, while lower values like 0.2 will make it more focused and deterministic. We generally recommend + * altering this or top_p but not both. + */ + private @JsonProperty("temperature") Float temperature; + /** + * An alternative to sampling with temperature, called nucleus sampling, where the model considers the + * results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% + * probability mass are considered. We generally recommend altering this or temperature but not both. + */ + private @JsonProperty("top_p") Float topP; + /** + * A list of tools the model may call. Currently, only functions are supported as a tool. Use this to + * provide a list of functions the model may generate JSON inputs for. + */ + @NestedConfigurationProperty + private @JsonProperty("tools") List tools; + /** + * Controls which (if any) function is called by the model. none means the model will not call a + * function and instead generates a message. auto means the model can pick between generating a message or calling a + * function. Specifying a particular function via {"type: "function", "function": {"name": "my_function"}} forces + * the model to call that function. none is the default when no functions are present. auto is the default if + * functions are present. Use the {@link ZhiPuAiApi.ChatCompletionRequest.ToolChoiceBuilder} to create a tool choice object. + */ + private @JsonProperty("tool_choice") String toolChoice; + /** + * A unique identifier representing your end-user, which can help ZhiPuAI to monitor and detect abuse. + * ID length requirement: minimum of 6 characters, maximum of 128 characters. + */ + private @JsonProperty("user_id") String user; + + /** + * ZhiPuAI Tool Function Callbacks to register with the ChatClient. + * For Prompt Options the functionCallbacks are automatically enabled for the duration of the prompt execution. + * For Default Options the functionCallbacks are registered but disabled by default. Use the enableFunctions to set the functions + * from the registry to be used by the ChatClient chat completion requests. + */ + @NestedConfigurationProperty + @JsonIgnore + private List functionCallbacks = new ArrayList<>(); + + /** + * List of functions, identified by their names, to configure for function calling in + * the chat completion requests. + * Functions with those names must exist in the functionCallbacks registry. + * The {@link #functionCallbacks} from the PromptOptions are automatically enabled for the duration of the prompt execution. + * + * Note that function enabled with the default options are enabled for all chat completion requests. This could impact the token count and the billing. + * If the functions is set in a prompt options, then the enabled functions are only active for the duration of this prompt execution. + */ + @NestedConfigurationProperty + @JsonIgnore + private Set functions = new HashSet<>(); + // @formatter:on + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + protected ZhiPuAiChatOptions options; + + public Builder() { + this.options = new ZhiPuAiChatOptions(); + } + + public Builder(ZhiPuAiChatOptions options) { + this.options = options; + } + + public Builder withModel(String model) { + this.options.model = model; + return this; + } + + public Builder withFrequencyPenalty(Float frequencyPenalty) { + this.options.frequencyPenalty = frequencyPenalty; + return this; + } + + public Builder withMaxTokens(Integer maxTokens) { + this.options.maxTokens = maxTokens; + return this; + } + + public Builder withN(Integer n) { + this.options.n = n; + return this; + } + + public Builder withPresencePenalty(Float presencePenalty) { + this.options.presencePenalty = presencePenalty; + return this; + } + + public Builder withResponseFormat(ResponseFormat responseFormat) { + this.options.responseFormat = responseFormat; + return this; + } + + public Builder withSeed(Integer seed) { + this.options.seed = seed; + return this; + } + + public Builder withStop(List stop) { + this.options.stop = stop; + return this; + } + + public Builder withTemperature(Float temperature) { + this.options.temperature = temperature; + return this; + } + + public Builder withTopP(Float topP) { + this.options.topP = topP; + return this; + } + + public Builder withTools(List tools) { + this.options.tools = tools; + return this; + } + + public Builder withToolChoice(String toolChoice) { + this.options.toolChoice = toolChoice; + return this; + } + + public Builder withUser(String user) { + this.options.user = user; + return this; + } + + public Builder withFunctionCallbacks(List functionCallbacks) { + this.options.functionCallbacks = functionCallbacks; + return this; + } + + public Builder withFunctions(Set functionNames) { + Assert.notNull(functionNames, "Function names must not be null"); + this.options.functions = functionNames; + return this; + } + + public Builder withFunction(String functionName) { + Assert.hasText(functionName, "Function name must not be empty"); + this.options.functions.add(functionName); + return this; + } + + public ZhiPuAiChatOptions build() { + return this.options; + } + + } + + public String getModel() { + return this.model; + } + + public void setModel(String model) { + this.model = model; + } + + public Float getFrequencyPenalty() { + return this.frequencyPenalty; + } + + public void setFrequencyPenalty(Float frequencyPenalty) { + this.frequencyPenalty = frequencyPenalty; + } + + public Integer getMaxTokens() { + return this.maxTokens; + } + + public void setMaxTokens(Integer maxTokens) { + this.maxTokens = maxTokens; + } + + public Integer getN() { + return this.n; + } + + public void setN(Integer n) { + this.n = n; + } + + public Float getPresencePenalty() { + return this.presencePenalty; + } + + public void setPresencePenalty(Float presencePenalty) { + this.presencePenalty = presencePenalty; + } + + public ResponseFormat getResponseFormat() { + return this.responseFormat; + } + + public void setResponseFormat(ResponseFormat responseFormat) { + this.responseFormat = responseFormat; + } + + public Integer getSeed() { + return this.seed; + } + + public void setSeed(Integer seed) { + this.seed = seed; + } + + public List getStop() { + return this.stop; + } + + public void setStop(List stop) { + this.stop = stop; + } + + @Override + public Float getTemperature() { + return this.temperature; + } + + public void setTemperature(Float temperature) { + this.temperature = temperature; + } + + @Override + public Float getTopP() { + return this.topP; + } + + public void setTopP(Float topP) { + this.topP = topP; + } + + public List getTools() { + return this.tools; + } + + public void setTools(List tools) { + this.tools = tools; + } + + public String getToolChoice() { + return this.toolChoice; + } + + public void setToolChoice(String toolChoice) { + this.toolChoice = toolChoice; + } + + public String getUser() { + return this.user; + } + + public void setUser(String user) { + this.user = user; + } + + @Override + public List getFunctionCallbacks() { + return this.functionCallbacks; + } + + @Override + public void setFunctionCallbacks(List functionCallbacks) { + this.functionCallbacks = functionCallbacks; + } + + @Override + public Set getFunctions() { + return functions; + } + + public void setFunctions(Set functionNames) { + this.functions = functionNames; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((model == null) ? 0 : model.hashCode()); + result = prime * result + ((frequencyPenalty == null) ? 0 : frequencyPenalty.hashCode()); + result = prime * result + ((maxTokens == null) ? 0 : maxTokens.hashCode()); + result = prime * result + ((n == null) ? 0 : n.hashCode()); + result = prime * result + ((presencePenalty == null) ? 0 : presencePenalty.hashCode()); + result = prime * result + ((responseFormat == null) ? 0 : responseFormat.hashCode()); + result = prime * result + ((seed == null) ? 0 : seed.hashCode()); + result = prime * result + ((stop == null) ? 0 : stop.hashCode()); + result = prime * result + ((temperature == null) ? 0 : temperature.hashCode()); + result = prime * result + ((topP == null) ? 0 : topP.hashCode()); + result = prime * result + ((tools == null) ? 0 : tools.hashCode()); + result = prime * result + ((toolChoice == null) ? 0 : toolChoice.hashCode()); + result = prime * result + ((user == null) ? 0 : user.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + ZhiPuAiChatOptions other = (ZhiPuAiChatOptions) obj; + if (this.model == null) { + if (other.model != null) + return false; + } + else if (!model.equals(other.model)) + return false; + if (this.frequencyPenalty == null) { + if (other.frequencyPenalty != null) + return false; + } + else if (!this.frequencyPenalty.equals(other.frequencyPenalty)) + return false; + if (this.maxTokens == null) { + if (other.maxTokens != null) + return false; + } + else if (!this.maxTokens.equals(other.maxTokens)) + return false; + if (this.n == null) { + if (other.n != null) + return false; + } + else if (!this.n.equals(other.n)) + return false; + if (this.presencePenalty == null) { + if (other.presencePenalty != null) + return false; + } + else if (!this.presencePenalty.equals(other.presencePenalty)) + return false; + if (this.responseFormat == null) { + if (other.responseFormat != null) + return false; + } + else if (!this.responseFormat.equals(other.responseFormat)) + return false; + if (this.seed == null) { + if (other.seed != null) + return false; + } + else if (!this.seed.equals(other.seed)) + return false; + if (this.stop == null) { + if (other.stop != null) + return false; + } + else if (!stop.equals(other.stop)) + return false; + if (this.temperature == null) { + if (other.temperature != null) + return false; + } + else if (!this.temperature.equals(other.temperature)) + return false; + if (this.topP == null) { + if (other.topP != null) + return false; + } + else if (!topP.equals(other.topP)) + return false; + if (this.tools == null) { + if (other.tools != null) + return false; + } + else if (!tools.equals(other.tools)) + return false; + if (this.toolChoice == null) { + if (other.toolChoice != null) + return false; + } + else if (!toolChoice.equals(other.toolChoice)) + return false; + if (this.user == null) { + if (other.user != null) + return false; + } + else if (!this.user.equals(other.user)) + return false; + return true; + } + + @Override + @JsonIgnore + public Integer getTopK() { + throw new UnsupportedOperationException("Unimplemented method 'getTopK'"); + } + + @JsonIgnore + public void setTopK(Integer topK) { + throw new UnsupportedOperationException("Unimplemented method 'setTopK'"); + } + +} diff --git a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiEmbeddingClient.java b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiEmbeddingClient.java new file mode 100644 index 00000000000..c12742377b7 --- /dev/null +++ b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiEmbeddingClient.java @@ -0,0 +1,149 @@ +/* + * 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.zhipuai; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.document.Document; +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.embedding.*; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; + +import java.util.List; + +/** + * ZhiPuAI Embedding Client implementation. + * + * @author Geng Rong + * @since 1.0.0 M1 + */ +public class ZhiPuAiEmbeddingClient extends AbstractEmbeddingClient { + + private static final Logger logger = LoggerFactory.getLogger(ZhiPuAiEmbeddingClient.class); + + private final ZhiPuAiEmbeddingOptions defaultOptions; + + private final RetryTemplate retryTemplate; + + private final ZhiPuAiApi zhiPuAiApi; + + private final MetadataMode metadataMode; + + /** + * Constructor for the ZhiPuAiEmbeddingClient class. + * @param zhiPuAiApi The ZhiPuAiApi instance to use for making API requests. + */ + public ZhiPuAiEmbeddingClient(ZhiPuAiApi zhiPuAiApi) { + this(zhiPuAiApi, MetadataMode.EMBED); + } + + /** + * Initializes a new instance of the ZhiPuAiEmbeddingClient class. + * @param zhiPuAiApi The ZhiPuAiApi instance to use for making API requests. + * @param metadataMode The mode for generating metadata. + */ + public ZhiPuAiEmbeddingClient(ZhiPuAiApi zhiPuAiApi, MetadataMode metadataMode) { + this(zhiPuAiApi, metadataMode, + ZhiPuAiEmbeddingOptions.builder().withModel(ZhiPuAiApi.DEFAULT_EMBEDDING_MODEL).build(), + RetryUtils.DEFAULT_RETRY_TEMPLATE); + } + + /** + * Initializes a new instance of the ZhiPuAiEmbeddingClient class. + * @param zhiPuAiApi The ZhiPuAiApi instance to use for making API requests. + * @param metadataMode The mode for generating metadata. + * @param zhiPuAiEmbeddingOptions The options for ZhiPuAI embedding. + */ + public ZhiPuAiEmbeddingClient(ZhiPuAiApi zhiPuAiApi, MetadataMode metadataMode, + ZhiPuAiEmbeddingOptions zhiPuAiEmbeddingOptions) { + this(zhiPuAiApi, metadataMode, zhiPuAiEmbeddingOptions, RetryUtils.DEFAULT_RETRY_TEMPLATE); + } + + /** + * Initializes a new instance of the ZhiPuAiEmbeddingClient class. + * @param zhiPuAiApi - The ZhiPuAiApi instance to use for making API requests. + * @param metadataMode - The mode for generating metadata. + * @param options - The options for ZhiPuAI embedding. + * @param retryTemplate - The RetryTemplate for retrying failed API requests. + */ + public ZhiPuAiEmbeddingClient(ZhiPuAiApi zhiPuAiApi, MetadataMode metadataMode, ZhiPuAiEmbeddingOptions options, + RetryTemplate retryTemplate) { + Assert.notNull(zhiPuAiApi, "ZhiPuAiApi must not be null"); + Assert.notNull(metadataMode, "metadataMode must not be null"); + Assert.notNull(options, "options must not be null"); + Assert.notNull(retryTemplate, "retryTemplate must not be null"); + + this.zhiPuAiApi = zhiPuAiApi; + this.metadataMode = metadataMode; + this.defaultOptions = options; + this.retryTemplate = retryTemplate; + } + + @Override + public List embed(Document document) { + Assert.notNull(document, "Document must not be null"); + return this.embed(document.getFormattedContent(this.metadataMode)); + } + + @SuppressWarnings("unchecked") + @Override + public EmbeddingResponse call(EmbeddingRequest request) { + + return this.retryTemplate.execute(ctx -> { + + ZhiPuAiApi.EmbeddingRequest> apiRequest = (this.defaultOptions != null) + ? new ZhiPuAiApi.EmbeddingRequest<>(request.getInstructions(), this.defaultOptions.getModel()) + : new ZhiPuAiApi.EmbeddingRequest<>(request.getInstructions(), ZhiPuAiApi.DEFAULT_EMBEDDING_MODEL); + + if (request.getOptions() != null && !EmbeddingOptions.EMPTY.equals(request.getOptions())) { + apiRequest = ModelOptionsUtils.merge(request.getOptions(), apiRequest, + ZhiPuAiApi.EmbeddingRequest.class); + } + + ZhiPuAiApi.EmbeddingList apiEmbeddingResponse = this.zhiPuAiApi.embeddings(apiRequest) + .getBody(); + + if (apiEmbeddingResponse == null) { + logger.warn("No embeddings returned for request: {}", request); + return new EmbeddingResponse(List.of()); + } + + var metadata = generateResponseMetadata(apiEmbeddingResponse.model(), apiEmbeddingResponse.usage()); + + List embeddings = apiEmbeddingResponse.data() + .stream() + .map(e -> new Embedding(e.embedding(), e.index())) + .toList(); + + return new EmbeddingResponse(embeddings, metadata); + + }); + } + + private EmbeddingResponseMetadata generateResponseMetadata(String model, ZhiPuAiApi.Usage usage) { + EmbeddingResponseMetadata metadata = new EmbeddingResponseMetadata(); + metadata.put("model", model); + metadata.put("prompt-tokens", usage.promptTokens()); + metadata.put("completion-tokens", usage.completionTokens()); + metadata.put("total-tokens", usage.totalTokens()); + return metadata; + } + +} diff --git a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiEmbeddingOptions.java b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiEmbeddingOptions.java new file mode 100644 index 00000000000..e36a1c4c9fd --- /dev/null +++ b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiEmbeddingOptions.java @@ -0,0 +1,70 @@ +/* + * Copyright 2023 - 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.zhipuai; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.ai.embedding.EmbeddingOptions; + +/** + * The ZhiPuAiEmbeddingOptions class represents the options for ZhiPuAI embedding. + * + * @author Geng Rong + * @since 1.0.0 M1 + */ +@JsonInclude(Include.NON_NULL) +public class ZhiPuAiEmbeddingOptions implements EmbeddingOptions { + + // @formatter:off + /** + * ID of the model to use. + */ + private @JsonProperty("model") String model; + // @formatter:on + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + protected ZhiPuAiEmbeddingOptions options; + + public Builder() { + this.options = new ZhiPuAiEmbeddingOptions(); + } + + public Builder withModel(String model) { + this.options.setModel(model); + return this; + } + + public ZhiPuAiEmbeddingOptions build() { + return this.options; + } + + } + + public String getModel() { + return this.model; + } + + public void setModel(String model) { + this.model = model; + } + +} diff --git a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiImageClient.java b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiImageClient.java new file mode 100644 index 00000000000..6d3df147504 --- /dev/null +++ b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiImageClient.java @@ -0,0 +1,129 @@ +/* + * 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.zhipuai; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.image.*; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.ai.zhipuai.api.ZhiPuAiImageApi; +import org.springframework.http.ResponseEntity; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; + +import java.util.List; + +/** + * ZhiPuAiImageClient is a class that implements the ImageClient interface. It provides a + * client for calling the ZhiPuAI image generation API. + * + * @author Geng Rong + * @since 1.0.0 M1 + */ +public class ZhiPuAiImageClient implements ImageClient { + + private final static Logger logger = LoggerFactory.getLogger(ZhiPuAiImageClient.class); + + private final ZhiPuAiImageOptions defaultOptions; + + private final ZhiPuAiImageApi zhiPuAiImageApi; + + public final RetryTemplate retryTemplate; + + public ZhiPuAiImageClient(ZhiPuAiImageApi zhiPuAiImageApi) { + this(zhiPuAiImageApi, ZhiPuAiImageOptions.builder().build(), RetryUtils.DEFAULT_RETRY_TEMPLATE); + } + + public ZhiPuAiImageClient(ZhiPuAiImageApi zhiPuAiImageApi, ZhiPuAiImageOptions defaultOptions, + RetryTemplate retryTemplate) { + Assert.notNull(zhiPuAiImageApi, "ZhiPuAiImageApi must not be null"); + Assert.notNull(defaultOptions, "defaultOptions must not be null"); + Assert.notNull(retryTemplate, "retryTemplate must not be null"); + this.zhiPuAiImageApi = zhiPuAiImageApi; + this.defaultOptions = defaultOptions; + this.retryTemplate = retryTemplate; + } + + public ZhiPuAiImageOptions getDefaultOptions() { + return this.defaultOptions; + } + + @Override + public ImageResponse call(ImagePrompt imagePrompt) { + return this.retryTemplate.execute(ctx -> { + + String instructions = imagePrompt.getInstructions().get(0).getText(); + + ZhiPuAiImageApi.ZhiPuAiImageRequest imageRequest = new ZhiPuAiImageApi.ZhiPuAiImageRequest(instructions, + ZhiPuAiImageApi.DEFAULT_IMAGE_MODEL); + + if (this.defaultOptions != null) { + imageRequest = ModelOptionsUtils.merge(this.defaultOptions, imageRequest, + ZhiPuAiImageApi.ZhiPuAiImageRequest.class); + } + + if (imagePrompt.getOptions() != null) { + imageRequest = ModelOptionsUtils.merge(toZhiPuAiImageOptions(imagePrompt.getOptions()), imageRequest, + ZhiPuAiImageApi.ZhiPuAiImageRequest.class); + } + + // Make the request + ResponseEntity imageResponseEntity = this.zhiPuAiImageApi + .createImage(imageRequest); + + // Convert to org.springframework.ai.model derived ImageResponse data type + return convertResponse(imageResponseEntity, imageRequest); + }); + } + + private ImageResponse convertResponse(ResponseEntity imageResponseEntity, + ZhiPuAiImageApi.ZhiPuAiImageRequest zhiPuAiImageRequest) { + ZhiPuAiImageApi.ZhiPuAiImageResponse imageApiResponse = imageResponseEntity.getBody(); + if (imageApiResponse == null) { + logger.warn("No image response returned for request: {}", zhiPuAiImageRequest); + return new ImageResponse(List.of()); + } + + List imageGenerationList = imageApiResponse.data() + .stream() + .map(entry -> new ImageGeneration(new Image(entry.url(), null))) + .toList(); + + return new ImageResponse(imageGenerationList); + } + + /** + * Convert the {@link ImageOptions} into {@link ZhiPuAiImageOptions}. + * @param runtimeImageOptions the image options to use. + * @return the converted {@link ZhiPuAiImageOptions}. + */ + private ZhiPuAiImageOptions toZhiPuAiImageOptions(ImageOptions runtimeImageOptions) { + ZhiPuAiImageOptions.Builder zhiPuAiImageOptionsBuilder = ZhiPuAiImageOptions.builder(); + if (runtimeImageOptions != null) { + if (runtimeImageOptions.getModel() != null) { + zhiPuAiImageOptionsBuilder.withModel(runtimeImageOptions.getModel()); + } + if (runtimeImageOptions instanceof ZhiPuAiImageOptions runtimeZhiPuAiImageOptions) { + if (runtimeZhiPuAiImageOptions.getUser() != null) { + zhiPuAiImageOptionsBuilder.withUser(runtimeZhiPuAiImageOptions.getUser()); + } + } + } + return zhiPuAiImageOptionsBuilder.build(); + } + +} diff --git a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiImageOptions.java b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiImageOptions.java new file mode 100644 index 00000000000..357eb536af3 --- /dev/null +++ b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiImageOptions.java @@ -0,0 +1,146 @@ +/* + * Copyright 2023 - 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.zhipuai; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.ai.image.ImageOptions; +import org.springframework.ai.zhipuai.api.ZhiPuAiImageApi; + +import java.util.Objects; + +/** + * ZhiPuAiImageOptions represents the options for image generation using ZhiPuAI image + * model. + * + *

+ * It implements the ImageOptions interface, which is portable across different image + * generation models. + *

+ * + *

+ * Default values: + *

+ *
    + *
  • model: ZhiPuAiImageApi.DEFAULT_IMAGE_MODEL
  • + *
  • user: null
  • + *
+ * + * @author Geng Rong + * @since 1.0.0 M1 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ZhiPuAiImageOptions implements ImageOptions { + + /** + * The model to use for image generation. + */ + @JsonProperty("model") + private String model = ZhiPuAiImageApi.DEFAULT_IMAGE_MODEL; + + /** + * A unique identifier representing your end-user, which can help ZhiPuAI to monitor + * and detect abuse. User ID length requirement: minimum of 6 characters, maximum of + * 128 characters + */ + @JsonProperty("user_id") + private String user; + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private final ZhiPuAiImageOptions options; + + private Builder() { + this.options = new ZhiPuAiImageOptions(); + } + + public Builder withModel(String model) { + options.setModel(model); + return this; + } + + public Builder withUser(String user) { + options.setUser(user); + return this; + } + + public ZhiPuAiImageOptions build() { + return options; + } + + } + + @Override + public Integer getN() { + return null; + } + + @Override + public String getModel() { + return this.model; + } + + @Override + public Integer getWidth() { + return null; + } + + @Override + public Integer getHeight() { + return null; + } + + @Override + public String getResponseFormat() { + return null; + } + + public void setModel(String model) { + this.model = model; + } + + public String getUser() { + return this.user; + } + + public void setUser(String user) { + this.user = user; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (!(o instanceof ZhiPuAiImageOptions that)) + return false; + return Objects.equals(model, that.model) && Objects.equals(user, that.user); + } + + @Override + public int hashCode() { + return Objects.hash(model, user); + } + + @Override + public String toString() { + return "ZhiPuAiImageOptions{model='" + model + '\'' + ", user='" + user + '\'' + '}'; + } + +} diff --git a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/aot/ZhiPuAiRuntimeHints.java b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/aot/ZhiPuAiRuntimeHints.java new file mode 100644 index 00000000000..51185977a56 --- /dev/null +++ b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/aot/ZhiPuAiRuntimeHints.java @@ -0,0 +1,46 @@ +/* + * 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.zhipuai.aot; + +import org.springframework.ai.zhipuai.api.ZhiPuAiApi; +import org.springframework.ai.zhipuai.api.ZhiPuAiImageApi; +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; + +import static org.springframework.ai.aot.AiRuntimeHints.findJsonAnnotatedClassesInPackage; + +/** + * The ZhiPuAiRuntimeHints class is responsible for registering runtime hints for ZhiPu AI + * API classes. + * + * @author Geng Rong + * @since 1.0.0 M1 + */ +public class ZhiPuAiRuntimeHints implements RuntimeHintsRegistrar { + + @Override + public void registerHints(@NonNull RuntimeHints hints, @Nullable ClassLoader classLoader) { + var mcs = MemberCategory.values(); + for (var tr : findJsonAnnotatedClassesInPackage(ZhiPuAiApi.class)) + hints.reflection().registerType(tr, mcs); + for (var tr : findJsonAnnotatedClassesInPackage(ZhiPuAiImageApi.class)) + hints.reflection().registerType(tr, mcs); + } + +} diff --git a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ApiUtils.java b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ApiUtils.java new file mode 100644 index 00000000000..ca5a96a80a4 --- /dev/null +++ b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ApiUtils.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.zhipuai.api; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +import java.util.function.Consumer; + +/** + * @author Geng Rong + */ +public class ApiUtils { + + public static final String DEFAULT_BASE_URL = "https://open.bigmodel.cn/api/paas"; + + public static Consumer getJsonContentHeaders(String apiKey) { + return (headers) -> { + headers.setBearerAuth(apiKey); + headers.setContentType(MediaType.APPLICATION_JSON); + }; + }; + +} diff --git a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiApi.java b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiApi.java new file mode 100644 index 00000000000..5984e189c09 --- /dev/null +++ b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiApi.java @@ -0,0 +1,839 @@ +/* + * 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.zhipuai.api; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.boot.context.properties.bind.ConstructorBinding; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Predicate; + +// @formatter:off +/** + * Single class implementation of the ZhiPuAI Chat Completion API: https://open.bigmodel.cn/dev/api#http and + * ZhiPuAI Embedding API: https://open.bigmodel.cn/dev/api#text_embedding. + * + * @author Geng Rong + * @since 1.0.0 M1 + */ +public class ZhiPuAiApi { + + public static final String DEFAULT_CHAT_MODEL = ChatModel.GLM_3_Turbo.getValue(); + public static final String DEFAULT_EMBEDDING_MODEL = EmbeddingModel.Embedding_2.getValue(); + private static final Predicate SSE_DONE_PREDICATE = "[DONE]"::equals; + + private final RestClient restClient; + + private final WebClient webClient; + + /** + * Create a new chat completion api with default base URL. + * + * @param zhiPuAiToken ZhiPuAI apiKey. + */ + public ZhiPuAiApi(String zhiPuAiToken) { + this(ApiUtils.DEFAULT_BASE_URL, zhiPuAiToken); + } + + /** + * Create a new chat completion api. + * + * @param baseUrl api base URL. + * @param zhiPuAiToken ZhiPuAI apiKey. + */ + public ZhiPuAiApi(String baseUrl, String zhiPuAiToken) { + this(baseUrl, zhiPuAiToken, RestClient.builder()); + } + + /** + * Create a new chat completion api. + * + * @param baseUrl api base URL. + * @param zhiPuAiToken ZhiPuAI apiKey. + * @param restClientBuilder RestClient builder. + */ + public ZhiPuAiApi(String baseUrl, String zhiPuAiToken, RestClient.Builder restClientBuilder) { + this(baseUrl, zhiPuAiToken, restClientBuilder, RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER); + } + + /** + * Create a new chat completion api. + * + * @param baseUrl api base URL. + * @param zhiPuAiToken ZhiPuAI apiKey. + * @param restClientBuilder RestClient builder. + * @param responseErrorHandler Response error handler. + */ + public ZhiPuAiApi(String baseUrl, String zhiPuAiToken, RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { + + this.restClient = restClientBuilder + .baseUrl(baseUrl) + .defaultHeaders(ApiUtils.getJsonContentHeaders(zhiPuAiToken)) + .defaultStatusHandler(responseErrorHandler) + .build(); + + this.webClient = WebClient.builder() + .baseUrl(baseUrl) + .defaultHeaders(ApiUtils.getJsonContentHeaders(zhiPuAiToken)) + .build(); + } + + /** + * ZhiPuAI Chat Completion Models: + * ZhiPuAI Model. + */ + public enum ChatModel { + GLM_4("GLM-4"), + GLM_3_Turbo("GLM-3-Turbo"); + + public final String value; + + ChatModel(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + /** + * Represents a tool the model may call. Currently, only functions are supported as a tool. + * + * @param type The type of the tool. Currently, only 'function' is supported. + * @param function The function definition. + */ + @JsonInclude(Include.NON_NULL) + public record FunctionTool( + @JsonProperty("type") Type type, + @JsonProperty("function") Function function) { + + /** + * Create a tool of type 'function' and the given function definition. + * @param function function definition. + */ + @ConstructorBinding + public FunctionTool(Function function) { + this(Type.FUNCTION, function); + } + + /** + * Create a tool of type 'function' and the given function definition. + */ + public enum Type { + /** + * Function tool type. + */ + @JsonProperty("function") FUNCTION + } + + /** + * Function definition. + * + * @param description A description of what the function does, used by the model to choose when and how to call + * the function. + * @param name The name of the function to be called. Must be a-z, A-Z, 0-9, or contain underscores and dashes, + * with a maximum length of 64. + * @param parameters The parameters the functions accepts, described as a JSON Schema object. To describe a + * function that accepts no parameters, provide the value {"type": "object", "properties": {}}. + */ + public record Function( + @JsonProperty("description") String description, + @JsonProperty("name") String name, + @JsonProperty("parameters") Map parameters) { + + /** + * Create tool function definition. + * + * @param description tool function description. + * @param name tool function name. + * @param jsonSchema tool function schema as json. + */ + @ConstructorBinding + public Function(String description, String name, String jsonSchema) { + this(description, name, ModelOptionsUtils.jsonToMap(jsonSchema)); + } + } + } + + /** + * Creates a model response for the given chat conversation. + * + * @param messages A list of messages comprising the conversation so far. + * @param model ID of the model to use. + * @param frequencyPenalty Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing + * frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim. + * @param maxTokens The maximum number of tokens to generate in the chat completion. The total length of input + * tokens and generated tokens is limited by the model's context length. + * @param n How many chat completion choices to generate for each input message. Note that you will be charged based + * on the number of generated tokens across all of the choices. Keep n as 1 to minimize costs. + * @param presencePenalty Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they + * appear in the text so far, increasing the model's likelihood to talk about new topics. + * @param responseFormat An object specifying the format that the model must output. Setting to { "type": + * "json_object" } enables JSON mode, which guarantees the message the model generates is valid JSON. + * @param seed This feature is in Beta. If specified, our system will make a best effort to sample + * deterministically, such that repeated requests with the same seed and parameters should return the same result. + * Determinism is not guaranteed, and you should refer to the system_fingerprint response parameter to monitor + * changes in the backend. + * @param stop Up to 4 sequences where the API will stop generating further tokens. + * @param stream If set, partial message deltas will be sent.Tokens will be sent as data-only server-sent events as + * they become available, with the stream terminated by a data: [DONE] message. + * @param temperature What sampling temperature to use, between 0 and 1. Higher values like 0.8 will make the output + * more random, while lower values like 0.2 will make it more focused and deterministic. We generally recommend + * altering this or top_p but not both. + * @param topP An alternative to sampling with temperature, called nucleus sampling, where the model considers the + * results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% + * probability mass are considered. We generally recommend altering this or temperature but not both. + * @param tools A list of tools the model may call. Currently, only functions are supported as a tool. Use this to + * provide a list of functions the model may generate JSON inputs for. + * @param toolChoice Controls which (if any) function is called by the model. none means the model will not call a + * function and instead generates a message. auto means the model can pick between generating a message or calling a + * function. Specifying a particular function via {"type: "function", "function": {"name": "my_function"}} forces + * the model to call that function. none is the default when no functions are present. auto is the default if + * functions are present. Use the {@link ToolChoiceBuilder} to create the tool choice value. + * @param user A unique identifier representing your end-user, which can help ZhiPuAI to monitor and detect abuse. + * + */ + @JsonInclude(Include.NON_NULL) + public record ChatCompletionRequest ( + @JsonProperty("messages") List messages, + @JsonProperty("model") String model, + @JsonProperty("frequency_penalty") Float frequencyPenalty, + @JsonProperty("max_tokens") Integer maxTokens, + @JsonProperty("n") Integer n, + @JsonProperty("presence_penalty") Float presencePenalty, + @JsonProperty("response_format") ResponseFormat responseFormat, + @JsonProperty("seed") Integer seed, + @JsonProperty("stop") List stop, + @JsonProperty("stream") Boolean stream, + @JsonProperty("temperature") Float temperature, + @JsonProperty("top_p") Float topP, + @JsonProperty("tools") List tools, + @JsonProperty("tool_choice") Object toolChoice, + @JsonProperty("user") String user) { + + /** + * Shortcut constructor for a chat completion request with the given messages and model. + * + * @param messages A list of messages comprising the conversation so far. + * @param model ID of the model to use. + * @param temperature What sampling temperature to use, between 0 and 1. + */ + public ChatCompletionRequest(List messages, String model, Float temperature) { + this(messages, model, null, null, null, null, + null, null, null, false, temperature, null, + null, null, null); + } + + /** + * Shortcut constructor for a chat completion request with the given messages, model and control for streaming. + * + * @param messages A list of messages comprising the conversation so far. + * @param model ID of the model to use. + * @param temperature What sampling temperature to use, between 0 and 1. + * @param stream If set, partial message deltas will be sent.Tokens will be sent as data-only server-sent events + * as they become available, with the stream terminated by a data: [DONE] message. + */ + public ChatCompletionRequest(List messages, String model, Float temperature, boolean stream) { + this(messages, model, null, null, null, null, + null, null, null, stream, temperature, null, + null, null, null); + } + + /** + * Shortcut constructor for a chat completion request with the given messages, model, tools and tool choice. + * Streaming is set to false, temperature to 0.8 and all other parameters are null. + * + * @param messages A list of messages comprising the conversation so far. + * @param model ID of the model to use. + * @param tools A list of tools the model may call. Currently, only functions are supported as a tool. + * @param toolChoice Controls which (if any) function is called by the model. + */ + public ChatCompletionRequest(List messages, String model, + List tools, Object toolChoice) { + this(messages, model, null, null, null, null, + null, null, null, false, 0.8f, null, + tools, toolChoice, null); + } + + /** + * Shortcut constructor for a chat completion request with the given messages, model, tools and tool choice. + * Streaming is set to false, temperature to 0.8 and all other parameters are null. + * + * @param messages A list of messages comprising the conversation so far. + * @param stream If set, partial message deltas will be sent.Tokens will be sent as data-only server-sent events + * as they become available, with the stream terminated by a data: [DONE] message. + */ + public ChatCompletionRequest(List messages, Boolean stream) { + this(messages, null, null, null, null, null, + null, null, null, stream, null, null, + null, null, null); + } + + /** + * Helper factory that creates a tool_choice of type 'none', 'auto' or selected function by name. + */ + public static class ToolChoiceBuilder { + /** + * Model can pick between generating a message or calling a function. + */ + public static final String AUTO = "auto"; + /** + * Model will not call a function and instead generates a message + */ + public static final String NONE = "none"; + + /** + * Specifying a particular function forces the model to call that function. + */ + public static Object FUNCTION(String functionName) { + return Map.of("type", "function", "function", Map.of("name", functionName)); + } + } + + /** + * An object specifying the format that the model must output. + * @param type Must be one of 'text' or 'json_object'. + */ + @JsonInclude(Include.NON_NULL) + public record ResponseFormat( + @JsonProperty("type") String type) { + } + } + + /** + * Message comprising the conversation. + * + * @param rawContent The contents of the message. Can be either a {@link MediaContent} or a {@link String}. + * The response message content is always a {@link String}. + * @param role The role of the messages author. Could be one of the {@link Role} types. + * @param name An optional name for the participant. Provides the model information to differentiate between + * participants of the same role. In case of Function calling, the name is the function name that the message is + * responding to. + * @param toolCallId Tool call that this message is responding to. Only applicable for the {@link Role#TOOL} role + * and null otherwise. + * @param toolCalls The tool calls generated by the model, such as function calls. Applicable only for + * {@link Role#ASSISTANT} role and null otherwise. + */ + @JsonInclude(Include.NON_NULL) + public record ChatCompletionMessage( + @JsonProperty("content") Object rawContent, + @JsonProperty("role") Role role, + @JsonProperty("name") String name, + @JsonProperty("tool_call_id") String toolCallId, + @JsonProperty("tool_calls") List toolCalls) { + + /** + * Get message content as String. + */ + public String content() { + if (this.rawContent == null) { + return null; + } + if (this.rawContent instanceof String text) { + return text; + } + throw new IllegalStateException("The content is not a string!"); + } + + /** + * Create a chat completion message with the given content and role. All other fields are null. + * @param content The contents of the message. + * @param role The role of the author of this message. + */ + public ChatCompletionMessage(Object content, Role role) { + this(content, role, null, null, null); + } + + /** + * The role of the author of this message. + */ + public enum Role { + /** + * System message. + */ + @JsonProperty("system") SYSTEM, + /** + * User message. + */ + @JsonProperty("user") USER, + /** + * Assistant message. + */ + @JsonProperty("assistant") ASSISTANT, + /** + * Tool message. + */ + @JsonProperty("tool") TOOL + } + + /** + * An array of content parts with a defined type. + * Each MediaContent can be of either "text" or "image_url" type. Not both. + * + * @param type Content type, each can be of type text or image_url. + * @param text The text content of the message. + * @param imageUrl The image content of the message. You can pass multiple + * images by adding multiple image_url content parts. Image input is only + * supported when using the glm-4v model. + */ + @JsonInclude(Include.NON_NULL) + public record MediaContent( + @JsonProperty("type") String type, + @JsonProperty("text") String text, + @JsonProperty("image_url") ImageUrl imageUrl) { + + /** + * @param url Either a URL of the image or the base64 encoded image data. + * The base64 encoded image data must have a special prefix in the following format: + * "data:{mimetype};base64,{base64-encoded-image-data}". + * @param detail Specifies the detail level of the image. + */ + @JsonInclude(Include.NON_NULL) + public record ImageUrl( + @JsonProperty("url") String url, + @JsonProperty("detail") String detail) { + + public ImageUrl(String url) { + this(url, null); + } + } + + /** + * Shortcut constructor for a text content. + * @param text The text content of the message. + */ + public MediaContent(String text) { + this("text", text, null); + } + + /** + * Shortcut constructor for an image content. + * @param imageUrl The image content of the message. + */ + public MediaContent(ImageUrl imageUrl) { + this("image_url", null, imageUrl); + } + } + /** + * The relevant tool call. + * + * @param id The ID of the tool call. This ID must be referenced when you submit the tool outputs in using the + * Submit tool outputs to run endpoint. + * @param type The type of tool call the output is required for. For now, this is always function. + * @param function The function definition. + */ + @JsonInclude(Include.NON_NULL) + public record ToolCall( + @JsonProperty("id") String id, + @JsonProperty("type") String type, + @JsonProperty("function") ChatCompletionFunction function) { + } + + /** + * The function definition. + * + * @param name The name of the function. + * @param arguments The arguments that the model expects you to pass to the function. + */ + @JsonInclude(Include.NON_NULL) + public record ChatCompletionFunction( + @JsonProperty("name") String name, + @JsonProperty("arguments") String arguments) { + } + } + + public static String getTextContent(List content) { + return content.stream() + .filter(c -> "text".equals(c.type())) + .map(ChatCompletionMessage.MediaContent::text) + .reduce("", (a, b) -> a + b); + } + + /** + * The reason the model stopped generating tokens. + */ + public enum ChatCompletionFinishReason { + /** + * The model hit a natural stop point or a provided stop sequence. + */ + @JsonProperty("stop") STOP, + /** + * The maximum number of tokens specified in the request was reached. + */ + @JsonProperty("length") LENGTH, + /** + * The content was omitted due to a flag from our content filters. + */ + @JsonProperty("content_filter") CONTENT_FILTER, + /** + * The model called a tool. + */ + @JsonProperty("tool_calls") TOOL_CALLS, + /** + * (deprecated) The model called a function. + */ + @JsonProperty("function_call") FUNCTION_CALL, + /** + * Only for compatibility with Mistral AI API. + */ + @JsonProperty("tool_call") TOOL_CALL + } + + /** + * Represents a chat completion response returned by model, based on the provided input. + * + * @param id A unique identifier for the chat completion. + * @param choices A list of chat completion choices. Can be more than one if n is greater than 1. + * @param created The Unix timestamp (in seconds) of when the chat completion was created. + * @param model The model used for the chat completion. + * @param systemFingerprint This fingerprint represents the backend configuration that the model runs with. Can be + * used in conjunction with the seed request parameter to understand when backend changes have been made that might + * impact determinism. + * @param object The object type, which is always chat.completion. + * @param usage Usage statistics for the completion request. + */ + @JsonInclude(Include.NON_NULL) + public record ChatCompletion( + @JsonProperty("id") String id, + @JsonProperty("choices") List choices, + @JsonProperty("created") Long created, + @JsonProperty("model") String model, + @JsonProperty("system_fingerprint") String systemFingerprint, + @JsonProperty("object") String object, + @JsonProperty("usage") Usage usage) { + + /** + * Chat completion choice. + * + * @param finishReason The reason the model stopped generating tokens. + * @param index The index of the choice in the list of choices. + * @param message A chat completion message generated by the model. + * @param logprobs Log probability information for the choice. + */ + @JsonInclude(Include.NON_NULL) + public record Choice( + @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason, + @JsonProperty("index") Integer index, + @JsonProperty("message") ChatCompletionMessage message, + @JsonProperty("logprobs") LogProbs logprobs) { + + } + } + + /** + * Log probability information for the choice. + * + * @param content A list of message content tokens with log probability information. + */ + @JsonInclude(Include.NON_NULL) + public record LogProbs( + @JsonProperty("content") List content) { + + /** + * Message content tokens with log probability information. + * + * @param token The token. + * @param logprob The log probability of the token. + * @param probBytes A list of integers representing the UTF-8 bytes representation + * of the token. Useful in instances where characters are represented by multiple + * tokens and their byte representations must be combined to generate the correct + * text representation. Can be null if there is no bytes representation for the token. + * @param topLogprobs List of the most likely tokens and their log probability, + * at this token position. In rare cases, there may be fewer than the number of + * requested top_logprobs returned. + */ + @JsonInclude(Include.NON_NULL) + public record Content( + @JsonProperty("token") String token, + @JsonProperty("logprob") Float logprob, + @JsonProperty("bytes") List probBytes, + @JsonProperty("top_logprobs") List topLogprobs) { + + /** + * The most likely tokens and their log probability, at this token position. + * + * @param token The token. + * @param logprob The log probability of the token. + * @param probBytes A list of integers representing the UTF-8 bytes representation + * of the token. Useful in instances where characters are represented by multiple + * tokens and their byte representations must be combined to generate the correct + * text representation. Can be null if there is no bytes representation for the token. + */ + @JsonInclude(Include.NON_NULL) + public record TopLogProbs( + @JsonProperty("token") String token, + @JsonProperty("logprob") Float logprob, + @JsonProperty("bytes") List probBytes) { + } + } + } + + /** + * Usage statistics for the completion request. + * + * @param completionTokens Number of tokens in the generated completion. Only applicable for completion requests. + * @param promptTokens Number of tokens in the prompt. + * @param totalTokens Total number of tokens used in the request (prompt + completion). + */ + @JsonInclude(Include.NON_NULL) + public record Usage( + @JsonProperty("completion_tokens") Integer completionTokens, + @JsonProperty("prompt_tokens") Integer promptTokens, + @JsonProperty("total_tokens") Integer totalTokens) { + + } + + /** + * Represents a streamed chunk of a chat completion response returned by model, based on the provided input. + * + * @param id A unique identifier for the chat completion. Each chunk has the same ID. + * @param choices A list of chat completion choices. Can be more than one if n is greater than 1. + * @param created The Unix timestamp (in seconds) of when the chat completion was created. Each chunk has the same + * timestamp. + * @param model The model used for the chat completion. + * @param systemFingerprint This fingerprint represents the backend configuration that the model runs with. Can be + * used in conjunction with the seed request parameter to understand when backend changes have been made that might + * impact determinism. + * @param object The object type, which is always 'chat.completion.chunk'. + */ + @JsonInclude(Include.NON_NULL) + public record ChatCompletionChunk( + @JsonProperty("id") String id, + @JsonProperty("choices") List choices, + @JsonProperty("created") Long created, + @JsonProperty("model") String model, + @JsonProperty("system_fingerprint") String systemFingerprint, + @JsonProperty("object") String object) { + + /** + * Chat completion choice. + * + * @param finishReason The reason the model stopped generating tokens. + * @param index The index of the choice in the list of choices. + * @param delta A chat completion delta generated by streamed model responses. + * @param logprobs Log probability information for the choice. + */ + @JsonInclude(Include.NON_NULL) + public record ChunkChoice( + @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason, + @JsonProperty("index") Integer index, + @JsonProperty("delta") ChatCompletionMessage delta, + @JsonProperty("logprobs") LogProbs logprobs) { + } + } + + /** + * Creates a model response for the given chat conversation. + * + * @param chatRequest The chat completion request. + * @return Entity response with {@link ChatCompletion} as a body and HTTP status code and headers. + */ + public ResponseEntity chatCompletionEntity(ChatCompletionRequest chatRequest) { + + Assert.notNull(chatRequest, "The request body can not be null."); + Assert.isTrue(!chatRequest.stream(), "Request must set the steam property to false."); + + return this.restClient.post() + .uri("/v4/chat/completions") + .body(chatRequest) + .retrieve() + .toEntity(ChatCompletion.class); + } + + private final ZhiPuAiStreamFunctionCallingHelper chunkMerger = new ZhiPuAiStreamFunctionCallingHelper(); + + /** + * Creates a streaming chat response for the given chat conversation. + * + * @param chatRequest The chat completion request. Must have the stream property set to true. + * @return Returns a {@link Flux} stream from chat completion chunks. + */ + public Flux chatCompletionStream(ChatCompletionRequest chatRequest) { + + Assert.notNull(chatRequest, "The request body can not be null."); + Assert.isTrue(chatRequest.stream(), "Request must set the steam property to true."); + + AtomicBoolean isInsideTool = new AtomicBoolean(false); + + return this.webClient.post() + .uri("/v4/chat/completions") + .body(Mono.just(chatRequest), ChatCompletionRequest.class) + .retrieve() + .bodyToFlux(String.class) + .takeUntil(SSE_DONE_PREDICATE) + .filter(SSE_DONE_PREDICATE.negate()) + .map(content -> ModelOptionsUtils.jsonToObject(content, ChatCompletionChunk.class)) + .map(chunk -> { + if (this.chunkMerger.isStreamingToolFunctionCall(chunk)) { + isInsideTool.set(true); + } + return chunk; + }) + .windowUntil(chunk -> { + if (isInsideTool.get() && this.chunkMerger.isStreamingToolFunctionCallFinish(chunk)) { + isInsideTool.set(false); + return true; + } + return !isInsideTool.get(); + }) + .concatMapIterable(window -> { + Mono monoChunk = window.reduce( + new ChatCompletionChunk(null, null, null, null, null, null), + this.chunkMerger::merge); + return List.of(monoChunk); + }) + .flatMap(mono -> mono); + } + + /** + * ZhiPuAI Embeddings Models: + * Embeddings. + */ + public enum EmbeddingModel { + + /** + * DIMENSION: 1024 + */ + Embedding_2("Embedding-2"); + + public final String value; + + EmbeddingModel(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + /** + * Represents an embedding vector returned by embedding endpoint. + * + * @param index The index of the embedding in the list of embeddings. + * @param embedding The embedding vector, which is a list of floats. The length of vector depends on the model. + * @param object The object type, which is always 'embedding'. + */ + @JsonInclude(Include.NON_NULL) + public record Embedding( + @JsonProperty("index") Integer index, + @JsonProperty("embedding") List embedding, + @JsonProperty("object") String object) { + + /** + * Create an embedding with the given index, embedding and object type set to 'embedding'. + * + * @param index The index of the embedding in the list of embeddings. + * @param embedding The embedding vector, which is a list of floats. The length of vector depends on the model. + */ + public Embedding(Integer index, List embedding) { + this(index, embedding, "embedding"); + } + } + + /** + * Creates an embedding vector representing the input text. + * + * @param input Input text to embed, encoded as a string or array of tokens. + * @param model ID of the model to use. + */ + @JsonInclude(Include.NON_NULL) + public record EmbeddingRequest( + @JsonProperty("input") T input, + @JsonProperty("model") String model) { + + + /** + * Create an embedding request with the given input. Encoding model is set to 'embedding-2'. + * @param input Input text to embed. + */ + public EmbeddingRequest(T input) { + this(input, DEFAULT_EMBEDDING_MODEL); + } + } + + /** + * List of multiple embedding responses. + * + * @param Type of the entities in the data list. + * @param object Must have value "list". + * @param data List of entities. + * @param model ID of the model to use. + * @param usage Usage statistics for the completion request. + */ + @JsonInclude(Include.NON_NULL) + public record EmbeddingList( + @JsonProperty("object") String object, + @JsonProperty("data") List data, + @JsonProperty("model") String model, + @JsonProperty("usage") Usage usage) { + } + + /** + * Creates an embedding vector representing the input text or token array. + * + * @param embeddingRequest The embedding request. + * @return Returns list of {@link Embedding} wrapped in {@link EmbeddingList}. + * @param Type of the entity in the data list. Can be a {@link String} or {@link List} of tokens (e.g. + * Integers). For embedding multiple inputs in a single request, You can pass a {@link List} of {@link String} or + * {@link List} of {@link List} of tokens. For example: + * + *
{@code List.of("text1", "text2", "text3") or List.of(List.of(1, 2, 3), List.of(3, 4, 5))} 
+ */ + public ResponseEntity> embeddings(EmbeddingRequest embeddingRequest) { + + Assert.notNull(embeddingRequest, "The request body can not be null."); + + // Input text to embed, encoded as a string or array of tokens. To embed multiple inputs in a single + // request, pass an array of strings or array of token arrays. + Assert.notNull(embeddingRequest.input(), "The input can not be null."); + Assert.isTrue(embeddingRequest.input() instanceof String || embeddingRequest.input() instanceof List, + "The input must be either a String, or a List of Strings or List of List of integers."); + + if (embeddingRequest.input() instanceof List list) { + Assert.isTrue(!CollectionUtils.isEmpty(list), "The input list can not be empty."); + Assert.isTrue(list.size() <= 512, "The list must be 512 dimensions or less"); + Assert.isTrue(list.get(0) instanceof String || list.get(0) instanceof Integer + || list.get(0) instanceof List, + "The input must be either a String, or a List of Strings or list of list of integers."); + } + + return this.restClient.post() + .uri("/v4/embeddings") + .body(embeddingRequest) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + } + +} +// @formatter:on diff --git a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiImageApi.java b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiImageApi.java new file mode 100644 index 00000000000..85ce72f6c21 --- /dev/null +++ b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiImageApi.java @@ -0,0 +1,130 @@ +/* + * 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.zhipuai.api; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; + +import java.util.List; + +/** + * ZhiPuAI Image API. + * + * @see CogView Images + * @author Geng Rong + * @since 1.0.0 M1 + */ +public class ZhiPuAiImageApi { + + public static final String DEFAULT_IMAGE_MODEL = ImageModel.CogView_3.getValue(); + + private final RestClient restClient; + + /** + * Create a new ZhiPuAI Image api with base URL set to https://api.ZhiPuAI.com + * @param zhiPuAiToken ZhiPuAI apiKey. + */ + public ZhiPuAiImageApi(String zhiPuAiToken) { + this(ApiUtils.DEFAULT_BASE_URL, zhiPuAiToken, RestClient.builder()); + } + + /** + * Create a new ZhiPuAI Image API with the provided base URL. + * @param baseUrl the base URL for the ZhiPuAI API. + * @param zhiPuAiToken ZhiPuAI apiKey. + * @param restClientBuilder the rest client builder to use. + */ + public ZhiPuAiImageApi(String baseUrl, String zhiPuAiToken, RestClient.Builder restClientBuilder) { + this(baseUrl, zhiPuAiToken, restClientBuilder, RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER); + } + + /** + * Create a new ZhiPuAI Image API with the provided base URL. + * @param baseUrl the base URL for the ZhiPuAI API. + * @param zhiPuAiToken ZhiPuAI apiKey. + * @param restClientBuilder the rest client builder to use. + * @param responseErrorHandler the response error handler to use. + */ + public ZhiPuAiImageApi(String baseUrl, String zhiPuAiToken, RestClient.Builder restClientBuilder, + ResponseErrorHandler responseErrorHandler) { + + this.restClient = restClientBuilder.baseUrl(baseUrl) + .defaultHeaders(ApiUtils.getJsonContentHeaders(zhiPuAiToken)) + .defaultStatusHandler(responseErrorHandler) + .build(); + } + + /** + * ZhiPuAI Image API model. + * CogView + */ + public enum ImageModel { + + CogView_3("cogview-3"); + + private final String value; + + ImageModel(String model) { + this.value = model; + } + + public String getValue() { + return this.value; + } + + } + + // @formatter:off + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ZhiPuAiImageRequest ( + @JsonProperty("prompt") String prompt, + @JsonProperty("model") String model, + @JsonProperty("user_id") String user) { + + public ZhiPuAiImageRequest(String prompt, String model) { + this(prompt, model, null); + } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ZhiPuAiImageResponse( + @JsonProperty("created") Long created, + @JsonProperty("data") List data) { + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Data( + @JsonProperty("url") String url) { + } + // @formatter:onn + + public ResponseEntity createImage(ZhiPuAiImageRequest zhiPuAiImageRequest) { + Assert.notNull(zhiPuAiImageRequest, "Image request cannot be null."); + Assert.hasLength(zhiPuAiImageRequest.prompt(), "Prompt cannot be empty."); + + return this.restClient.post() + .uri("/v4/images/generations") + .body(zhiPuAiImageRequest) + .retrieve() + .toEntity(ZhiPuAiImageResponse.class); + } + +} diff --git a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiStreamFunctionCallingHelper.java b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiStreamFunctionCallingHelper.java new file mode 100644 index 00000000000..4a7f24a79e0 --- /dev/null +++ b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiStreamFunctionCallingHelper.java @@ -0,0 +1,195 @@ +/* + * 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.zhipuai.api; + +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.*; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletion.Choice; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionChunk.ChunkChoice; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage.ChatCompletionFunction; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage.Role; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage.ToolCall; +import org.springframework.util.CollectionUtils; + +import java.util.ArrayList; +import java.util.List; + +/** + * Helper class to support Streaming function calling. It can merge the streamed + * ChatCompletionChunk in case of function calling message. + * + * @author Geng Rong + * @since 1.0.0 M1 + */ +public class ZhiPuAiStreamFunctionCallingHelper { + + /** + * Merge the previous and current ChatCompletionChunk into a single one. + * @param previous the previous ChatCompletionChunk + * @param current the current ChatCompletionChunk + * @return the merged ChatCompletionChunk + */ + public ChatCompletionChunk merge(ChatCompletionChunk previous, ChatCompletionChunk current) { + + if (previous == null) { + return current; + } + + String id = (current.id() != null ? current.id() : previous.id()); + Long created = (current.created() != null ? current.created() : previous.created()); + String model = (current.model() != null ? current.model() : previous.model()); + String systemFingerprint = (current.systemFingerprint() != null ? current.systemFingerprint() + : previous.systemFingerprint()); + String object = (current.object() != null ? current.object() : previous.object()); + + ChunkChoice previousChoice0 = (CollectionUtils.isEmpty(previous.choices()) ? null : previous.choices().get(0)); + ChunkChoice currentChoice0 = (CollectionUtils.isEmpty(current.choices()) ? null : current.choices().get(0)); + + ChunkChoice choice = merge(previousChoice0, currentChoice0); + List chunkChoices = choice == null ? List.of() : List.of(choice); + return new ChatCompletionChunk(id, chunkChoices, created, model, systemFingerprint, object); + } + + private ChunkChoice merge(ChunkChoice previous, ChunkChoice current) { + if (previous == null) { + return current; + } + + ChatCompletionFinishReason finishReason = (current.finishReason() != null ? current.finishReason() + : previous.finishReason()); + Integer index = (current.index() != null ? current.index() : previous.index()); + + ChatCompletionMessage message = merge(previous.delta(), current.delta()); + + LogProbs logprobs = (current.logprobs() != null ? current.logprobs() : previous.logprobs()); + return new ChunkChoice(finishReason, index, message, logprobs); + } + + private ChatCompletionMessage merge(ChatCompletionMessage previous, ChatCompletionMessage current) { + String content = (current.content() != null ? current.content() + : (previous.content() != null) ? previous.content() : ""); + Role role = (current.role() != null ? current.role() : previous.role()); + role = (role != null ? role : Role.ASSISTANT); // default to ASSISTANT (if null + String name = (current.name() != null ? current.name() : previous.name()); + String toolCallId = (current.toolCallId() != null ? current.toolCallId() : previous.toolCallId()); + + List toolCalls = new ArrayList<>(); + ToolCall lastPreviousTooCall = null; + if (previous.toolCalls() != null) { + lastPreviousTooCall = previous.toolCalls().get(previous.toolCalls().size() - 1); + if (previous.toolCalls().size() > 1) { + toolCalls.addAll(previous.toolCalls().subList(0, previous.toolCalls().size() - 1)); + } + } + if (current.toolCalls() != null) { + if (current.toolCalls().size() > 1) { + throw new IllegalStateException("Currently only one tool call is supported per message!"); + } + var currentToolCall = current.toolCalls().iterator().next(); + if (currentToolCall.id() != null) { + if (lastPreviousTooCall != null) { + toolCalls.add(lastPreviousTooCall); + } + toolCalls.add(currentToolCall); + } + else { + toolCalls.add(merge(lastPreviousTooCall, currentToolCall)); + } + } + else { + if (lastPreviousTooCall != null) { + toolCalls.add(lastPreviousTooCall); + } + } + return new ChatCompletionMessage(content, role, name, toolCallId, toolCalls); + } + + private ToolCall merge(ToolCall previous, ToolCall current) { + if (previous == null) { + return current; + } + String id = (current.id() != null ? current.id() : previous.id()); + String type = (current.type() != null ? current.type() : previous.type()); + ChatCompletionFunction function = merge(previous.function(), current.function()); + return new ToolCall(id, type, function); + } + + private ChatCompletionFunction merge(ChatCompletionFunction previous, ChatCompletionFunction current) { + if (previous == null) { + return current; + } + String name = (current.name() != null ? current.name() : previous.name()); + StringBuilder arguments = new StringBuilder(); + if (previous.arguments() != null) { + arguments.append(previous.arguments()); + } + if (current.arguments() != null) { + arguments.append(current.arguments()); + } + return new ChatCompletionFunction(name, arguments.toString()); + } + + /** + * @param chatCompletion the ChatCompletionChunk to check + * @return true if the ChatCompletionChunk is a streaming tool function call. + */ + public boolean isStreamingToolFunctionCall(ChatCompletionChunk chatCompletion) { + + if (chatCompletion == null || CollectionUtils.isEmpty(chatCompletion.choices())) { + return false; + } + + var choice = chatCompletion.choices().get(0); + if (choice == null || choice.delta() == null) { + return false; + } + return !CollectionUtils.isEmpty(choice.delta().toolCalls()); + } + + /** + * @param chatCompletion the ChatCompletionChunk to check + * @return true if the ChatCompletionChunk is a streaming tool function call and it is + * the last one. + */ + public boolean isStreamingToolFunctionCallFinish(ChatCompletionChunk chatCompletion) { + + if (chatCompletion == null || CollectionUtils.isEmpty(chatCompletion.choices())) { + return false; + } + + var choice = chatCompletion.choices().get(0); + if (choice == null || choice.delta() == null) { + return false; + } + return choice.finishReason() == ChatCompletionFinishReason.TOOL_CALLS; + } + + /** + * Convert the ChatCompletionChunk into a ChatCompletion. The Usage is set to null. + * @param chunk the ChatCompletionChunk to convert + * @return the ChatCompletion + */ + public ChatCompletion chunkToChatCompletion(ChatCompletionChunk chunk) { + List choices = chunk.choices() + .stream() + .map(chunkChoice -> new Choice(chunkChoice.finishReason(), chunkChoice.index(), chunkChoice.delta(), + chunkChoice.logprobs())) + .toList(); + + return new ChatCompletion(chunk.id(), choices, chunk.created(), chunk.model(), chunk.systemFingerprint(), + "chat.completion", null); + } + +} diff --git a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/metadata/ZhiPuAiUsage.java b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/metadata/ZhiPuAiUsage.java new file mode 100644 index 00000000000..dc47c1c7f0e --- /dev/null +++ b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/metadata/ZhiPuAiUsage.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.zhipuai.metadata; + +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi; +import org.springframework.util.Assert; + +/** + * {@link Usage} implementation for {@literal ZhiPuAI}. + * + * @author Geng Rong + * @since 1.0.0 M1 + */ +public class ZhiPuAiUsage implements Usage { + + public static ZhiPuAiUsage from(ZhiPuAiApi.Usage usage) { + return new ZhiPuAiUsage(usage); + } + + private final ZhiPuAiApi.Usage usage; + + protected ZhiPuAiUsage(ZhiPuAiApi.Usage usage) { + Assert.notNull(usage, "ZhiPuAI Usage must not be null"); + this.usage = usage; + } + + protected ZhiPuAiApi.Usage getUsage() { + return this.usage; + } + + @Override + public Long getPromptTokens() { + return getUsage().promptTokens().longValue(); + } + + @Override + public Long getGenerationTokens() { + return getUsage().completionTokens().longValue(); + } + + @Override + public Long getTotalTokens() { + return getUsage().totalTokens().longValue(); + } + + @Override + public String toString() { + return getUsage().toString(); + } + +} diff --git a/models/spring-ai-zhipuai/src/main/resources/META-INF/spring/aot.factories b/models/spring-ai-zhipuai/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 00000000000..5fc5cb0a7d1 --- /dev/null +++ b/models/spring-ai-zhipuai/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1,2 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=\ + org.springframework.ai.zhipuai.aot.ZhiPuAiRuntimeHints \ No newline at end of file diff --git a/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/ChatCompletionRequestTests.java b/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/ChatCompletionRequestTests.java new file mode 100644 index 00000000000..b958bc013be --- /dev/null +++ b/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/ChatCompletionRequestTests.java @@ -0,0 +1,144 @@ +/* + * 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.zhipuai; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.function.FunctionCallbackWrapper; +import org.springframework.ai.zhipuai.api.MockWeatherService; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +public class ChatCompletionRequestTests { + + @Test + public void createRequestWithChatOptions() { + + var client = new ZhiPuAiChatClient(new ZhiPuAiApi("TEST"), + ZhiPuAiChatOptions.builder().withModel("DEFAULT_MODEL").withTemperature(66.6f).build()); + + var request = client.createRequest(new Prompt("Test message content"), false); + + assertThat(request.messages()).hasSize(1); + assertThat(request.stream()).isFalse(); + + assertThat(request.model()).isEqualTo("DEFAULT_MODEL"); + assertThat(request.temperature()).isEqualTo(66.6f); + + request = client.createRequest(new Prompt("Test message content", + ZhiPuAiChatOptions.builder().withModel("PROMPT_MODEL").withTemperature(99.9f).build()), true); + + assertThat(request.messages()).hasSize(1); + assertThat(request.stream()).isTrue(); + + assertThat(request.model()).isEqualTo("PROMPT_MODEL"); + assertThat(request.temperature()).isEqualTo(99.9f); + } + + @Test + public void promptOptionsTools() { + + final String TOOL_FUNCTION_NAME = "CurrentWeather"; + + var client = new ZhiPuAiChatClient(new ZhiPuAiApi("TEST"), + ZhiPuAiChatOptions.builder().withModel("DEFAULT_MODEL").build()); + + var request = client.createRequest(new Prompt("Test message content", + ZhiPuAiChatOptions.builder() + .withModel("PROMPT_MODEL") + .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) + .withName(TOOL_FUNCTION_NAME) + .withDescription("Get the weather in location") + .withResponseConverter((response) -> "" + response.temp() + response.unit()) + .build())) + .build()), + false); + + assertThat(client.getFunctionCallbackRegister()).hasSize(1); + assertThat(client.getFunctionCallbackRegister()).containsKeys(TOOL_FUNCTION_NAME); + + assertThat(request.messages()).hasSize(1); + assertThat(request.stream()).isFalse(); + assertThat(request.model()).isEqualTo("PROMPT_MODEL"); + + assertThat(request.tools()).hasSize(1); + assertThat(request.tools().get(0).function().name()).isEqualTo(TOOL_FUNCTION_NAME); + } + + @Test + public void defaultOptionsTools() { + + final String TOOL_FUNCTION_NAME = "CurrentWeather"; + + var client = new ZhiPuAiChatClient(new ZhiPuAiApi("TEST"), + ZhiPuAiChatOptions.builder() + .withModel("DEFAULT_MODEL") + .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) + .withName(TOOL_FUNCTION_NAME) + .withDescription("Get the weather in location") + .withResponseConverter((response) -> "" + response.temp() + response.unit()) + .build())) + .build()); + + var request = client.createRequest(new Prompt("Test message content"), false); + + assertThat(client.getFunctionCallbackRegister()).hasSize(1); + assertThat(client.getFunctionCallbackRegister()).containsKeys(TOOL_FUNCTION_NAME); + assertThat(client.getFunctionCallbackRegister().get(TOOL_FUNCTION_NAME).getDescription()) + .isEqualTo("Get the weather in location"); + + assertThat(request.messages()).hasSize(1); + assertThat(request.stream()).isFalse(); + assertThat(request.model()).isEqualTo("DEFAULT_MODEL"); + + assertThat(request.tools()).as("Default Options callback functions are not automatically enabled!") + .isNullOrEmpty(); + + // Explicitly enable the function + request = client.createRequest(new Prompt("Test message content", + ZhiPuAiChatOptions.builder().withFunction(TOOL_FUNCTION_NAME).build()), false); + + assertThat(request.tools()).hasSize(1); + assertThat(request.tools().get(0).function().name()).as("Explicitly enabled function") + .isEqualTo(TOOL_FUNCTION_NAME); + + // Override the default options function with one from the prompt + request = client.createRequest(new Prompt("Test message content", + ZhiPuAiChatOptions.builder() + .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) + .withName(TOOL_FUNCTION_NAME) + .withDescription("Overridden function description") + .build())) + .build()), + false); + + assertThat(request.tools()).hasSize(1); + assertThat(request.tools().get(0).function().name()).as("Explicitly enabled function") + .isEqualTo(TOOL_FUNCTION_NAME); + + assertThat(client.getFunctionCallbackRegister()).hasSize(1); + assertThat(client.getFunctionCallbackRegister()).containsKeys(TOOL_FUNCTION_NAME); + assertThat(client.getFunctionCallbackRegister().get(TOOL_FUNCTION_NAME).getDescription()) + .isEqualTo("Overridden function description"); + } + +} diff --git a/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/ZhiPuAiTestConfiguration.java b/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/ZhiPuAiTestConfiguration.java new file mode 100644 index 00000000000..92f0bbb28df --- /dev/null +++ b/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/ZhiPuAiTestConfiguration.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.zhipuai; + +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi; +import org.springframework.ai.zhipuai.api.ZhiPuAiImageApi; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.util.StringUtils; + +/** + * @author Geng Rong + */ +@SpringBootConfiguration +public class ZhiPuAiTestConfiguration { + + @Bean + public ZhiPuAiApi zhiPuAiApi() { + return new ZhiPuAiApi(getApiKey()); + } + + @Bean + public ZhiPuAiImageApi zhiPuAiImageApi() { + return new ZhiPuAiImageApi(getApiKey()); + } + + private String getApiKey() { + String apiKey = System.getenv("ZHIPU_AI_API_KEY"); + if (!StringUtils.hasText(apiKey)) { + throw new IllegalArgumentException( + "You must provide an API key. Put it in an environment variable under the name ZHIPU_AI_API_KEY"); + } + return apiKey; + } + + @Bean + public ZhiPuAiChatClient zhiPuAiChatClient(ZhiPuAiApi api) { + return new ZhiPuAiChatClient(api); + } + + @Bean + public ZhiPuAiImageClient zhiPuAiImageClient(ZhiPuAiImageApi imageApi) { + return new ZhiPuAiImageClient(imageApi); + } + + @Bean + public EmbeddingClient zhiPuAiEmbeddingClient(ZhiPuAiApi api) { + return new ZhiPuAiEmbeddingClient(api); + } + +} diff --git a/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/MockWeatherService.java b/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/MockWeatherService.java new file mode 100644 index 00000000000..937665d3de7 --- /dev/null +++ b/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/MockWeatherService.java @@ -0,0 +1,92 @@ +/* + * 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.zhipuai.api; + +import com.fasterxml.jackson.annotation.JsonClassDescription; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +import java.util.function.Function; + +/** + * @author Geng Rong + */ +public class MockWeatherService implements Function { + + /** + * Weather Function request. + */ + @JsonInclude(Include.NON_NULL) + @JsonClassDescription("Weather API request") + public record Request(@JsonProperty(required = true, + value = "location") @JsonPropertyDescription("The city and state e.g. San Francisco, CA") String location, + @JsonProperty(required = true, value = "lat") @JsonPropertyDescription("The city latitude") double lat, + @JsonProperty(required = true, value = "lon") @JsonPropertyDescription("The city longitude") double lon, + @JsonProperty(required = true, value = "unit") @JsonPropertyDescription("Temperature unit") Unit unit) { + } + + /** + * Temperature units. + */ + public enum Unit { + + /** + * Celsius. + */ + C("metric"), + /** + * Fahrenheit. + */ + F("imperial"); + + /** + * Human readable unit name. + */ + public final String unitName; + + private Unit(String text) { + this.unitName = text; + } + + } + + /** + * Weather Function response. + */ + public record Response(double temp, double feels_like, double temp_min, double temp_max, int pressure, int humidity, + Unit unit) { + } + + @Override + public Response apply(Request request) { + + double temperature = 0; + if (request.location().contains("Paris")) { + temperature = 15; + } + else if (request.location().contains("Tokyo")) { + temperature = 10; + } + else if (request.location().contains("San Francisco")) { + temperature = 30; + } + + return new Response(temperature, 15, 20, 2, 53, 45, request.unit); + } + +} \ No newline at end of file diff --git a/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiApiIT.java b/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiApiIT.java new file mode 100644 index 00000000000..18eea6b7450 --- /dev/null +++ b/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiApiIT.java @@ -0,0 +1,68 @@ +/* + * 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.zhipuai.api; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.*; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage.Role; +import org.springframework.http.ResponseEntity; +import reactor.core.publisher.Flux; + +import java.util.List; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "ZHIPU_AI_API_KEY", matches = ".+") +public class ZhiPuAiApiIT { + + ZhiPuAiApi zhiPuAiApi = new ZhiPuAiApi(System.getenv("ZHIPU_AI_API_KEY")); + + @Test + void chatCompletionEntity() { + ChatCompletionMessage chatCompletionMessage = new ChatCompletionMessage("Hello world", Role.USER); + ResponseEntity response = zhiPuAiApi.chatCompletionEntity( + new ChatCompletionRequest(List.of(chatCompletionMessage), "glm-3-turbo", 0.7f, false)); + + assertThat(response).isNotNull(); + assertThat(response.getBody()).isNotNull(); + } + + @Test + void chatCompletionStream() { + ChatCompletionMessage chatCompletionMessage = new ChatCompletionMessage("Hello world", Role.USER); + Flux response = zhiPuAiApi + .chatCompletionStream(new ChatCompletionRequest(List.of(chatCompletionMessage), "glm-3-turbo", 0.7f, true)); + + assertThat(response).isNotNull(); + assertThat(response.collectList().block()).isNotNull(); + } + + @Test + void embeddings() { + ResponseEntity> response = zhiPuAiApi + .embeddings(new ZhiPuAiApi.EmbeddingRequest<>("Hello world")); + + assertThat(response).isNotNull(); + assertThat(Objects.requireNonNull(response.getBody()).data()).hasSize(1); + assertThat(response.getBody().data().get(0).embedding()).hasSize(1024); + } + +} diff --git a/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiApiToolFunctionCallIT.java b/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiApiToolFunctionCallIT.java new file mode 100644 index 00000000000..a56231ee78e --- /dev/null +++ b/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiApiToolFunctionCallIT.java @@ -0,0 +1,148 @@ +/* + * 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.zhipuai.api; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletion; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage.Role; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage.ToolCall; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionRequest; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionRequest.ToolChoiceBuilder; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.FunctionTool.Type; +import org.springframework.http.ResponseEntity; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatModel.GLM_4; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "ZHIPU_AI_API_KEY", matches = ".+") +public class ZhiPuAiApiToolFunctionCallIT { + + private final Logger logger = LoggerFactory.getLogger(ZhiPuAiApiToolFunctionCallIT.class); + + MockWeatherService weatherService = new MockWeatherService(); + + ZhiPuAiApi zhiPuAiApi = new ZhiPuAiApi(System.getenv("ZHIPU_AI_API_KEY")); + + @SuppressWarnings("null") + @Test + public void toolFunctionCall() { + + // Step 1: send the conversation and available functions to the model + var message = new ChatCompletionMessage("What's the weather like in San Francisco, Tokyo, and Paris?", + Role.USER); + + var functionTool = new ZhiPuAiApi.FunctionTool(Type.FUNCTION, + new ZhiPuAiApi.FunctionTool.Function( + "Get the weather in location. Return temperature in 30°F or 30°C format.", "getCurrentWeather", + ModelOptionsUtils.jsonToMap(""" + { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state e.g. San Francisco, CA" + }, + "lat": { + "type": "number", + "description": "The city latitude" + }, + "lon": { + "type": "number", + "description": "The city longitude" + }, + "unit": { + "type": "string", + "enum": ["C", "F"] + } + }, + "required": ["location", "lat", "lon", "unit"] + } + """))); + + List messages = new ArrayList<>(List.of(message)); + + ChatCompletionRequest chatCompletionRequest = new ChatCompletionRequest(messages, GLM_4.value, + List.of(functionTool), ToolChoiceBuilder.AUTO); + + ResponseEntity chatCompletion = zhiPuAiApi.chatCompletionEntity(chatCompletionRequest); + + assertThat(chatCompletion.getBody()).isNotNull(); + assertThat(chatCompletion.getBody().choices()).isNotEmpty(); + + ChatCompletionMessage responseMessage = chatCompletion.getBody().choices().get(0).message(); + + assertThat(responseMessage.role()).isEqualTo(Role.ASSISTANT); + assertThat(responseMessage.toolCalls()).isNotNull(); + + messages.add(responseMessage); + + // Send the info for each function call and function response to the model. + for (ToolCall toolCall : responseMessage.toolCalls()) { + var functionName = toolCall.function().name(); + if ("getCurrentWeather".equals(functionName)) { + MockWeatherService.Request weatherRequest = fromJson(toolCall.function().arguments(), + MockWeatherService.Request.class); + + MockWeatherService.Response weatherResponse = weatherService.apply(weatherRequest); + + // extend conversation with function response. + messages.add(new ChatCompletionMessage("" + weatherResponse.temp() + weatherRequest.unit(), Role.TOOL, + functionName, toolCall.id(), null)); + } + } + + var functionResponseRequest = new ChatCompletionRequest(messages, GLM_4.value, 0.8f); + + ResponseEntity chatCompletion2 = zhiPuAiApi.chatCompletionEntity(functionResponseRequest); + + logger.info("Final response: " + chatCompletion2.getBody()); + + assertThat(Objects.requireNonNull(chatCompletion2.getBody()).choices()).isNotEmpty(); + + assertThat(chatCompletion2.getBody().choices().get(0).message().role()).isEqualTo(Role.ASSISTANT); + assertThat(chatCompletion2.getBody().choices().get(0).message().content()).contains("San Francisco") + .containsAnyOf("30.0°C", "30°C", "30.0°F", "30°F"); + assertThat(chatCompletion2.getBody().choices().get(0).message().content()).contains("Tokyo") + .containsAnyOf("10.0°C", "10°C", "10.0°F", "10°F"); + assertThat(chatCompletion2.getBody().choices().get(0).message().content()).contains("Paris") + .containsAnyOf("15.0°C", "15°C", "15.0°F", "15°F"); + } + + private static T fromJson(String json, Class targetClass) { + try { + return new ObjectMapper().readValue(json, targetClass); + } + catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + +} \ No newline at end of file diff --git a/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiRetryTests.java b/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiRetryTests.java new file mode 100644 index 00000000000..61ea6d35359 --- /dev/null +++ b/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiRetryTests.java @@ -0,0 +1,212 @@ +/* + * 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.zhipuai.api; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.image.ImageMessage; +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.ai.retry.TransientAiException; +import org.springframework.ai.zhipuai.*; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.*; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage.Role; +import org.springframework.ai.zhipuai.api.ZhiPuAiImageApi.Data; +import org.springframework.ai.zhipuai.api.ZhiPuAiImageApi.ZhiPuAiImageRequest; +import org.springframework.ai.zhipuai.api.ZhiPuAiImageApi.ZhiPuAiImageResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.retry.RetryCallback; +import org.springframework.retry.RetryContext; +import org.springframework.retry.RetryListener; +import org.springframework.retry.support.RetryTemplate; +import reactor.core.publisher.Flux; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.when; + +/** + * @author Geng Rong + */ +@SuppressWarnings("unchecked") +@ExtendWith(MockitoExtension.class) +public class ZhiPuAiRetryTests { + + private class TestRetryListener implements RetryListener { + + int onErrorRetryCount = 0; + + int onSuccessRetryCount = 0; + + @Override + public void onSuccess(RetryContext context, RetryCallback callback, T result) { + onSuccessRetryCount = context.getRetryCount(); + } + + @Override + public void onError(RetryContext context, RetryCallback callback, + Throwable throwable) { + onErrorRetryCount = context.getRetryCount(); + } + + } + + private TestRetryListener retryListener; + + private RetryTemplate retryTemplate; + + private @Mock ZhiPuAiApi zhiPuAiApi; + + private @Mock ZhiPuAiImageApi zhiPuAiImageApi; + + private ZhiPuAiChatClient chatClient; + + private ZhiPuAiEmbeddingClient embeddingClient; + + private ZhiPuAiImageClient imageClient; + + @BeforeEach + public void beforeEach() { + retryTemplate = RetryUtils.DEFAULT_RETRY_TEMPLATE; + retryListener = new TestRetryListener(); + retryTemplate.registerListener(retryListener); + + chatClient = new ZhiPuAiChatClient(zhiPuAiApi, ZhiPuAiChatOptions.builder().build(), null, retryTemplate); + embeddingClient = new ZhiPuAiEmbeddingClient(zhiPuAiApi, MetadataMode.EMBED, + ZhiPuAiEmbeddingOptions.builder().build(), retryTemplate); + imageClient = new ZhiPuAiImageClient(zhiPuAiImageApi, ZhiPuAiImageOptions.builder().build(), retryTemplate); + } + + @Test + public void zhiPuAiChatTransientError() { + + var choice = new ChatCompletion.Choice(ChatCompletionFinishReason.STOP, 0, + new ChatCompletionMessage("Response", Role.ASSISTANT), null); + ChatCompletion expectedChatCompletion = new ChatCompletion("id", List.of(choice), 666l, "model", null, null, + new ZhiPuAiApi.Usage(10, 10, 10)); + + when(zhiPuAiApi.chatCompletionEntity(isA(ChatCompletionRequest.class))) + .thenThrow(new TransientAiException("Transient Error 1")) + .thenThrow(new TransientAiException("Transient Error 2")) + .thenReturn(ResponseEntity.of(Optional.of(expectedChatCompletion))); + + var result = chatClient.call(new Prompt("text")); + + assertThat(result).isNotNull(); + assertThat(result.getResult().getOutput().getContent()).isSameAs("Response"); + assertThat(retryListener.onSuccessRetryCount).isEqualTo(2); + assertThat(retryListener.onErrorRetryCount).isEqualTo(2); + } + + @Test + public void zhiPuAiChatNonTransientError() { + when(zhiPuAiApi.chatCompletionEntity(isA(ChatCompletionRequest.class))) + .thenThrow(new RuntimeException("Non Transient Error")); + assertThrows(RuntimeException.class, () -> chatClient.call(new Prompt("text"))); + } + + @Test + public void zhiPuAiChatStreamTransientError() { + + var choice = new ChatCompletionChunk.ChunkChoice(ChatCompletionFinishReason.STOP, 0, + new ChatCompletionMessage("Response", Role.ASSISTANT), null); + ChatCompletionChunk expectedChatCompletion = new ChatCompletionChunk("id", List.of(choice), 666l, "model", null, + null); + + when(zhiPuAiApi.chatCompletionStream(isA(ChatCompletionRequest.class))) + .thenThrow(new TransientAiException("Transient Error 1")) + .thenThrow(new TransientAiException("Transient Error 2")) + .thenReturn(Flux.just(expectedChatCompletion)); + + var result = chatClient.stream(new Prompt("text")); + + assertThat(result).isNotNull(); + assertThat(result.collectList().block().get(0).getResult().getOutput().getContent()).isSameAs("Response"); + assertThat(retryListener.onSuccessRetryCount).isEqualTo(2); + assertThat(retryListener.onErrorRetryCount).isEqualTo(2); + } + + @Test + public void zhiPuAiChatStreamNonTransientError() { + when(zhiPuAiApi.chatCompletionStream(isA(ChatCompletionRequest.class))) + .thenThrow(new RuntimeException("Non Transient Error")); + assertThrows(RuntimeException.class, () -> chatClient.stream(new Prompt("text"))); + } + + @Test + public void zhiPuAiEmbeddingTransientError() { + + EmbeddingList expectedEmbeddings = new EmbeddingList<>("list", + List.of(new Embedding(0, List.of(9.9, 8.8))), "model", new ZhiPuAiApi.Usage(10, 10, 10)); + + when(zhiPuAiApi.embeddings(isA(EmbeddingRequest.class))) + .thenThrow(new TransientAiException("Transient Error 1")) + .thenThrow(new TransientAiException("Transient Error 2")) + .thenReturn(ResponseEntity.of(Optional.of(expectedEmbeddings))); + + var result = embeddingClient + .call(new org.springframework.ai.embedding.EmbeddingRequest(List.of("text1", "text2"), null)); + + assertThat(result).isNotNull(); + assertThat(result.getResult().getOutput()).isEqualTo(List.of(9.9, 8.8)); + assertThat(retryListener.onSuccessRetryCount).isEqualTo(2); + assertThat(retryListener.onErrorRetryCount).isEqualTo(2); + } + + @Test + public void zhiPuAiEmbeddingNonTransientError() { + when(zhiPuAiApi.embeddings(isA(EmbeddingRequest.class))) + .thenThrow(new RuntimeException("Non Transient Error")); + assertThrows(RuntimeException.class, () -> embeddingClient + .call(new org.springframework.ai.embedding.EmbeddingRequest(List.of("text1", "text2"), null))); + } + + @Test + public void zhiPuAiImageTransientError() { + + var expectedResponse = new ZhiPuAiImageResponse(678l, List.of(new Data("url678"))); + + when(zhiPuAiImageApi.createImage(isA(ZhiPuAiImageRequest.class))) + .thenThrow(new TransientAiException("Transient Error 1")) + .thenThrow(new TransientAiException("Transient Error 2")) + .thenReturn(ResponseEntity.of(Optional.of(expectedResponse))); + + var result = imageClient.call(new ImagePrompt(List.of(new ImageMessage("Image Message")))); + + assertThat(result).isNotNull(); + assertThat(result.getResult().getOutput().getUrl()).isEqualTo("url678"); + assertThat(retryListener.onSuccessRetryCount).isEqualTo(2); + assertThat(retryListener.onErrorRetryCount).isEqualTo(2); + } + + @Test + public void zhiPuAiImageNonTransientError() { + when(zhiPuAiImageApi.createImage(isA(ZhiPuAiImageRequest.class))) + .thenThrow(new RuntimeException("Transient Error 1")); + assertThrows(RuntimeException.class, + () -> imageClient.call(new ImagePrompt(List.of(new ImageMessage("Image Message"))))); + } + +} diff --git a/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/image/ZhiPuAiImageClientIT.java b/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/image/ZhiPuAiImageClientIT.java new file mode 100644 index 00000000000..ea3f9dca16b --- /dev/null +++ b/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/image/ZhiPuAiImageClientIT.java @@ -0,0 +1,56 @@ +/* + * 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.zhipuai.image; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.ai.image.*; +import org.springframework.ai.zhipuai.ZhiPuAiTestConfiguration; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = ZhiPuAiTestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "ZHIPU_AI_API_KEY", matches = ".+") +public class ZhiPuAiImageClientIT { + + @Autowired + protected ImageClient imageClient; + + @Test + void imageAsUrlTest() { + var options = ImageOptionsBuilder.builder().withHeight(1024).withWidth(1024).build(); + + var instructions = """ + A light cream colored mini golden doodle with a sign that contains the message "I'm on my way to BARCADE!"."""; + + ImagePrompt imagePrompt = new ImagePrompt(instructions, options); + + ImageResponse imageResponse = imageClient.call(imagePrompt); + + assertThat(imageResponse.getResults()).hasSize(1); + + ImageResponseMetadata imageResponseMetadata = imageResponse.getMetadata(); + assertThat(imageResponseMetadata.created()).isPositive(); + + var generation = imageResponse.getResult(); + Image image = generation.getOutput(); + assertThat(image.getUrl()).isNotEmpty(); + assertThat(image.getB64Json()).isNull(); + } + +} diff --git a/models/spring-ai-zhipuai/src/test/resources/prompts/system-message.st b/models/spring-ai-zhipuai/src/test/resources/prompts/system-message.st new file mode 100644 index 00000000000..579febd8d9b --- /dev/null +++ b/models/spring-ai-zhipuai/src/test/resources/prompts/system-message.st @@ -0,0 +1,3 @@ +You are an AI assistant that helps people find information. +Your name is {name}. +You should reply to the user's request with your name and also in the style of a {voice}. \ No newline at end of file diff --git a/pom.xml b/pom.xml index 42f645f3de2..5f583cbb364 100644 --- a/pom.xml +++ b/pom.xml @@ -67,6 +67,7 @@ models/spring-ai-vertex-ai-gemini models/spring-ai-vertex-ai-palm2 models/spring-ai-watsonx-ai + models/spring-ai-zhipuai spring-ai-spring-boot-starters/spring-ai-starter-anthropic spring-ai-spring-boot-starters/spring-ai-starter-azure-openai @@ -81,8 +82,7 @@ spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-gemini spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-palm2 spring-ai-spring-boot-starters/spring-ai-starter-watsonx-ai - - + spring-ai-spring-boot-starters/spring-ai-starter-zhipuai diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index 55684225237..84615ecc2d4 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -308,6 +308,12 @@ ${project.version}
+ + org.springframework.ai + spring-ai-zhipuai + ${project.version} + + org.springframework.ai spring-ai-pinecone-store-spring-boot-starter @@ -396,6 +402,12 @@ spring-ai-minimax-spring-boot-starter ${project.version} + + + org.springframework.ai + spring-ai-zhipuai-spring-boot-starter + ${project.version} + 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 e8d73576549..62d2a59aec7 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc @@ -22,6 +22,8 @@ ***** xref:api/chat/functions/vertexai-gemini-chat-functions.adoc[Function Calling] *** xref:api/chat/mistralai-chat.adoc[Mistral AI] **** xref:api/chat/functions/mistralai-chat-functions.adoc[Function Calling] +*** xref:api/chat/zhipuai-chat.adoc[ZhiPu AI] +**** xref:api/chat/functions/zhipuai-chat-functions.adoc[Function Calling] *** xref:api/chat/anthropic-chat.adoc[Anthropic 3] **** xref:api/chat/functions/anthropic-chat-functions.adoc[Function Calling] *** xref:api/chat/watsonx-ai-chat.adoc[Watsonx.AI] @@ -39,9 +41,11 @@ *** xref:api/embeddings/onnx.adoc[Transformers (ONNX)] *** xref:api/embeddings/mistralai-embeddings.adoc[Mistral AI] *** xref:api/embeddings/minimax-embeddings.adoc[MiniMax] +*** xref:api/embeddings/zhipuai-embeddings.adoc[ZhiPu AI] ** xref:api/imageclient.adoc[] *** xref:api/image/openai-image.adoc[OpenAI] *** xref:api/image/stabilityai-image.adoc[Stability] +*** xref:api/image/zhipuai-image.adoc[ZhiPuAI] ** xref:api/audio[Audio API] *** xref:api/audio/transcriptions.adoc[] **** xref:api/audio/transcriptions/openai-transcriptions.adoc[OpenAI] diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/functions/zhipuai-chat-functions.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/functions/zhipuai-chat-functions.adoc new file mode 100644 index 00000000000..338505b78b5 --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/functions/zhipuai-chat-functions.adoc @@ -0,0 +1,226 @@ += Function Calling + +You can register custom Java functions with the `ZhiPuAiChatClient` and have the ZhiPuAI model intelligently choose to output a JSON object containing arguments to call one or many of the registered functions. +This allows you to connect the LLM capabilities with external tools and APIs. +The ZhiPuAI models are trained to detect when a function should be called and to respond with JSON that adheres to the function signature. + +The ZhiPuAI API does not call the function directly; instead, the model generates JSON that you can use to call the function in your code and return the result back to the model to complete the conversation. + +Spring AI provides flexible and user-friendly ways to register and call custom functions. +In general, the custom functions need to provide a function `name`, `description`, and the function call `signature` (as JSON schema) to let the model know what arguments the function expects. The `description` helps the model to understand when to call the function. + +As a developer, you need to implement a functions that takes the function call arguments sent from the AI model, and respond with the result back to the model. Your function can in turn invoke other 3rd party services to provide the results. + +Spring AI makes this as easy as defining a `@Bean` definition that returns a `java.util.Function` and supplying the bean name as an option when invoking the `ChatClient`. + +Under the hood, Spring wraps your POJO (the function) with the appropriate adapter code that enables interaction with the AI Model, saving you from writing tedious boilerplate code. +The basis of the underlying infrastructure is the link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/model/function/FunctionCallback.java[FunctionCallback.java] interface and the companion link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/model/function/FunctionCallbackWrapper.java[FunctionCallbackWrapper.java] utility class to simplify the implementation and registration of Java callback functions. + +// Additionally, the Auto-Configuration provides a way to auto-register any Function beans definition as function calling candidates in the `ChatClient`. + + +== How it works + +Suppose we want the AI model to respond with information that it does not have, for example the current temperature at a given location. + +We can provide the AI model with metadata about our own functions that it can use to retrieve that information as it processes your prompt. + +For example, if during the processing of a prompt, the AI Model determines that it needs additional information about the temperature in a given location, it will start a server side generated request/response interaction. The AI Model invokes a client side function. +The AI Model provides method invocation details as JSON and it is the responsibility of the client to execute that function and return the response. + +The model-client interaction is illustrated in the <> diagram. + +Spring AI greatly simplifies code you need to write to support function invocation. +It brokers the function invocation conversation for you. +You can simply provide your function definition as a `@Bean` and then provide the bean name of the function in your prompt options. +You can also reference multiple function bean names in your prompt. + +== Quick Start + +Let's create a chatbot that answer questions by calling our own function. +To support the response of the chatbot, we will register our own function that takes a location and returns the current weather in that location. + +When the response to the prompt to the model needs to answer a question such as `"What’s the weather like in Boston?"` the AI model will invoke the client providing the location value as an argument to be passed to the function. This RPC-like data is passed as JSON. + +Our function calls some SaaS based weather service API and returns the weather response back to the model to complete the conversation. In this example we will use a simple implementation named `MockWeatherService` that hard codes the temperature for various locations. + +The following `MockWeatherService.java` represents the weather service API: + +[source,java] +---- +public class MockWeatherService implements Function { + + public enum Unit { C, F } + public record Request(String location, Unit unit) {} + public record Response(double temp, Unit unit) {} + + public Response apply(Request request) { + return new Response(30.0, Unit.C); + } +} +---- + +=== Registering Functions as Beans + +With the link:../zhipuai-chat.html#_auto_configuration[ZhiPuAiChatClient Auto-Configuration] you have multiple ways to register custom functions as beans in the Spring context. + +We start with describing the most POJO friendly options. + + +==== Plain Java Functions + +In this approach you define `@Beans` in your application context as you would any other Spring managed object. + +Internally, Spring AI `ChatClient` will create an instance of a `FunctionCallbackWrapper` wrapper that adds the logic for it being invoked via the AI model. +The name of the `@Bean` is passed as a `ChatOption`. + + +[source,java] +---- +@Configuration +static class Config { + + @Bean + @Description("Get the weather in location") // function description + public Function weatherFunction1() { + return new MockWeatherService(); + } + ... +} +---- + +The `@Description` annotation is optional and provides a function description (2) that helps the model to understand when to call the function. It is an important property to set to help the AI model determine what client side function to invoke. + +Another option to provide the description of the function is to the `@JacksonDescription` annotation on the `MockWeatherService.Request` to provide the function description: + +[source,java] +---- + +@Configuration +static class Config { + + @Bean + public Function currentWeather3() { // (1) bean name as function name. + return new MockWeatherService(); + } + ... +} + +@JsonClassDescription("Get the weather in location") // (2) function description +public record Request(String location, Unit unit) {} +---- + +It is a best practice to annotate the request object with information such that the generates JSON schema of that function is as descriptive as possible to help the AI model pick the correct function to invoke. + +The link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackWithPlainFunctionBeanIT.java[FunctionCallbackWithPlainFunctionBeanIT.java] demonstrates this approach. + + +==== FunctionCallback Wrapper + +Another way register a function is to create `FunctionCallbackWrapper` wrapper like this: + +[source,java] +---- +@Configuration +static class Config { + + @Bean + public FunctionCallback weatherFunctionInfo() { + + return new FunctionCallbackWrapper<>("CurrentWeather", // (1) function name + "Get the weather in location", // (2) function description + (response) -> "" + response.temp() + response.unit(), // (3) Response Converter + new MockWeatherService()); // function code + } + ... +} +---- + +It wraps the 3rd party, `MockWeatherService` function and registers it as a `CurrentWeather` function with the `ZhiPuAiChatClient`. +It also provides a description (2) and an optional response converter (3) to convert the response into a text as expected by the model. + +NOTE: By default, the response converter does a JSON serialization of the Response object. + +NOTE: The `FunctionCallbackWrapper` internally resolves the function call signature based on the `MockWeatherService.Request` class. + +=== Specifying functions in Chat Options + +To let the model know and call your `CurrentWeather` function you need to enable it in your prompt requests: + +[source,java] +---- +ZhiPuAiChatClient chatClient = ... + +UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + +ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), + ZhiPuAiChatOptions.builder().withFunction("CurrentWeather").build())); // (1) Enable the function + +logger.info("Response: {}", response); +---- + +// NOTE: You can can have multiple functions registered in your `ChatClient` but only those enabled in the prompt request will be considered for the function calling. + +Above user question will trigger 3 calls to `CurrentWeather` function (one for each city) and the final response will be something like this: + +---- +Here is the current weather for the requested cities: +- San Francisco, CA: 30.0°C +- Tokyo, Japan: 10.0°C +- Paris, France: 15.0°C +---- + +The link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackWrapperIT.java[FunctionCallbackWrapperIT.java] test demo this approach. + + +=== Register/Call Functions with Prompt Options + +In addition to the auto-configuration you can register callback functions, dynamically, with your Prompt requests: + +[source,java] +---- +ZhiPuAiChatClient chatClient = ... + +UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + +var promptOptions = ZhiPuAiChatOptions.builder() + .withFunctionCallbacks(List.of(new FunctionCallbackWrapper<>( + "CurrentWeather", // name + "Get the weather in location", // function description + new MockWeatherService()))) // function code + .build(); + +ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), promptOptions)); +---- + +NOTE: The in-prompt registered functions are enabled by default for the duration of this request. + +This approach allows to dynamically chose different functions to be called based on the user input. + +The https://github.com/spring-projects/spring-ai/blob/main/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackInPromptIT.java[FunctionCallbackInPromptIT.java] integration test provides a complete example of how to register a function with the `ZhiPuAiChatClient` and use it in a prompt request. +// +// === Register Functions with Default Options +// +// You can programmatically register functions with the `ZhiPuAiChatClient` using the `ZhiPuAiChatOptions#withFunctionCallbacks`: +// +// [source,java] +// ---- +// +// ZhiPuAiApi zhiPuAiApi = new ZhiPuAiApi(apiKey); +// +// var defaultOptions = ZhiPuAiChatOptions.builder() +// .withFunctionCallbacks(List.of(new FunctionCallbackWrapper<>( +// "CurrentWeather", // name +// "Get the weather in location", // function description +// new MockWeatherService()))) // function code +// .build(); +// +// ZhiPuAiChatClient chatClient = new ZhiPuAiChatClient(zhiPuAiApi, defaultOptions); +// +// UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); +// +// ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), +// ZhiPuAiChatOptions.builder().withFunction("CurrentWeather").build())); // Enable the function +// ---- +// +// NOTE: Functions are registered when ZhiPuAiChatClient is created, by you must enable in the Prompt the functions to be used in the request. \ No newline at end of file diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/zhipuai-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/zhipuai-chat.adoc new file mode 100644 index 00000000000..a88b2526e75 --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/zhipuai-chat.adoc @@ -0,0 +1,250 @@ += ZhiPu AI Chat + +Spring AI supports the various AI language models from ZhiPu AI. You can interact with ZhiPu AI language models and create a multilingual conversational assistant based on ZhiPuAI models. + +== Prerequisites + +You will need to create an API with ZhiPuAI to access ZhiPu AI language models. + +Create an account at https://open.bigmodel.cn/login[ZhiPu AI registration page] and generate the token on the https://open.bigmodel.cn/usercenter/apikeys[API Keys page]. +The Spring AI project defines a configuration property named `spring.ai.zhipuai.api-key` that you should set to the value of the `API Key` obtained from https://open.bigmodel.cn/usercenter/apikeys[API Keys page]. +Exporting an environment variable is one way to set that configuration property: + +[source,shell] +---- +export SPRING_AI_ZHIPU_AI_API_KEY= +---- + +=== Add Repositories and BOM + +Spring AI artifacts are published in Spring Milestone and Snapshot repositories. +Refer to the xref:getting-started.adoc#repositories[Repositories] section to add these repositories to your build system. + +To help with dependency management, Spring AI provides a BOM (bill of materials) to ensure that a consistent version of Spring AI is used throughout the entire project. Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build system. + + + +== Auto-configuration + +Spring AI provides Spring Boot auto-configuration for the ZhiPuAI Chat Client. +To enable it add the following dependency to your project's Maven `pom.xml` file: + +[source, xml] +---- + + org.springframework.ai + spring-ai-zhipuai-spring-boot-starter + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-zhipuai-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. + +=== Chat Properties + +==== Retry Properties + +The prefix `spring.ai.retry` is used as the property prefix that lets you configure the retry mechanism for the ZhiPu AI Chat client. + +[cols="3,5,1"] +|==== +| Property | Description | Default + +| spring.ai.retry.max-attempts | Maximum number of retry attempts. | 10 +| spring.ai.retry.backoff.initial-interval | Initial sleep duration for the exponential backoff policy. | 2 sec. +| spring.ai.retry.backoff.multiplier | Backoff interval multiplier. | 5 +| spring.ai.retry.backoff.max-interval | Maximum backoff duration. | 3 min. +| spring.ai.retry.on-client-errors | If false, throw a NonTransientAiException, and do not attempt retry for `4xx` client error codes | false +| spring.ai.retry.exclude-on-http-codes | List of HTTP status codes that should not trigger a retry (e.g. to throw NonTransientAiException). | empty +| spring.ai.retry.on-http-codes | List of HTTP status codes that should trigger a retry (e.g. to throw TransientAiException). | empty +|==== + +==== Connection Properties + +The prefix `spring.ai.zhiPu` is used as the property prefix that lets you connect to ZhiPuAI. + +[cols="3,5,1"] +|==== +| Property | Description | Default + +| spring.ai.zhipuai.base-url | The URL to connect to | https://open.bigmodel.cn/api/paas +| spring.ai.zhipuai.api-key | The API Key | - +|==== + +==== Configuration Properties + +The prefix `spring.ai.zhipuai.chat` is the property prefix that lets you configure the chat client implementation for ZhiPuAI. + +[cols="3,5,1"] +|==== +| Property | Description | Default + +| spring.ai.zhipuai.chat.enabled | Enable ZhiPuAI chat client. | true +| spring.ai.zhipuai.chat.base-url | Optional overrides the spring.ai.zhipuai.base-url to provide chat specific url | https://open.bigmodel.cn/api/paas +| spring.ai.zhipuai.chat.api-key | Optional overrides the spring.ai.zhipuai.api-key to provide chat specific api-key | - +| spring.ai.zhipuai.chat.options.model | This is the ZhiPuAI Chat model to use | `GLM-3-Turbo` (the `GLM-3-Turbo`, `GLM-4`, and `GLM-4V` point to the latest model versions) +| spring.ai.zhipuai.chat.options.maxTokens | The maximum number of tokens to generate in the chat completion. The total length of input tokens and generated tokens is limited by the model's context length. | - +| spring.ai.zhipuai.chat.options.temperature | The sampling temperature to use that controls the apparent creativity of generated completions. Higher values will make output more random while lower values will make results more focused and deterministic. It is not recommended to modify temperature and top_p for the same completions request as the interaction of these two settings is difficult to predict. | 0.7 +| spring.ai.zhipuai.chat.options.topP | An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both. | 1.0 +| spring.ai.zhipuai.chat.options.n | How many chat completion choices to generate for each input message. Note that you will be charged based on the number of generated tokens across all of the choices. Default value is 1 and cannot be greater than 5. Specifically, when the temperature is very small and close to 0, we can only return 1 result. If n is already set and>1 at this time, service will return an illegal input parameter (invalid_request_error) | 1 +| spring.ai.zhipuai.chat.options.presencePenalty | Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics. | 0.0f +| spring.ai.zhipuai.chat.options.frequencyPenalty | Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim. | 0.0f +| spring.ai.zhipuai.chat.options.stop | The model will stop generating characters specified by stop, and currently only supports a single stop word in the format of ["stop_word1"] | - +| spring.ai.zhipuai.chat.options.user | A unique identifier representing your end-user, which can help ZhiPuAI to monitor and detect abuse. | - +|==== + +NOTE: You can override the common `spring.ai.zhipuai.base-url` and `spring.ai.zhipuai.api-key` for the `ChatClient` implementations. +The `spring.ai.zhipuai.chat.base-url` and `spring.ai.zhipuai.chat.api-key` properties if set take precedence over the common properties. +This is useful if you want to use different ZhiPuAI accounts for different models and different model endpoints. + +TIP: All properties prefixed with `spring.ai.zhipuai.chat.options` can be overridden at runtime by adding a request specific <> to the `Prompt` call. + +== Runtime Options [[chat-options]] + +The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiChatOptions.java[ZhiPuAiChatOptions.java] provides model configurations, such as the model to use, the temperature, the frequency penalty, etc. + +On start-up, the default options can be configured with the `ZhiPuAiChatClient(api, options)` constructor or the `spring.ai.zhipuai.chat.options.*` properties. + +At run-time you can override the default options by adding new, request specific, options to the `Prompt` call. +For example to override the default model and temperature for a specific request: + +[source,java] +---- +ChatResponse response = chatClient.call( + new Prompt( + "Generate the names of 5 famous pirates.", + ZhiPuAiChatOptions.builder() + .withModel(ZhiPuAiApi.ChatModel.GLM_3_Turbo.getValue()) + .withTemperature(0.5f) + .build() + )); +---- + +TIP: In addition to the model specific link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiChatOptions.java[ZhiPuAiChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. + +== Sample Controller + +https://start.spring.io/[Create] a new Spring Boot project and add the `spring-ai-zhipuai-spring-boot-starter` to your pom (or gradle) dependencies. + +Add a `application.properties` file, under the `src/main/resources` directory, to enable and configure the ZhiPuAi Chat client: + +[source,application.properties] +---- +spring.ai.zhipuai.api-key=YOUR_API_KEY +spring.ai.zhipuai.chat.options.model=glm-3-turbo +spring.ai.zhipuai.chat.options.temperature=0.7 +---- + +TIP: replace the `api-key` with your ZhiPuAI credentials. + +This will create a `ZhiPuAiChatClient` implementation that you can inject into your class. +Here is an example of a simple `@Controller` class that uses the chat client for text generations. + +[source,java] +---- +@RestController +public class ChatController { + + private final ZhiPuAiChatClient chatClient; + + @Autowired + public ChatController(ZhiPuAiChatClient chatClient) { + this.chatClient = chatClient; + } + + @GetMapping("/ai/generate") + public Map generate(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { + return Map.of("generation", chatClient.call(message)); + } + + @GetMapping("/ai/generateStream") + public Flux generateStream(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { + var prompt = new Prompt(new UserMessage(message)); + return chatClient.stream(prompt); + } +} +---- + +== Manual Configuration + +The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiChatClient.java[ZhiPuAiChatClient] implements the `ChatClient` and `StreamingChatClient` and uses the <> to connect to the ZhiPuAI service. + +Add the `spring-ai-zhipuai` dependency to your project's Maven `pom.xml` file: + +[source, xml] +---- + + org.springframework.ai + spring-ai-zhipuai + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-zhipuai' +} +---- + +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 a `ZhiPuAiChatClient` and use it for text generations: + +[source,java] +---- +var zhiPuAiApi = new ZhiPuAiApi(System.getenv("ZHIPU_AI_API_KEY")); + +var chatClient = new ZhiPuAiChatClient(zhiPuAiApi, ZhiPuAiChatOptions.builder() + .withModel(ZhiPuAiApi.ChatModel.GLM_3_Turbo.getValue()) + .withTemperature(0.4f) + .withMaxTokens(200) + .build()); + +ChatResponse response = chatClient.call( + new Prompt("Generate the names of 5 famous pirates.")); + +// Or with streaming responses +Flux streamResponse = chatClient.stream( + new Prompt("Generate the names of 5 famous pirates.")); +---- + +The `ZhiPuAiChatOptions` provides the configuration information for the chat requests. +The `ZhiPuAiChatOptions.Builder` is fluent options builder. + +=== Low-level ZhiPuAiApi Client [[low-level-api]] + +The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiApi.java[ZhiPuAiApi] provides is lightweight Java client for link:https://open.bigmodel.cn/dev/api[ZhiPu AI API]. + +Here is a simple snippet how to use the api programmatically: + +[source,java] +---- +ZhiPuAiApi zhiPuAiApi = + new ZhiPuAiApi(System.getenv("ZHIPU_AI_API_KEY")); + +ChatCompletionMessage chatCompletionMessage = + new ChatCompletionMessage("Hello world", Role.USER); + +// Sync request +ResponseEntity response = zhiPuAiApi.chatCompletionEntity( + new ChatCompletionRequest(List.of(chatCompletionMessage), ZhiPuAiApi.ChatModel.GLM_3_Turbo.getValue(), 0.7f, false)); + +// Streaming request +Flux streamResponse = zhiPuAiApi.chatCompletionStream( + new ChatCompletionRequest(List.of(chatCompletionMessage), ZhiPuAiApi.ChatModel.GLM_3_Turbo.getValue(), 0.7f, true)); +---- + +Follow the https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiApi.java[ZhiPuAiApi.java]'s JavaDoc for further information. + +==== ZhiPuAiApi Samples +* The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiApiIT.java[ZhiPuAiApiIT.java] test provides some general examples how to use the lightweight library. \ No newline at end of file diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/zhipuai-embeddings.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/zhipuai-embeddings.adoc new file mode 100644 index 00000000000..a189b448ac7 --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/zhipuai-embeddings.adoc @@ -0,0 +1,198 @@ += ZhiPuAI Embeddings + +Spring AI supports the ZhiPuAI's text embeddings models. +ZhiPuAI’s text embeddings measure the relatedness of text strings. +An embedding is a vector (list) of floating point numbers. The distance between two vectors measures their relatedness. Small distances suggest high relatedness and large distances suggest low relatedness. + +== Prerequisites + +You will need to create an API with ZhiPuAI to access ZhiPu AI language models. + +Create an account at https://open.bigmodel.cn/login[ZhiPu AI registration page] and generate the token on the https://open.bigmodel.cn/usercenter/apikeys[API Keys page]. +The Spring AI project defines a configuration property named `spring.ai.zhipu.api-key` that you should set to the value of the `API Key` obtained from https://open.bigmodel.cn/usercenter/apikeys[API Keys page]. +Exporting an environment variable is one way to set that configuration property: + +[source,shell] +---- +export SPRING_AI_ZHIPU_AI_API_KEY= +---- + +=== Add Repositories and BOM + +Spring AI artifacts are published in Spring Milestone and Snapshot repositories. Refer to the xref:getting-started.adoc#repositories[Repositories] section to add these repositories to your build system. + +To help with dependency management, Spring AI provides a BOM (bill of materials) to ensure that a consistent version of Spring AI is used throughout the entire project. Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build system. + + +== Auto-configuration + +Spring AI provides Spring Boot auto-configuration for the Azure ZhiPuAI Embedding Client. +To enable it add the following dependency to your project's Maven `pom.xml` file: + +[source, xml] +---- + + org.springframework.ai + spring-ai-zhipuai-spring-boot-starter + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-zhipuai-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. + +=== Embedding Properties + +==== Retry Properties + +The prefix `spring.ai.retry` is used as the property prefix that lets you configure the retry mechanism for the ZhiPuAI Embedding client. + +[cols="3,5,1"] +|==== +| Property | Description | Default + +| spring.ai.retry.max-attempts | Maximum number of retry attempts. | 10 +| spring.ai.retry.backoff.initial-interval | Initial sleep duration for the exponential backoff policy. | 2 sec. +| spring.ai.retry.backoff.multiplier | Backoff interval multiplier. | 5 +| spring.ai.retry.backoff.max-interval | Maximum backoff duration. | 3 min. +| spring.ai.retry.on-client-errors | If false, throw a NonTransientAiException, and do not attempt retry for `4xx` client error codes | false +| spring.ai.retry.exclude-on-http-codes | List of HTTP status codes that should not trigger a retry (e.g. to throw NonTransientAiException). | empty +| spring.ai.retry.on-http-codes | List of HTTP status codes that should trigger a retry (e.g. to throw TransientAiException). | empty +|==== + +==== Connection Properties + +The prefix `spring.ai.zhipuai` is used as the property prefix that lets you connect to ZhiPuAI. + +[cols="3,5,1"] +|==== +| Property | Description | Default + +| spring.ai.zhipuai.base-url | The URL to connect to | https://open.bigmodel.cn/api/paas +| spring.ai.zhipuai.api-key | The API Key | - +|==== + +==== Configuration Properties + +The prefix `spring.ai.zhipuai.embedding` is property prefix that configures the `EmbeddingClient` implementation for ZhiPuAI. + +[cols="3,5,1"] +|==== +| Property | Description | Default + +| spring.ai.zhipuai.embedding.enabled | Enable ZhiPuAI embedding client. | true +| spring.ai.zhipuai.embedding.base-url | Optional overrides the spring.ai.zhipuai.base-url to provide embedding specific url | - +| spring.ai.zhipuai.embedding.api-key | Optional overrides the spring.ai.zhipuai.api-key to provide embedding specific api-key | - +| spring.ai.zhipuai.embedding.options.model | The model to use | embedding-2 +|==== + +NOTE: You can override the common `spring.ai.zhipuai.base-url` and `spring.ai.zhipuai.api-key` for the `ChatClient` and `EmbeddingClient` implementations. +The `spring.ai.zhipuai.embedding.base-url` and `spring.ai.zhipuai.embedding.api-key` properties if set take precedence over the common properties. +Similarly, the `spring.ai.zhipuai.embedding.base-url` and `spring.ai.zhipuai.embedding.api-key` properties if set take precedence over the common properties. +This is useful if you want to use different ZhiPuAI accounts for different models and different model endpoints. + +TIP: All properties prefixed with `spring.ai.zhipuai.embedding.options` can be overridden at runtime by adding a request specific <> to the `EmbeddingRequest` call. + +== Runtime Options [[embedding-options]] + +The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiEmbeddingOptions.java[ZhiPuAiEmbeddingOptions.java] provides the ZhiPuAI configurations, such as the model to use and etc. + +The default options can be configured using the `spring.ai.zhipuai.embedding.options` properties as well. + +At start-time use the `ZhiPuAiEmbeddingClient` constructor to set the default options used for all embedding requests. +At run-time you can override the default options, using a `ZhiPuAiEmbeddingOptions` instance as part of your `EmbeddingRequest`. + +For example to override the default model name for a specific request: + +[source,java] +---- +EmbeddingResponse embeddingResponse = embeddingClient.call( + new EmbeddingRequest(List.of("Hello World", "World is big and salvation is near"), + ZhiPuAiEmbeddingOptions.builder() + .withModel("Different-Embedding-Model-Deployment-Name") + .build())); +---- + +== Sample Controller + +This will create a `EmbeddingClient` implementation that you can inject into your class. +Here is an example of a simple `@Controller` class that uses the `EmbeddingClient` implementation. + +[source,application.properties] +---- +spring.ai.zhipuai.api-key=YOUR_API_KEY +spring.ai.zhipuai.embedding.options.model=embedding-2 +---- + +[source,java] +---- +@RestController +public class EmbeddingController { + + private final EmbeddingClient embeddingClient; + + @Autowired + public EmbeddingController(EmbeddingClient embeddingClient) { + this.embeddingClient = embeddingClient; + } + + @GetMapping("/ai/embedding") + public Map embed(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { + EmbeddingResponse embeddingResponse = this.embeddingClient.embedForResponse(List.of(message)); + return Map.of("embedding", embeddingResponse); + } +} +---- + +== Manual Configuration + +If you are not using Spring Boot, you can manually configure the ZhiPuAI Embedding Client. +For this add the `spring-ai-zhipuai` dependency to your project's Maven `pom.xml` file: +[source, xml] +---- + + org.springframework.ai + spring-ai-zhipuai + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-zhipuai' +} +---- + +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. + +NOTE: The `spring-ai-zhipuai` dependency provides access also to the `ZhiPuAiChatClient`. +For more information about the `ZhiPuAiChatClient` refer to the link:../chat/zhipuai-chat.html[ZhiPuAI Chat Client] section. + +Next, create an `ZhiPuAiEmbeddingClient` instance and use it to compute the similarity between two input texts: + +[source,java] +---- +var zhiPuAiApi = new ZhiPuAiApi(System.getenv("ZHIPU_AI_API_KEY")); + +var embeddingClient = new ZhiPuAiEmbeddingClient(zhiPuAiApi) + .withDefaultOptions(ZhiPuAiChatOptions.build() + .withModel("embedding-2") + .build()); + +EmbeddingResponse embeddingResponse = embeddingClient + .embedForResponse(List.of("Hello World", "World is big and salvation is near")); +---- + +The `ZhiPuAiEmbeddingOptions` provides the configuration information for the embedding requests. +The options class offers a `builder()` for easy options creation. + + diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/zhipuai-image.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/zhipuai-image.adoc new file mode 100644 index 00000000000..b6a02d3bdaf --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/zhipuai-image.adoc @@ -0,0 +1,117 @@ += ZhiPuAI Image Generation + + +Spring AI supports CogView, the Image generation model from ZhiPuAI. + +== Prerequisites + +You will need to create an API with ZhiPuAI to access ZhiPu AI language models. + +Create an account at https://open.bigmodel.cn/login[ZhiPu AI registration page] and generate the token on the https://open.bigmodel.cn/usercenter/apikeys[API Keys page]. +The Spring AI project defines a configuration property named `spring.ai.zhipuai.api-key` that you should set to the value of the `API Key` obtained from https://open.bigmodel.cn/usercenter/apikeys[API Keys page]. +Exporting an environment variable is one way to set that configuration property: + +[source,shell] +---- +export SPRING_AI_ZHIPU_AI_API_KEY= +---- +=== Add Repositories and BOM + +Spring AI artifacts are published in Spring Milestone and Snapshot repositories. +Refer to the xref:getting-started.adoc#repositories[Repositories] section to add these repositories to your build system. + +To help with dependency management, Spring AI provides a BOM (bill of materials) to ensure that a consistent version of Spring AI is used throughout the entire project. Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build system. + +== Auto-configuration + +Spring AI provides Spring Boot auto-configuration for the ZhiPuAI Chat Client. +To enable it add the following dependency to your project's Maven `pom.xml` file: + +[source, xml] +---- + + org.springframework.ai + spring-ai-zhipuai-spring-boot-starter + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-zhipuai-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. + +=== Image Generation Properties + +The prefix `spring.ai.zhipuai.image` is the property prefix that lets you configure the `ImageClient` implementation for ZhiPuAI. + +[cols="3,5,1"] +|==== +| Property | Description | Default +| spring.ai.zhipuai.image.enabled | Enable ZhiPuAI image client. | true +| spring.ai.zhipuai.image.base-url | Optional overrides the spring.ai.zhipuai.base-url to provide chat specific url | - +| spring.ai.zhipuai.image.api-key | Optional overrides the spring.ai.zhipuai.api-key to provide chat specific api-key | - +| spring.ai.zhipuai.image.options.model | The model to use for image generation. | cogview-3 +| spring.ai.zhipuai.image.options.user | A unique identifier representing your end-user, which can help ZhiPuAI to monitor and detect abuse. | - +|==== + +==== Connection Properties + +The prefix `spring.ai.zhipuai` is used as the property prefix that lets you connect to ZhiPuAI. + +[cols="3,5,1"] +|==== +| Property | Description | Default +| spring.ai.zhipuai.base-url | The URL to connect to | https://open.bigmodel.cn/api/paas +| spring.ai.zhipuai.api-key | The API Key | - +|==== + +==== Configuration Properties + + +==== Retry Properties + +The prefix `spring.ai.retry` is used as the property prefix that lets you configure the retry mechanism for the ZhiPuAI Image client. + +[cols="3,5,1"] +|==== +| Property | Description | Default + +| spring.ai.retry.max-attempts | Maximum number of retry attempts. | 10 +| spring.ai.retry.backoff.initial-interval | Initial sleep duration for the exponential backoff policy. | 2 sec. +| spring.ai.retry.backoff.multiplier | Backoff interval multiplier. | 5 +| spring.ai.retry.backoff.max-interval | Maximum backoff duration. | 3 min. +| spring.ai.retry.on-client-errors | If false, throw a NonTransientAiException, and do not attempt retry for `4xx` client error codes | false +| spring.ai.retry.exclude-on-http-codes | List of HTTP status codes that should not trigger a retry (e.g. to throw NonTransientAiException). | empty +| spring.ai.retry.on-http-codes | List of HTTP status codes that should trigger a retry (e.g. to throw TransientAiException). | empty +|==== + + +== Runtime Options [[image-options]] + +The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiImageOptions.java[ZhiPuAiImageOptions.java] provides model configurations, such as the model to use, the quality, the size, etc. + +On start-up, the default options can be configured with the `ZhiPuAiImageClient(ZhiPuAiImageApi zhiPuAiImageApi)` constructor and the `withDefaultOptions(ZhiPuAiImageOptions defaultOptions)` method. Alternatively, use the `spring.ai.zhipuai.image.options.*` properties described previously. + +At runtime you can override the default options by adding new, request specific, options to the `ImagePrompt` call. +For example to override the ZhiPuAI specific options such as quality and the number of images to create, use the following code example: + +[source,java] +---- +ImageResponse response = zhiPuAiImageClient.call( + new ImagePrompt("A light cream colored mini golden doodle", + ZhiPuAiImageOptions.builder() + .withQuality("hd") + .withN(4) + .withHeight(1024) + .withWidth(1024).build()) + +); +---- + +TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiImageOptions.java[ZhiPuAiImageOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/image/ImageOptions.java[ImageOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/image/ImageOptionsBuilder.java[ImageOptionsBuilder#builder()]. diff --git a/spring-ai-spring-boot-autoconfigure/pom.xml b/spring-ai-spring-boot-autoconfigure/pom.xml index 0d05d589c23..84bd54a188a 100644 --- a/spring-ai-spring-boot-autoconfigure/pom.xml +++ b/spring-ai-spring-boot-autoconfigure/pom.xml @@ -274,6 +274,13 @@ true + + org.springframework.ai + spring-ai-zhipuai + ${project.parent.version} + true + + diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiAutoConfiguration.java new file mode 100644 index 00000000000..e2c8b1126cf --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiAutoConfiguration.java @@ -0,0 +1,128 @@ +/* + * Copyright 2023 - 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.zhipuai; + +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.model.function.FunctionCallbackContext; +import org.springframework.ai.zhipuai.ZhiPuAiChatClient; +import org.springframework.ai.zhipuai.ZhiPuAiEmbeddingClient; +import org.springframework.ai.zhipuai.ZhiPuAiImageClient; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi; +import org.springframework.ai.zhipuai.api.ZhiPuAiImageApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; + +import java.util.List; + +/** + * @author Geng Rong + */ +@AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class }) +@ConditionalOnClass(ZhiPuAiApi.class) +@EnableConfigurationProperties({ ZhiPuAiConnectionProperties.class, ZhiPuAiChatProperties.class, + ZhiPuAiEmbeddingProperties.class, ZhiPuAiImageProperties.class }) +public class ZhiPuAiAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = ZhiPuAiChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public ZhiPuAiChatClient zhiPuAiChatClient(ZhiPuAiConnectionProperties commonProperties, + ZhiPuAiChatProperties chatProperties, RestClient.Builder restClientBuilder, + List toolFunctionCallbacks, FunctionCallbackContext functionCallbackContext, + RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler) { + + var zhiPuAiApi = zhiPuAiApi(chatProperties.getBaseUrl(), commonProperties.getBaseUrl(), + chatProperties.getApiKey(), commonProperties.getApiKey(), restClientBuilder, responseErrorHandler); + + if (!CollectionUtils.isEmpty(toolFunctionCallbacks)) { + chatProperties.getOptions().getFunctionCallbacks().addAll(toolFunctionCallbacks); + } + + return new ZhiPuAiChatClient(zhiPuAiApi, chatProperties.getOptions(), functionCallbackContext, retryTemplate); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = ZhiPuAiEmbeddingProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public ZhiPuAiEmbeddingClient zhiPuAiEmbeddingClient(ZhiPuAiConnectionProperties commonProperties, + ZhiPuAiEmbeddingProperties embeddingProperties, RestClient.Builder restClientBuilder, + RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler) { + + var zhiPuAiApi = zhiPuAiApi(embeddingProperties.getBaseUrl(), commonProperties.getBaseUrl(), + embeddingProperties.getApiKey(), commonProperties.getApiKey(), restClientBuilder, responseErrorHandler); + + return new ZhiPuAiEmbeddingClient(zhiPuAiApi, embeddingProperties.getMetadataMode(), + embeddingProperties.getOptions(), retryTemplate); + } + + private ZhiPuAiApi zhiPuAiApi(String baseUrl, String commonBaseUrl, String apiKey, String commonApiKey, + RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { + + String resolvedBaseUrl = StringUtils.hasText(baseUrl) ? baseUrl : commonBaseUrl; + Assert.hasText(resolvedBaseUrl, "ZhiPuAI base URL must be set"); + + String resolvedApiKey = StringUtils.hasText(apiKey) ? apiKey : commonApiKey; + Assert.hasText(resolvedApiKey, "ZhiPuAI API key must be set"); + + return new ZhiPuAiApi(resolvedBaseUrl, resolvedApiKey, restClientBuilder, responseErrorHandler); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = ZhiPuAiImageProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public ZhiPuAiImageClient zhiPuAiImageClient(ZhiPuAiConnectionProperties commonProperties, + ZhiPuAiImageProperties imageProperties, RestClient.Builder restClientBuilder, RetryTemplate retryTemplate, + ResponseErrorHandler responseErrorHandler) { + + String apiKey = StringUtils.hasText(imageProperties.getApiKey()) ? imageProperties.getApiKey() + : commonProperties.getApiKey(); + + String baseUrl = StringUtils.hasText(imageProperties.getBaseUrl()) ? imageProperties.getBaseUrl() + : commonProperties.getBaseUrl(); + + Assert.hasText(apiKey, "ZhiPuAI API key must be set"); + Assert.hasText(baseUrl, "ZhiPuAI base URL must be set"); + + var zhiPuAiImageApi = new ZhiPuAiImageApi(baseUrl, apiKey, restClientBuilder, responseErrorHandler); + + return new ZhiPuAiImageClient(zhiPuAiImageApi, imageProperties.getOptions(), retryTemplate); + } + + @Bean + @ConditionalOnMissingBean + public FunctionCallbackContext springAiFunctionManager(ApplicationContext context) { + FunctionCallbackContext manager = new FunctionCallbackContext(); + manager.setApplicationContext(context); + return manager; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiChatProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiChatProperties.java new file mode 100644 index 00000000000..1ec554b44dc --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiChatProperties.java @@ -0,0 +1,62 @@ +/* + * Copyright 2023 - 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.zhipuai; + +import org.springframework.ai.zhipuai.ZhiPuAiChatOptions; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * @author Geng Rong + */ +@ConfigurationProperties(ZhiPuAiChatProperties.CONFIG_PREFIX) +public class ZhiPuAiChatProperties extends ZhiPuAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.zhipuai.chat"; + + public static final String DEFAULT_CHAT_MODEL = ZhiPuAiApi.ChatModel.GLM_3_Turbo.value; + + private static final Double DEFAULT_TEMPERATURE = 0.7; + + /** + * Enable ZhiPuAI chat client. + */ + private boolean enabled = true; + + @NestedConfigurationProperty + private ZhiPuAiChatOptions options = ZhiPuAiChatOptions.builder() + .withModel(DEFAULT_CHAT_MODEL) + .withTemperature(DEFAULT_TEMPERATURE.floatValue()) + .build(); + + public ZhiPuAiChatOptions getOptions() { + return options; + } + + public void setOptions(ZhiPuAiChatOptions options) { + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiConnectionProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiConnectionProperties.java new file mode 100644 index 00000000000..6d850f3d75f --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiConnectionProperties.java @@ -0,0 +1,31 @@ +/* + * Copyright 2023 - 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.zhipuai; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(ZhiPuAiConnectionProperties.CONFIG_PREFIX) +public class ZhiPuAiConnectionProperties extends ZhiPuAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.zhipuai"; + + public static final String DEFAULT_BASE_URL = "https://open.bigmodel.cn/api/paas"; + + public ZhiPuAiConnectionProperties() { + super.setBaseUrl(DEFAULT_BASE_URL); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiEmbeddingProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiEmbeddingProperties.java new file mode 100644 index 00000000000..78357ceff50 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiEmbeddingProperties.java @@ -0,0 +1,70 @@ +/* + * Copyright 2023 - 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.zhipuai; + +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.zhipuai.ZhiPuAiEmbeddingOptions; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * @author Geng Rong + */ +@ConfigurationProperties(ZhiPuAiEmbeddingProperties.CONFIG_PREFIX) +public class ZhiPuAiEmbeddingProperties extends ZhiPuAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.zhipuai.embedding"; + + public static final String DEFAULT_EMBEDDING_MODEL = ZhiPuAiApi.EmbeddingModel.Embedding_2.value; + + /** + * Enable ZhiPuAI embedding client. + */ + private boolean enabled = true; + + private MetadataMode metadataMode = MetadataMode.EMBED; + + @NestedConfigurationProperty + private ZhiPuAiEmbeddingOptions options = ZhiPuAiEmbeddingOptions.builder() + .withModel(DEFAULT_EMBEDDING_MODEL) + .build(); + + public ZhiPuAiEmbeddingOptions getOptions() { + return this.options; + } + + public void setOptions(ZhiPuAiEmbeddingOptions options) { + this.options = options; + } + + public MetadataMode getMetadataMode() { + return this.metadataMode; + } + + public void setMetadataMode(MetadataMode metadataMode) { + this.metadataMode = metadataMode; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiImageProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiImageProperties.java new file mode 100644 index 00000000000..19ce4f7bb4c --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiImageProperties.java @@ -0,0 +1,57 @@ +/* + * Copyright 2023 - 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.zhipuai; + +import org.springframework.ai.zhipuai.ZhiPuAiImageOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * @author Geng Rong + */ +@ConfigurationProperties(ZhiPuAiImageProperties.CONFIG_PREFIX) +public class ZhiPuAiImageProperties extends ZhiPuAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.zhipuai.image"; + + /** + * Enable ZhiPuAI Image client. + */ + private boolean enabled = true; + + /** + * Options for ZhiPuAI Image API. + */ + @NestedConfigurationProperty + private ZhiPuAiImageOptions options = ZhiPuAiImageOptions.builder().build(); + + public ZhiPuAiImageOptions getOptions() { + return options; + } + + public void setOptions(ZhiPuAiImageOptions options) { + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiParentProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiParentProperties.java new file mode 100644 index 00000000000..70d43d77092 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiParentProperties.java @@ -0,0 +1,43 @@ +/* + * Copyright 2023 - 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.zhipuai; + +/** + * @author Geng Rong + */ +class ZhiPuAiParentProperties { + + private String apiKey; + + private String baseUrl; + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index c2816002bcd..4cbab5ad6f3 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -32,3 +32,4 @@ org.springframework.ai.autoconfigure.anthropic.AnthropicAutoConfiguration org.springframework.ai.autoconfigure.watsonxai.WatsonxAiAutoConfiguration org.springframework.ai.autoconfigure.vectorstore.elasticsearch.ElasticsearchVectorStoreAutoConfiguration org.springframework.ai.autoconfigure.vectorstore.cassandra.CassandraVectorStoreAutoConfiguration +org.springframework.ai.autoconfigure.zhipuai.ZhiPuAiAutoConfiguration diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiAutoConfigurationIT.java new file mode 100644 index 00000000000..073c81ffce9 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiAutoConfigurationIT.java @@ -0,0 +1,107 @@ +/* + * Copyright 2023 - 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.zhipuai; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.chat.ChatResponse; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.image.ImageResponse; +import org.springframework.ai.zhipuai.ZhiPuAiChatClient; +import org.springframework.ai.zhipuai.ZhiPuAiEmbeddingClient; +import org.springframework.ai.zhipuai.ZhiPuAiImageClient; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import reactor.core.publisher.Flux; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "ZHIPU_AI_API_KEY", matches = ".*") +public class ZhiPuAiAutoConfigurationIT { + + private static final Log logger = LogFactory.getLog(ZhiPuAiAutoConfigurationIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.apiKey=" + System.getenv("ZHIPU_AI_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)); + + @Test + void generate() { + contextRunner.run(context -> { + ZhiPuAiChatClient client = context.getBean(ZhiPuAiChatClient.class); + String response = client.call("Hello"); + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void generateStreaming() { + contextRunner.run(context -> { + ZhiPuAiChatClient client = context.getBean(ZhiPuAiChatClient.class); + Flux responseFlux = client.stream(new Prompt(new UserMessage("Hello"))); + String response = responseFlux.collectList().block().stream().map(chatResponse -> { + return chatResponse.getResults().get(0).getOutput().getContent(); + }).collect(Collectors.joining()); + + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + + @Test + void embedding() { + contextRunner.run(context -> { + ZhiPuAiEmbeddingClient embeddingClient = context.getBean(ZhiPuAiEmbeddingClient.class); + + EmbeddingResponse embeddingResponse = embeddingClient + .embedForResponse(List.of("Hello World", "World is big and salvation is near")); + assertThat(embeddingResponse.getResults()).hasSize(2); + assertThat(embeddingResponse.getResults().get(0).getOutput()).isNotEmpty(); + assertThat(embeddingResponse.getResults().get(0).getIndex()).isEqualTo(0); + assertThat(embeddingResponse.getResults().get(1).getOutput()).isNotEmpty(); + assertThat(embeddingResponse.getResults().get(1).getIndex()).isEqualTo(1); + + assertThat(embeddingClient.dimensions()).isEqualTo(1536); + }); + } + + @Test + void generateImage() { + contextRunner.withPropertyValues("spring.ai.zhipuai.image.options.size=1024x1024").run(context -> { + ZhiPuAiImageClient client = context.getBean(ZhiPuAiImageClient.class); + ImageResponse imageResponse = client.call(new ImagePrompt("forest")); + assertThat(imageResponse.getResults()).hasSize(1); + assertThat(imageResponse.getResult().getOutput().getUrl()).isNotEmpty(); + logger.info("Generated image: " + imageResponse.getResult().getOutput().getUrl()); + }); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiPropertiesTests.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiPropertiesTests.java new file mode 100644 index 00000000000..121473dfdc6 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/ZhiPuAiPropertiesTests.java @@ -0,0 +1,438 @@ +/* + * Copyright 2023 - 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.zhipuai; + +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import org.skyscreamer.jsonassert.JSONCompareMode; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.zhipuai.ZhiPuAiChatClient; +import org.springframework.ai.zhipuai.ZhiPuAiEmbeddingClient; +import org.springframework.ai.zhipuai.ZhiPuAiImageClient; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit Tests for {@link ZhiPuAiConnectionProperties}, {@link ZhiPuAiChatProperties} and + * {@link ZhiPuAiEmbeddingProperties}. + * + * @author Geng Rong + */ +public class ZhiPuAiPropertiesTests { + + @Test + public void chatProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.api-key=abc123", + "spring.ai.zhipuai.chat.options.model=MODEL_XYZ", + "spring.ai.zhipuai.chat.options.temperature=0.55") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(ZhiPuAiChatProperties.class); + var connectionProperties = context.getBean(ZhiPuAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(chatProperties.getApiKey()).isNull(); + assertThat(chatProperties.getBaseUrl()).isNull(); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55f); + }); + } + + @Test + public void chatOverrideConnectionProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.api-key=abc123", + "spring.ai.zhipuai.chat.base-url=TEST_BASE_URL2", + "spring.ai.zhipuai.chat.api-key=456", + "spring.ai.zhipuai.chat.options.model=MODEL_XYZ", + "spring.ai.zhipuai.chat.options.temperature=0.55") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(ZhiPuAiChatProperties.class); + var connectionProperties = context.getBean(ZhiPuAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(chatProperties.getApiKey()).isEqualTo("456"); + assertThat(chatProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2"); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55f); + }); + } + + @Test + public void embeddingProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.api-key=abc123", + "spring.ai.zhipuai.embedding.options.model=MODEL_XYZ") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(ZhiPuAiEmbeddingProperties.class); + var connectionProperties = context.getBean(ZhiPuAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(embeddingProperties.getApiKey()).isNull(); + assertThat(embeddingProperties.getBaseUrl()).isNull(); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + public void embeddingOverrideConnectionProperties() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.api-key=abc123", + "spring.ai.zhipuai.embedding.base-url=TEST_BASE_URL2", + "spring.ai.zhipuai.embedding.api-key=456", + "spring.ai.zhipuai.embedding.options.model=MODEL_XYZ") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(ZhiPuAiEmbeddingProperties.class); + var connectionProperties = context.getBean(ZhiPuAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(embeddingProperties.getApiKey()).isEqualTo("456"); + assertThat(embeddingProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + public void imageProperties() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.api-key=abc123", + "spring.ai.zhipuai.image.options.model=MODEL_XYZ") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + var imageProperties = context.getBean(ZhiPuAiImageProperties.class); + var connectionProperties = context.getBean(ZhiPuAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(imageProperties.getApiKey()).isNull(); + assertThat(imageProperties.getBaseUrl()).isNull(); + + assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + public void imageOverrideConnectionProperties() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.api-key=abc123", + "spring.ai.zhipuai.image.base-url=TEST_BASE_URL2", + "spring.ai.zhipuai.image.api-key=456", + "spring.ai.zhipuai.image.options.model=MODEL_XYZ") + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + var imageProperties = context.getBean(ZhiPuAiImageProperties.class); + var connectionProperties = context.getBean(ZhiPuAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(imageProperties.getApiKey()).isEqualTo("456"); + assertThat(imageProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2"); + + assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + public void chatOptionsTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.zhipuai.api-key=API_KEY", + "spring.ai.zhipuai.base-url=TEST_BASE_URL", + + "spring.ai.zhipuai.chat.options.model=MODEL_XYZ", + "spring.ai.zhipuai.chat.options.frequencyPenalty=-1.5", + "spring.ai.zhipuai.chat.options.logitBias.myTokenId=-5", + "spring.ai.zhipuai.chat.options.maxTokens=123", + "spring.ai.zhipuai.chat.options.n=10", + "spring.ai.zhipuai.chat.options.presencePenalty=0", + "spring.ai.zhipuai.chat.options.responseFormat.type=json", + "spring.ai.zhipuai.chat.options.seed=66", + "spring.ai.zhipuai.chat.options.stop=boza,koza", + "spring.ai.zhipuai.chat.options.temperature=0.55", + "spring.ai.zhipuai.chat.options.topP=0.56", + + // "spring.ai.zhipuai.chat.options.toolChoice.functionName=toolChoiceFunctionName", + "spring.ai.zhipuai.chat.options.toolChoice=" + ModelOptionsUtils.toJsonString(ZhiPuAiApi.ChatCompletionRequest.ToolChoiceBuilder.FUNCTION("toolChoiceFunctionName")), + + "spring.ai.zhipuai.chat.options.tools[0].function.name=myFunction1", + "spring.ai.zhipuai.chat.options.tools[0].function.description=function description", + "spring.ai.zhipuai.chat.options.tools[0].function.jsonSchema=" + """ + { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state e.g. San Francisco, CA" + }, + "lat": { + "type": "number", + "description": "The city latitude" + }, + "lon": { + "type": "number", + "description": "The city longitude" + }, + "unit": { + "type": "string", + "enum": ["c", "f"] + } + }, + "required": ["location", "lat", "lon", "unit"] + } + """, + "spring.ai.zhipuai.chat.options.user=userXYZ" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(ZhiPuAiChatProperties.class); + var connectionProperties = context.getBean(ZhiPuAiConnectionProperties.class); + var embeddingProperties = context.getBean(ZhiPuAiEmbeddingProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("Embedding-2"); + + assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(chatProperties.getOptions().getFrequencyPenalty()).isEqualTo(-1.5f); + assertThat(chatProperties.getOptions().getMaxTokens()).isEqualTo(123); + assertThat(chatProperties.getOptions().getN()).isEqualTo(10); + assertThat(chatProperties.getOptions().getPresencePenalty()).isEqualTo(0); + assertThat(chatProperties.getOptions().getResponseFormat()) + .isEqualTo(new ZhiPuAiApi.ChatCompletionRequest.ResponseFormat("json")); + assertThat(chatProperties.getOptions().getSeed()).isEqualTo(66); + assertThat(chatProperties.getOptions().getStop()).contains("boza", "koza"); + assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55f); + assertThat(chatProperties.getOptions().getTopP()).isEqualTo(0.56f); + + JSONAssert.assertEquals("{\"type\":\"function\",\"function\":{\"name\":\"toolChoiceFunctionName\"}}", + chatProperties.getOptions().getToolChoice(), JSONCompareMode.LENIENT); + + assertThat(chatProperties.getOptions().getUser()).isEqualTo("userXYZ"); + + assertThat(chatProperties.getOptions().getTools()).hasSize(1); + var tool = chatProperties.getOptions().getTools().get(0); + assertThat(tool.type()).isEqualTo(ZhiPuAiApi.FunctionTool.Type.FUNCTION); + var function = tool.function(); + assertThat(function.name()).isEqualTo("myFunction1"); + assertThat(function.description()).isEqualTo("function description"); + assertThat(function.parameters()).isNotEmpty(); + }); + } + + @Test + public void embeddingOptionsTest() { + + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.zhipuai.api-key=API_KEY", + "spring.ai.zhipuai.base-url=TEST_BASE_URL", + + "spring.ai.zhipuai.embedding.options.model=MODEL_XYZ", + "spring.ai.zhipuai.embedding.options.encodingFormat=MyEncodingFormat", + "spring.ai.zhipuai.embedding.options.user=userXYZ" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + var connectionProperties = context.getBean(ZhiPuAiConnectionProperties.class); + var embeddingProperties = context.getBean(ZhiPuAiEmbeddingProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + public void imageOptionsTest() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.zhipuai.api-key=API_KEY", + "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.image.options.model=MODEL_XYZ", + "spring.ai.zhipuai.image.options.user=userXYZ" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + var imageProperties = context.getBean(ZhiPuAiImageProperties.class); + var connectionProperties = context.getBean(ZhiPuAiConnectionProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(imageProperties.getOptions().getUser()).isEqualTo("userXYZ"); + }); + } + + @Test + void embeddingActivation() { + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.api-key=API_KEY", "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.embedding.enabled=false") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(ZhiPuAiEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(ZhiPuAiEmbeddingClient.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.api-key=API_KEY", "spring.ai.zhipuai.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(ZhiPuAiEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(ZhiPuAiEmbeddingClient.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.api-key=API_KEY", "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.embedding.enabled=true") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(ZhiPuAiEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(ZhiPuAiEmbeddingClient.class)).isNotEmpty(); + }); + } + + @Test + void chatActivation() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.api-key=API_KEY", "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.chat.enabled=false") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(ZhiPuAiChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(ZhiPuAiChatClient.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.api-key=API_KEY", "spring.ai.zhipuai.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(ZhiPuAiChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(ZhiPuAiChatClient.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.api-key=API_KEY", "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.chat.enabled=true") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(ZhiPuAiChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(ZhiPuAiChatClient.class)).isNotEmpty(); + }); + + } + + @Test + void imageActivation() { + new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.api-key=API_KEY", "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.image.enabled=false") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(ZhiPuAiImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(ZhiPuAiImageClient.class)).isEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.api-key=API_KEY", "spring.ai.zhipuai.base-url=TEST_BASE_URL") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(ZhiPuAiImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(ZhiPuAiImageClient.class)).isNotEmpty(); + }); + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.api-key=API_KEY", "spring.ai.zhipuai.base-url=TEST_BASE_URL", + "spring.ai.zhipuai.image.enabled=true") + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(ZhiPuAiImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(ZhiPuAiImageClient.class)).isNotEmpty(); + }); + + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackInPromptIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackInPromptIT.java new file mode 100644 index 00000000000..29a71041f33 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackInPromptIT.java @@ -0,0 +1,114 @@ +/* + * Copyright 2023 - 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.zhipuai.tool; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.autoconfigure.zhipuai.ZhiPuAiAutoConfiguration; +import org.springframework.ai.chat.ChatResponse; +import org.springframework.ai.chat.Generation; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.function.FunctionCallbackWrapper; +import org.springframework.ai.zhipuai.ZhiPuAiChatClient; +import org.springframework.ai.zhipuai.ZhiPuAiChatOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import reactor.core.publisher.Flux; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "ZHIPU_AI_API_KEY", matches = ".*") +public class FunctionCallbackInPromptIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallbackInPromptIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.apiKey=" + System.getenv("ZHIPU_AI_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)); + + @Test + void functionCallTest() { + contextRunner.withPropertyValues("spring.ai.zhipuai.chat.options.model=glm-4").run(context -> { + + ZhiPuAiChatClient chatClient = context.getBean(ZhiPuAiChatClient.class); + + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + + var promptOptions = ZhiPuAiChatOptions.builder() + .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) + .withName("CurrentWeatherService") + .withDescription("Get the weather in location") + .withResponseConverter((response) -> "" + response.temp() + response.unit()) + .build())) + .build(); + + ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), promptOptions)); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getContent()).contains("30.0", "10.0", "15.0"); + }); + } + + @Test + void streamingFunctionCallTest() { + + contextRunner.withPropertyValues("spring.ai.zhipuai.chat.options.model=glm-4").run(context -> { + + ZhiPuAiChatClient chatClient = context.getBean(ZhiPuAiChatClient.class); + + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + + var promptOptions = ZhiPuAiChatOptions.builder() + .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) + .withName("CurrentWeatherService") + .withDescription("Get the weather in location") + .withResponseConverter((response) -> "" + response.temp() + response.unit()) + .build())) + .build(); + + Flux response = chatClient.stream(new Prompt(List.of(userMessage), promptOptions)); + + String content = response.collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getContent) + .collect(Collectors.joining()); + logger.info("Response: {}", content); + + assertThat(content).containsAnyOf("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + }); + } + +} \ No newline at end of file diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackWithPlainFunctionBeanIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackWithPlainFunctionBeanIT.java new file mode 100644 index 00000000000..2d7c121d11c --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackWithPlainFunctionBeanIT.java @@ -0,0 +1,172 @@ +/* + * Copyright 2023 - 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.zhipuai.tool; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.autoconfigure.zhipuai.ZhiPuAiAutoConfiguration; +import org.springframework.ai.chat.ChatResponse; +import org.springframework.ai.chat.Generation; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.function.FunctionCallingOptions; +import org.springframework.ai.model.function.FunctionCallingOptionsBuilder.PortableFunctionCallingOptions; +import org.springframework.ai.zhipuai.ZhiPuAiChatClient; +import org.springframework.ai.zhipuai.ZhiPuAiChatOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; +import reactor.core.publisher.Flux; + +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "ZHIPU_AI_API_KEY", matches = ".*") +class FunctionCallbackWithPlainFunctionBeanIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallbackWithPlainFunctionBeanIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.apiKey=" + System.getenv("ZHIPU_AI_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + contextRunner.withPropertyValues("spring.ai.zhipuai.chat.options.model=glm-4").run(context -> { + + ZhiPuAiChatClient chatClient = context.getBean(ZhiPuAiChatClient.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + + ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), + ZhiPuAiChatOptions.builder().withFunction("weatherFunction").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getContent()).contains("30", "10", "15"); + + // Test weatherFunctionTwo + response = chatClient.call(new Prompt(List.of(userMessage), + ZhiPuAiChatOptions.builder().withFunction("weatherFunctionTwo").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getContent()).contains("30", "10", "15"); + + }); + } + + @Test + void functionCallWithPortableFunctionCallingOptions() { + contextRunner.withPropertyValues("spring.ai.zhipuai.chat.options.model=glm-4").run(context -> { + + ZhiPuAiChatClient chatClient = context.getBean(ZhiPuAiChatClient.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + + PortableFunctionCallingOptions functionOptions = FunctionCallingOptions.builder() + .withFunction("weatherFunction") + .build(); + + ChatResponse response = chatClient.call(new Prompt(List.of(userMessage), functionOptions)); + + logger.info("Response: {}", response); + }); + } + + @Test + void streamFunctionCallTest() { + contextRunner.withPropertyValues("spring.ai.zhipuai.chat.options.model=glm-4").run(context -> { + + ZhiPuAiChatClient chatClient = context.getBean(ZhiPuAiChatClient.class); + + // Test weatherFunction + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + + Flux response = chatClient.stream(new Prompt(List.of(userMessage), + ZhiPuAiChatOptions.builder().withFunction("weatherFunction").build())); + + String content = response.collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getContent) + .collect(Collectors.joining()); + logger.info("Response: {}", content); + + assertThat(content).containsAnyOf("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + + // Test weatherFunctionTwo + response = chatClient.stream(new Prompt(List.of(userMessage), + ZhiPuAiChatOptions.builder().withFunction("weatherFunctionTwo").build())); + + content = response.collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getContent) + .collect(Collectors.joining()); + logger.info("Response: {}", content); + + assertThat(content).containsAnyOf("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + }); + } + + @Configuration + static class Config { + + @Bean + @Description("Get the weather in location") + public Function weatherFunction() { + return new MockWeatherService(); + } + + // Relies on the Request's JsonClassDescription annotation to provide the + // function description. + @Bean + public Function weatherFunctionTwo() { + MockWeatherService weatherService = new MockWeatherService(); + return (weatherService::apply); + } + + } + +} \ No newline at end of file diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackWrapperIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackWrapperIT.java new file mode 100644 index 00000000000..fefd8132607 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/FunctionCallbackWrapperIT.java @@ -0,0 +1,120 @@ +/* + * Copyright 2023 - 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.zhipuai.tool; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration; +import org.springframework.ai.autoconfigure.zhipuai.ZhiPuAiAutoConfiguration; +import org.springframework.ai.chat.ChatResponse; +import org.springframework.ai.chat.Generation; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.model.function.FunctionCallbackWrapper; +import org.springframework.ai.zhipuai.ZhiPuAiChatClient; +import org.springframework.ai.zhipuai.ZhiPuAiChatOptions; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import reactor.core.publisher.Flux; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Geng Rong + */ +@EnabledIfEnvironmentVariable(named = "ZHIPU_AI_API_KEY", matches = ".*") +public class FunctionCallbackWrapperIT { + + private final Logger logger = LoggerFactory.getLogger(FunctionCallbackWrapperIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.zhipuai.apiKey=" + System.getenv("ZHIPU_AI_API_KEY")) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, ZhiPuAiAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void functionCallTest() { + contextRunner.withPropertyValues("spring.ai.zhipuai.chat.options.model=glm-4").run(context -> { + + ZhiPuAiChatClient chatClient = context.getBean(ZhiPuAiChatClient.class); + + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + + ChatResponse response = chatClient.call( + new Prompt(List.of(userMessage), ZhiPuAiChatOptions.builder().withFunction("WeatherInfo").build())); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getContent()).contains("30.0", "10.0", "15.0"); + + }); + } + + @Test + void streamFunctionCallTest() { + contextRunner.withPropertyValues("spring.ai.zhipuai.chat.options.model=glm-4").run(context -> { + + ZhiPuAiChatClient chatClient = context.getBean(ZhiPuAiChatClient.class); + + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + + Flux response = chatClient.stream( + new Prompt(List.of(userMessage), ZhiPuAiChatOptions.builder().withFunction("WeatherInfo").build())); + + String content = response.collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getContent) + .collect(Collectors.joining()); + logger.info("Response: {}", content); + + assertThat(content).containsAnyOf("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + + }); + } + + @Configuration + static class Config { + + @Bean + public FunctionCallback weatherFunctionInfo() { + + return FunctionCallbackWrapper.builder(new MockWeatherService()) + .withName("WeatherInfo") + .withDescription("Get the weather in location") + .withResponseConverter((response) -> "" + response.temp() + response.unit()) + .build(); + } + + } + +} \ No newline at end of file diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/MockWeatherService.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/MockWeatherService.java new file mode 100644 index 00000000000..61f6d6c2db7 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/zhipuai/tool/MockWeatherService.java @@ -0,0 +1,94 @@ +/* + * Copyright 2023 - 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.zhipuai.tool; + +import com.fasterxml.jackson.annotation.JsonClassDescription; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +import java.util.function.Function; + +/** + * Mock 3rd party weather service. + * + * @author Geng Rong + */ +public class MockWeatherService implements Function { + + /** + * Weather Function request. + */ + @JsonInclude(Include.NON_NULL) + @JsonClassDescription("Weather API request") + public record Request(@JsonProperty(required = true, + value = "location") @JsonPropertyDescription("The city and state e.g. San Francisco, CA") String location, + @JsonProperty(required = true, value = "lat") @JsonPropertyDescription("The city latitude") double lat, + @JsonProperty(required = true, value = "lon") @JsonPropertyDescription("The city longitude") double lon, + @JsonProperty(required = true, value = "unit") @JsonPropertyDescription("Temperature unit") Unit unit) { + } + + /** + * Temperature units. + */ + public enum Unit { + + /** + * Celsius. + */ + C("metric"), + /** + * Fahrenheit. + */ + F("imperial"); + + /** + * Human readable unit name. + */ + public final String unitName; + + private Unit(String text) { + this.unitName = text; + } + + } + + /** + * Weather Function response. + */ + public record Response(double temp, double feels_like, double temp_min, double temp_max, int pressure, int humidity, + Unit unit) { + } + + @Override + public Response apply(Request request) { + + double temperature = 0; + if (request.location().contains("Paris")) { + temperature = 15; + } + else if (request.location().contains("Tokyo")) { + temperature = 10; + } + else if (request.location().contains("San Francisco")) { + temperature = 30; + } + + return new Response(temperature, 15, 20, 2, 53, 45, Unit.C); + } + +} \ No newline at end of file diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-zhipuai/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-zhipuai/pom.xml new file mode 100644 index 00000000000..060ac290810 --- /dev/null +++ b/spring-ai-spring-boot-starters/spring-ai-starter-zhipuai/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-zhipuai-spring-boot-starter + jar + Spring AI Starter - ZhiPuAI + Spring AI ZhiPuAI Auto Configuration + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.ai + spring-ai-spring-boot-autoconfigure + ${project.parent.version} + + + + org.springframework.ai + spring-ai-zhipuai + ${project.parent.version} + + + + From 83c997d18e706c398d2fe2abd2d2c3a9508d8bfd Mon Sep 17 00:00:00 2001 From: Craig Walls Date: Mon, 20 May 2024 06:10:13 -0600 Subject: [PATCH 38/39] Add spring-ai-retry dependency to Ollama model pom.xml --- models/spring-ai-ollama/pom.xml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/models/spring-ai-ollama/pom.xml b/models/spring-ai-ollama/pom.xml index b9ba62f82e9..7cb31162192 100644 --- a/models/spring-ai-ollama/pom.xml +++ b/models/spring-ai-ollama/pom.xml @@ -33,6 +33,12 @@ ${project.parent.version} + + org.springframework.ai + spring-ai-retry + ${project.parent.version} + + org.springframework spring-webflux @@ -80,4 +86,4 @@ test - \ No newline at end of file + From 2abf10dbf9774dece0ca4bc743890869daeba617 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Tue, 21 May 2024 09:49:57 +0200 Subject: [PATCH 39/39] Fix javadoc issue. Remove wildcard imports --- .../ai/minimax/MiniMaxChatClient.java | 8 +++++++- .../ai/minimax/MiniMaxChatOptions.java | 7 +++++-- .../ai/minimax/MiniMaxEmbeddingClient.java | 7 ++++++- .../ai/minimax/api/MiniMaxApi.java | 3 --- .../ai/minimax/api/MiniMaxApiIT.java | 7 ++++++- .../api/MiniMaxApiToolFunctionCallIT.java | 10 +++++----- .../ai/minimax/api/MiniMaxRetryTests.java | 8 +++++++- .../ai/mistralai/MistralAiChatClient.java | 7 ++++++- .../ai/zhipuai/ZhiPuAiChatClient.java | 9 ++++++++- .../ai/zhipuai/ZhiPuAiChatOptions.java | 7 +++++-- .../ai/zhipuai/ZhiPuAiEmbeddingClient.java | 7 ++++++- .../ai/zhipuai/ZhiPuAiImageClient.java | 8 +++++++- .../api/ZhiPuAiStreamFunctionCallingHelper.java | 6 +++++- .../ai/zhipuai/api/ZhiPuAiApiIT.java | 8 +++++++- .../ai/zhipuai/api/ZhiPuAiRetryTests.java | 16 ++++++++++++++-- .../ai/zhipuai/image/ZhiPuAiImageClientIT.java | 8 +++++++- .../ai/chat/messages/AbstractMessage.java | 8 +++++++- .../prompt/transformer/ChatServiceContext.java | 6 +++++- .../springframework/ai/document/Document.java | 5 ++++- 19 files changed, 117 insertions(+), 28 deletions(-) diff --git a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatClient.java b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatClient.java index 560afffed3d..24bde533341 100644 --- a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatClient.java +++ b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatClient.java @@ -37,7 +37,13 @@ import org.springframework.util.MimeType; import reactor.core.publisher.Flux; -import java.util.*; +import java.util.Base64; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; /** diff --git a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatOptions.java b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatOptions.java index bc2b29cda1b..1b46ca6a618 100644 --- a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatOptions.java +++ b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxChatOptions.java @@ -15,6 +15,11 @@ */ package org.springframework.ai.minimax; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; @@ -26,8 +31,6 @@ import org.springframework.boot.context.properties.NestedConfigurationProperty; import org.springframework.util.Assert; -import java.util.*; - /** * MiniMaxChatOptions represents the options for performing chat completion using the * MiniMax API. It provides methods to set and retrieve various options like model, diff --git a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxEmbeddingClient.java b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxEmbeddingClient.java index 74dc5e4e79a..acede92b892 100644 --- a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxEmbeddingClient.java +++ b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/MiniMaxEmbeddingClient.java @@ -19,7 +19,12 @@ import org.slf4j.LoggerFactory; import org.springframework.ai.document.Document; import org.springframework.ai.document.MetadataMode; -import org.springframework.ai.embedding.*; +import org.springframework.ai.embedding.AbstractEmbeddingClient; +import org.springframework.ai.embedding.Embedding; +import org.springframework.ai.embedding.EmbeddingOptions; +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.embedding.EmbeddingResponseMetadata; import org.springframework.ai.minimax.api.MiniMaxApi; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.retry.RetryUtils; diff --git a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java index f86206e247d..915e13142d3 100644 --- a/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java +++ b/models/spring-ai-minimax/src/main/java/org/springframework/ai/minimax/api/MiniMaxApi.java @@ -863,9 +863,6 @@ public record EmbeddingList( * * @param embeddingRequest The embedding request. * @return Returns {@link EmbeddingList}. - * @param Type of the entity in the data list. Can be a {@link String} or {@link List} of tokens (e.g. - * Integers). For embedding multiple inputs in a single request, You can pass a {@link List} of {@link String} or - * {@link List} of {@link List} of tokens. For example: * *
{@code List.of("text1", "text2", "text3") or List.of(List.of(1, 2, 3), List.of(3, 4, 5))} 
*/ diff --git a/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiIT.java b/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiIT.java index 431d20ff2f3..b308f79ac5c 100644 --- a/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiIT.java +++ b/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiIT.java @@ -17,8 +17,13 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; -import org.springframework.ai.minimax.api.MiniMaxApi.*; + +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletion; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionChunk; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionMessage; import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionMessage.Role; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionRequest; +import org.springframework.ai.minimax.api.MiniMaxApi.EmbeddingList; import org.springframework.http.ResponseEntity; import reactor.core.publisher.Flux; diff --git a/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiToolFunctionCallIT.java b/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiToolFunctionCallIT.java index cfcd2fa0a7d..8e86dd8d035 100644 --- a/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiToolFunctionCallIT.java +++ b/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxApiToolFunctionCallIT.java @@ -22,7 +22,6 @@ import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletion; import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionMessage; import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionMessage.Role; @@ -37,7 +36,6 @@ import java.util.Objects; import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.ai.minimax.api.MiniMaxApi.ChatModel.*; /** * @author Geng Rong @@ -86,8 +84,9 @@ public void toolFunctionCall() { List messages = new ArrayList<>(List.of(message)); - ChatCompletionRequest chatCompletionRequest = new ChatCompletionRequest(messages, ABAB_6_Chat.value, - List.of(functionTool), ToolChoiceBuilder.AUTO); + ChatCompletionRequest chatCompletionRequest = new ChatCompletionRequest(messages, + org.springframework.ai.minimax.api.MiniMaxApi.ChatModel.ABAB_6_Chat.getValue(), List.of(functionTool), + ToolChoiceBuilder.AUTO); ResponseEntity chatCompletion = miniMaxApi.chatCompletionEntity(chatCompletionRequest); @@ -116,7 +115,8 @@ public void toolFunctionCall() { } } - var functionResponseRequest = new ChatCompletionRequest(messages, ABAB_6_Chat.value, 0.5F); + var functionResponseRequest = new ChatCompletionRequest(messages, + org.springframework.ai.minimax.api.MiniMaxApi.ChatModel.ABAB_6_Chat.getValue(), 0.5F); ResponseEntity chatCompletion2 = miniMaxApi.chatCompletionEntity(functionResponseRequest); diff --git a/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxRetryTests.java b/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxRetryTests.java index 4310b87fbb4..46d62ae3c6c 100644 --- a/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxRetryTests.java +++ b/models/spring-ai-minimax/src/test/java/org/springframework/ai/minimax/api/MiniMaxRetryTests.java @@ -26,8 +26,14 @@ import org.springframework.ai.minimax.MiniMaxChatOptions; import org.springframework.ai.minimax.MiniMaxEmbeddingClient; import org.springframework.ai.minimax.MiniMaxEmbeddingOptions; -import org.springframework.ai.minimax.api.MiniMaxApi.*; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletion; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionChunk; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionFinishReason; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionMessage; import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionMessage.Role; +import org.springframework.ai.minimax.api.MiniMaxApi.ChatCompletionRequest; +import org.springframework.ai.minimax.api.MiniMaxApi.EmbeddingList; +import org.springframework.ai.minimax.api.MiniMaxApi.EmbeddingRequest; import org.springframework.ai.retry.RetryUtils; import org.springframework.ai.retry.TransientAiException; import org.springframework.http.ResponseEntity; diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java index 9b7e19de097..17b7f8d93a2 100644 --- a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatClient.java @@ -41,7 +41,12 @@ import org.springframework.util.CollectionUtils; import reactor.core.publisher.Flux; -import java.util.*; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; /** diff --git a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiChatClient.java b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiChatClient.java index eeafbb4ea63..7d511d98c68 100644 --- a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiChatClient.java +++ b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiChatClient.java @@ -44,7 +44,14 @@ import org.springframework.util.MimeType; import reactor.core.publisher.Flux; -import java.util.*; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; /** diff --git a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiChatOptions.java b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiChatOptions.java index dc9d7345474..13dc8d51944 100644 --- a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiChatOptions.java +++ b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiChatOptions.java @@ -15,6 +15,11 @@ */ package org.springframework.ai.zhipuai; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; @@ -28,8 +33,6 @@ import org.springframework.boot.context.properties.NestedConfigurationProperty; import org.springframework.util.Assert; -import java.util.*; - /** * ZhiPuAiChatOptions represents the options for the ZhiPuAiChat model. * diff --git a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiEmbeddingClient.java b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiEmbeddingClient.java index c12742377b7..25aecfc749f 100644 --- a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiEmbeddingClient.java +++ b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiEmbeddingClient.java @@ -19,7 +19,12 @@ import org.slf4j.LoggerFactory; import org.springframework.ai.document.Document; import org.springframework.ai.document.MetadataMode; -import org.springframework.ai.embedding.*; +import org.springframework.ai.embedding.AbstractEmbeddingClient; +import org.springframework.ai.embedding.Embedding; +import org.springframework.ai.embedding.EmbeddingOptions; +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.embedding.EmbeddingResponseMetadata; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.retry.RetryUtils; import org.springframework.ai.zhipuai.api.ZhiPuAiApi; diff --git a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiImageClient.java b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiImageClient.java index 6d3df147504..09caabb630e 100644 --- a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiImageClient.java +++ b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/ZhiPuAiImageClient.java @@ -17,7 +17,13 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.ai.image.*; + +import org.springframework.ai.image.Image; +import org.springframework.ai.image.ImageClient; +import org.springframework.ai.image.ImageGeneration; +import org.springframework.ai.image.ImageOptions; +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.image.ImageResponse; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.retry.RetryUtils; import org.springframework.ai.zhipuai.api.ZhiPuAiImageApi; diff --git a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiStreamFunctionCallingHelper.java b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiStreamFunctionCallingHelper.java index 4a7f24a79e0..b74303729c4 100644 --- a/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiStreamFunctionCallingHelper.java +++ b/models/spring-ai-zhipuai/src/main/java/org/springframework/ai/zhipuai/api/ZhiPuAiStreamFunctionCallingHelper.java @@ -15,12 +15,16 @@ */ package org.springframework.ai.zhipuai.api; -import org.springframework.ai.zhipuai.api.ZhiPuAiApi.*; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletion; import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletion.Choice; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionChunk; import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionChunk.ChunkChoice; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionFinishReason; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage; import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage.ChatCompletionFunction; import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage.Role; import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage.ToolCall; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.LogProbs; import org.springframework.util.CollectionUtils; import java.util.ArrayList; diff --git a/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiApiIT.java b/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiApiIT.java index 18eea6b7450..7be3a09ae9b 100644 --- a/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiApiIT.java +++ b/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiApiIT.java @@ -17,8 +17,14 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; -import org.springframework.ai.zhipuai.api.ZhiPuAiApi.*; + +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletion; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionChunk; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage; import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage.Role; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionRequest; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.Embedding; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.EmbeddingList; import org.springframework.http.ResponseEntity; import reactor.core.publisher.Flux; diff --git a/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiRetryTests.java b/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiRetryTests.java index 61ea6d35359..3326af0981d 100644 --- a/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiRetryTests.java +++ b/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/api/ZhiPuAiRetryTests.java @@ -26,9 +26,21 @@ import org.springframework.ai.image.ImagePrompt; import org.springframework.ai.retry.RetryUtils; import org.springframework.ai.retry.TransientAiException; -import org.springframework.ai.zhipuai.*; -import org.springframework.ai.zhipuai.api.ZhiPuAiApi.*; +import org.springframework.ai.zhipuai.ZhiPuAiChatClient; +import org.springframework.ai.zhipuai.ZhiPuAiChatOptions; +import org.springframework.ai.zhipuai.ZhiPuAiEmbeddingClient; +import org.springframework.ai.zhipuai.ZhiPuAiEmbeddingOptions; +import org.springframework.ai.zhipuai.ZhiPuAiImageClient; +import org.springframework.ai.zhipuai.ZhiPuAiImageOptions; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletion; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionChunk; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionFinishReason; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage; import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage.Role; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionRequest; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.Embedding; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.EmbeddingList; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi.EmbeddingRequest; import org.springframework.ai.zhipuai.api.ZhiPuAiImageApi.Data; import org.springframework.ai.zhipuai.api.ZhiPuAiImageApi.ZhiPuAiImageRequest; import org.springframework.ai.zhipuai.api.ZhiPuAiImageApi.ZhiPuAiImageResponse; diff --git a/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/image/ZhiPuAiImageClientIT.java b/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/image/ZhiPuAiImageClientIT.java index ea3f9dca16b..c150cce6315 100644 --- a/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/image/ZhiPuAiImageClientIT.java +++ b/models/spring-ai-zhipuai/src/test/java/org/springframework/ai/zhipuai/image/ZhiPuAiImageClientIT.java @@ -17,7 +17,13 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; -import org.springframework.ai.image.*; + +import org.springframework.ai.image.Image; +import org.springframework.ai.image.ImageClient; +import org.springframework.ai.image.ImageOptionsBuilder; +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.image.ImageResponse; +import org.springframework.ai.image.ImageResponseMetadata; import org.springframework.ai.zhipuai.ZhiPuAiTestConfiguration; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/AbstractMessage.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/AbstractMessage.java index b58cb3c218f..9db9aa720f6 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/AbstractMessage.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/messages/AbstractMessage.java @@ -18,7 +18,13 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.Charset; -import java.util.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; import org.springframework.core.io.Resource; import org.springframework.util.Assert; diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/ChatServiceContext.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/ChatServiceContext.java index 82868e52841..2b064c55d14 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/ChatServiceContext.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/transformer/ChatServiceContext.java @@ -20,7 +20,11 @@ import org.springframework.ai.chat.service.ChatService; import org.springframework.ai.model.Content; -import java.util.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; /** diff --git a/spring-ai-core/src/main/java/org/springframework/ai/document/Document.java b/spring-ai-core/src/main/java/org/springframework/ai/document/Document.java index dbc5fac8f95..662cade894c 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/document/Document.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/document/Document.java @@ -15,7 +15,10 @@ */ package org.springframework.ai.document; -import java.util.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnore;

9oNv5!E(U5UiK32J6kt%)i%v9W9O>??~=9VF+LdRWN=d*&_MQYR<2{ln`EN zr>u;QS_C`+i2==!bV_y*F{i+6H{Pj6Cdx49fS(8WAJBp?BNGA3wGY}vp7(|Xjekd_ z%M!JRN8~+&@_X}jz29_j55jY^)MrPKTE^WUIwt6$@thjO4zz7IB<7LLpb+C6@?})9 z_?Z+JzOe7G5k3s=`}pu=$k1T2#yO}=S`(fE$-D2>EmskK=90_L4ES@zlLqvO=_1Z= z!|YGPRHe9O+ku2-d!+jvV_@!~IPIv5Xleu@*mSkBiesoM9j3;@3}%^f*KxYZJR`+B z3_koIT~d-^QY~N0`xlARlssz;8Q0(yW}ml;W@gaYHfF78nPqda&yGGRW|K@r{T*E| zS82zAv_64v81g#VJCCf(dlyuRDQ4*b#9aq>4EX_(j+>kwXu1Au{_u`bo8Y9`ro_6_ z;wBgrN2$>7Lx~?Vt++!I;;E{?DKrJUKekTs+aH5YlVoFE&ifT~8(=tGpzu!-IhSpTu>-2m!21yt$c5EJM~zAIK<7vi zSNABhE}{3T+lm5T<<_O;%H>_i3TtXQP0fS$dEPwv(P>n(LLitAjaoV56Lj)G85*YO z2TN|mj@4eWlseYq874F$c;MAU?I>Nwml$~O^8*u?(@9kF|Zb+@L zCzUfteTY>Z2o8c9=%gf3OnaeE9D*xQr%%mlZVCc2j4P*AW31uqkyKpIxF%+QmD~yc z3Ll?UYl;+&yi?vMA}78n~ z3qV#dy>dGTb2LCf#E*7T%~utm#xnQ#`T6cUS^v6K*qp75Kf{DR;u`!<#G@x7Pnh}1 z6zzF{pqCRFs49@WEOHwPr6r2(-6E$=Ym*?kMX?dltYyXH8f(no zqG+Y?VOG97_H+=(4IBl4vZCotU~~GT5N|3ujV&?J^T%bJYtjHUXR*CkV_*VMw;3on zYWd4ADx!)dXGTS#uDV9kF3vcw8yGDWC5MZqtwyE4ocj^zX~v*SiHa|vLv z(N@d%uyYSHIPOO3&XW#0oclgpZ%U5gX1jaA@hic5am>lG4ZM1EP>Jk0; zj*7#PHe=V%*ImcYK`lQtVbSQ?rx#RSw2$$xl!B+LXF_1f>T4AOKz*!WWmXE}Dr;Nv zLEfcC)e|AFCVd|W$X>5IiAMcXIS~vd6Z%>_L z14i_v$VJQN;T;Ot_Ir-Mdw^NpY%JLsE4;drVJexZdtI^XsIs)Qw=T8)64@eP(15Yp zmsi0(T{iX)PuZsB(ej;NfGALW@;*Y%FR?$4Xi7q^t~o5YhFIv?$lGkWB}QYFs%qKem^T1m=_7G+tVthU ze836kw#oY?S-{k=w2AesE5^Em32PKi5k%g|17DiYX*0t4U4;g?_4ub7Y|5~lN_)l7 zs}Z+KLK3I$^R*Ie|8KghxHxX3VB(Ei*30c9(ta-h55{Oe~Lo;7Y^4pl@*`yE#Tlq^%oF9Zq&!i1Ti^lObGL}WV*hT~8r=vEFfBKt& z{C|uk*jz*mj7;$gg{6G$5?pz+?>LmS-zsBbrA8ArSi#D)qVV%bRbZoD)!9+CWD_V! z+G}O6$(6vQ!Al@>ayU{FX!3A0H1XRia|s*6@ z+w&NI(%0KOYe3IP^~-GkO@CtZ9FoN4{lAy+KeY@jf#@9cc4O()$Yw<{pXKMe@UwXKH!?pJn*b*;o8j%ER%&qa*F4YvL~=J6YQMa8z$v^#m?!{8R>k}yXk1gON#uT8wQGP2aC z+Rp!3W`ut%H{G>!UKuAOt^S>h*HQ&@2HmE6v& zW={Lk>=dQP>A31j_C%#%?x68`A4|-zy1&sF-^0gD>}rP3GxJ~ZQ5cWz{)3feay9vZpdn$+{W&!Y>lmht*fG`i{)kTp-%5*m%QMbi9u zK6bh`_(kU@P$#F*XaM?sE3-Gtv_B$JOiiv3-JdJKzIK3eU~?5Rd5Q$ zKuD1HmoJMp7)9#{F#Qw|%}R`EyXrOGee=gbN_L&AnTvwy=jD+mT4(&Wr$>j@VuVoh zo%%YTsjNfIj-o!iL?iQ?G#9Tt>B#qRB^DPyr0s(uq}K|eZvoPL>*o00((;?Le35)Py1+C8)m6QrojdumR$;%L1TV&%2=JO z&}dX)YkAW1Dfi_nckeB{Cat6zf2$>JkyKA4oyW*_AeBIP0AXbdCJCrLoCZ{{sTcOY z`L+!trA_l2>dIk<9+tS&#>%skUN`lPcE+y~?9bz~FWn_?>*m!ki7`{M$Fd2STTQ7e zi<;LK-51!7rM*-Rb)@k6i=;x=KKxOcgF|^3-A#2#d$E%yG7ID6>VcebKDKprsoK3P zIhSzVl$jz^k6yz{{*Ki*r;b9#e{Mv988kE91Y+^beBxe|JrlA`P{>C(P>dCw6`cl( zi{I2#@XS4Nj+~kA%j!PZaNQqv7`L`fuWWptV2^1%8X6&Z8(3eJ^t0@O;SY?id7s4W z8~S~~?0Vf@N9pvCD*WK_9Wd{|(mA8?a5;W@l=1DOo&jRrSXiG*IsS1ga>yEMWVL1q z+09}NyPzX)UR+AKx&5*Y)tF+CA~(f7tM{S2V|W6kQy^A2JQ<$2#T!7;z~Fvud*ZR% zl%{@yh4d@Rt=31F1VVBiM+`K7mD~5}Ka1PoF`s#nwvPR!x=6K=%J*rF$5czFoeI)~ z=uk6a@+e}#zxs))0-8R^a`7`Gm@*{j3UA931*g}{J{W|nYmqtiD#i|DvF}mIwT~+? zwJ(QKIo2{jTl=DL3S zso@IRW9|~st_&a~I$bcnUU)B$p}T`xl{bW1-in1E!13#6FtpoR-GN?3G4n$Vb63b$ zj=G9W>(=LmG93FBhwfsJA3(e3@XI?rQgrW{XSXfqKa{tWZY6LP;@g31tqwYadhSnk z?W>^tv-1LZV)RMav^Q$*XOSE=WTba@X5*-7WZUca+>|P2icP_qdsOU|LJ!2lks#Bi zt8JXmGkSCUN2-@s0Me=la@=K>f-7SsO&i@hTc*_L^?Bjmd%CvQj++$ghwkm2V-L!GoF zWNM^KLJZegU0)LD(9<>OG^7~ffQx52Z+L}YcBS1$&>fR%Y6!@}@8`Wo-SU?snoJ^h zT{JK_75v=V%WfbG)J7i4d>-UO+sXHH_w^Pfq`z2zrN~GzE4+7})ArxMR(vd!1p$$0F6yed?5Qc|a*EBE6Lp7%%cY>Y6 z8fG=f$tiSUXcNC^a_a6Wq6_oyY(7*0Giv9WgU@o}g}zh$-tywhXoMTM9y7?IdV@XN zO;d0`dpNOr_qWE$_i%&?Y&gQ)y+tjV&}37hSHOni2i<=CF8#{2dPg4*sn5R=C49ca zmv`xQ?%B3~ss{lY|P+u*L*C45ChNjX=Bkxo9Wb*x{VxN^>n72SC2hW4&S$LxPa(`P)P)E&89tD>Yz!^=Utrrnj%8z zuu62lN;=pHk4JNW$}NSL%d+|Pf;iQ=o8n<2FB@Ku5&B$<%)ecAg&ISHHRaRZBXS36 zWBi8dRdXl~CW~uh!faWC&`pDpOtM5!=T0p-&5sv4-#%)e*{%%l*Rk!s_?Y7GovIHL zTiE!J_<;z!x)IxDDZsm+wAl7Hr_CwLTYC9z&*l>yMt$DV^O7cMFLHJCzM&-4FPhrb zZ#C6dEs&5Lbo|qGoGd22WR2*tIj|jnUikUFzxPk;i*VngdcZHPm)ft+PW!J1B8MY? zMDQEl=y(_|k&u}ElMz518{8Ai6~L-O17_ex?mEU=&#-L91UI0fhBSl*g1eg)T@uJl zgw=BHRRqIM+60g2hsy%IE7LFq?JkLDDDH>XGdf|HDwQMqh^UDR2NAO?LprD|T z>5%_St!iP}6rvpui>LCZSFy`q!qtbEo3#5xPe;~H&&Yk8QwqX#zp5Vc2er{@SW9c5 z5kj-LZzM7jayskJBb8~x3u^7FVzkCUEj3vIEBrEz={C#UNyQ*GSra`^Ia|-Ov+b8x z*TXe6YsD;ooFp+fr3VIS_MBU8e^DtLo390qVZm3u2y_(~iY)7v#n8dm*ek)*lW$)q zHY&6hy-vylC-~b8gO8#$Fm-66+U_VqL)J5^k=6K5ReLndMJ9Ij01EeIQM?HqnGBd*qS9&Rih=-y_RlD}<=W)(r zEG+<)5p6rgM8n#ayjN%s-))@6>*g6rV1j}$)5Qs z>bU0t5+lxO$+5K2wgz9XNi3nn5L0KZj||DXVg-&z!G0yqHv0rr>jDSvqfU;f+M9KuR zLd=GrInxf2zVf`0u|se!B5Uap(O~_U=fkkb-DM@DMN}(_y~;pd5s&HV52Dku*oB%h^1Ofh5(=l;vnWg7wxk5 zMHnqiLvc(!*LqBXyR?p5r=dGk>~_}8&SZ_1^$)i_DR-b;uCIA0gD^*8e zIdBXpFad`6XvBO11#n$4gSd>Sub$mZn znjwkm`#t_jQ6yBdA-q?5En#%>S1+i$`hxnpp#OMee{1mNV4n|6^OZ9^ z5YA`~Ik^5a&4bDNhRU~X=@a)q=R)tl&^~-D)qfJoxUqbz+jObaR9zy*)OG)H^Aoe0 z6hYc>%8A%#s-kTb!$UmWo>X~tE4C}>Iv)(yP(gc zzPF9`dlId*mrNKRRhygQy348mec1n#Dv1z69FRtYv5as2ieD4q?ic@RN^w^!U{<%@ zh$qQN zEIuT+H=4rr6WiT>+?wV{;=abI7lZUw*r4|*IGQtE0;xmMB$r%3WBfWm3v6gkP(&bP zZAGSW_g9MWl0cX%|5Q+YfHn!Upoe4bS2Z|NEmuU}sljEy*&h@GFsOy>C@oY6p&d9A zon!bRwucOkah)?v?bSU@UzZLl66)gFE`PbG(@A4o&?W+QB9E4f(!TbgM-XtuNy0}d zfc(L;fJ})q^ru_hmQDBQp~t`Qzh_rg)_#>D=i>@-{Tj0oHUs4@>&~8$uE7DJK)J3P zPI&5?aKj75H9gtrm`w&K#Fg5;=g4%DACTh)jDvAgvw~Q&O8i`mL9#Q|>~Ce25Q_ck z_eq$8QpAR3JGl1^E43b)68hlQ9K}7=OqfXG4UJEC$-@m`6`(s@ZK3sK&PB9w&BT zf5glD{W!yKWQSfUhq2zd*T4V2oALL%&ME~u5vdx>HBHY0^482I0IE8uFN#~awKQ|J z=LT?F6@aWFgdAJY5B@Lb5ZjoV=Txm6xFjDLCwi^U?45n!zKN7Kt)=KnoD++8x0U$Y z1<8JnxA3M>dv5GepRMCzQQoH#XsSz?gCb_ShpHUCLw`RmQZ2l0XCHv|v!;bwb7hNU zUWR^xn^V}=)7`U8XVxt!q=5Q}6fC`IQP_ZD$rDxBOyoVEHKeh7;S8IcVsM(!j}GB!>Xpw<1bUQWwbu$UT4OgWZ$^Ga%v+qM4H)y4hVygw z?6R8RdxBCjEBKDnCBCw62ohvae`35h!H9bNR5r=22}^|kLwlTxE*}&y6c^0ofN+e# z;7dtwDC&)_y^70uuc z235y`%r7Y9jk*3tMDTjS=T)PFDilP`MgCI&rDUcGwCpSUvCb%;{DeH|_BE9?`MKt^ z<>;;)EENvihpSv5Frk1#kAcv<+g|8mSxZn@Xc~TE%Z%TyOqAOo!-_nLKcWU<_m0WV zDtE(MEt}K0#;oUU=22~Q+B1n@$&VN=tAc!57YP$t1=Pk9m_5faa9vM5g7;FR3aD_Q zEY;Hr(Iv|ym{dj;8&4w_%8x3bn^lPXysGNyS0?Mgwu+DikGD`Y8#5D2Xu^7uU}|s2 zhq7lFU)2_1XfC@~Z={=JrxRIAT!3H000 zI@Y6tKEYkj@+0d_lBP!XBPF7ywi8bt85Uq|QCnUyXNFd?S}8taVPTnHx*Uu$P|Qd( z|ICk+BHp5`;MYYe(*Qu)eP-RMZG`N|0Jxiz{jvTAziMaA%;?WXMm^j#tgEDmz0s{h zhFPyu8`2EQ{Y924OMpUI0_Lq0tEZ}`>H|6?6Go(Q8QGyc_L&To4oh^&_Vh(3^y}@d ztge~vq`S;4yK25U?qvycq3?7BM%|6+3%j|bvX=)A$_bF;#)-ojjWYNG4`_b_dHl0! zi$PAcUVoJazR6h@61S-HM^(Zs#sJto6#ZCsIyngU!fXOpM9(@q)v&}r8_eFj>`{@@ zHMEqQF~T;7yiietQa_#J?X%k(GBs-MzgU7BQzr z)hX?kKAwe^wF7Y4vo^SSp*k^3Q$fA5{EpK zQWdT{K%jE+T}S2P_7v`34l{|2W!5zDMkvJJz0;Wg`f@g4&Jm@4XY$HL`CMK~pxph0 z-j?h0spTHjfk%H1Cz!cfaoF&kSJQZ-BX_YQmI67^Yt)|{PIx&B;A;nJ{&FDt`Y|8# zHki7_7JqrCx-^}t6wzCh9s0*gE}0x+tu;bkIi}k^@Ts*L01J8mjfZ$4$vs!<#Gn{h zoeS!u7w*6TlBo#jncB_*WlUq~^Kc-Zocdi2Ma}kZPUfliT5a18!}Fa+`ceo3Gwwh3 zVs*I^v(n8`h*&(jB^4}5Sz=4)Bq??_b9NbFP?GEGmtQo>6q4Gp`>3w#?Xvp(xgM}s z&0f*iQoctRtwKgOSRo0N!aB_2uSYi7Om$3it7-K_JvKB3-&OXBT##33FIaTb#RbF6 zod9~ZOgj|evZmEnw0mB1qmpxk)7k(PV@nU`fkum?gJEW|eSKtE>iZ;9kYHAxPsH7? ztZ^F>6IR!5fw^#%#9!7i&CY@YfRuz2Rig8~-t1y{5zB=9yq75h;F? z4V4V(&M8jbH{}r*c|v3SiX3A$6I}i=4i$Zo^;IDm$MR=NVQ}GCmoP)_)Yb5|`86FO zYA^WeE|Vbjrp7hcqF#Wu+G3#!VrwZWMvL|2&!8OU)banFyK8w$zR{J%Y@s@0rBcN? z7QERs)srYtL?$f%)uyX9jY74X`HDuCV`-timOJg012-xD@OiA2`oS*gWMu;b!&R!@HCE6m%(O^FzcmE5;WQq+x#7(=HJXTf*9lu(2FWLC8o+No;c z9RmmfSwHX0_i@iQg}Pq0&NM;3P1eVeWzSGR3X z@4RZmaery4ug~ z6>X0n6J_DCx&B2LlzsX-mK!tlp`ybAPG#fPH^Y*ay&9+q{YYxS+l3$n7QchK_U+*Y4wNl&;#Lpm+C#V8w=_jNI9dHRVp z0JbwK>K+NS6=<-JiF1PM(Bh{OX+U9HIYsBg(xTWO0ehb*M}1p7$@HTYBn{`?9gbY~ zW>Fl+9|DqG^_m;YFl(~&s>j$!a1K*qLU-i)M@t|p9YUd<6-j&1pkA&tv!$2^!f8); z*4GqOq__|>y<{YMn5txS#GWlFrQ}fnRp|vyw%|!BqVwAq zz~<_*?h4|Fdw91p_jFoObN$Ig!S$zu0-*2xqK4wLxYqsYdv+)kFb6teoUTZMalILz9WigO-yQmrr zwPzZz^yBO^X3Fne?baxStmlj}$$_VERHMao_1&jn9ZPc7F4cg@Uu7U^wB4P zXXK@v<^@}8*W`wVY>U&>I3`4dj7bGYSvSs28vPZJ8?F+cESoYoJTzj(W4n``QE#sv z&aW0SmCsE}HdidU_I63!Puwc<`+c}1E^l7m5>Z1z31xp`$pvN3-do&u73ezHc+wtD z=3zY>mc5Q=sHwCCtm!S4LlfQc-}>q!hmbzJm9~kOW`A5BigiVoi;$al2QtF1sMkA$ zZ^EiCDsj*J;5sbO+!;9ep%iu(qDEe7`n*G6q(Rf{2=6uIFsuWjJe;l^g|3>ZDt|!X zJte4<3U^AFXqx98PedH#)HCx`!f`2^#++2p7?*O`ba9x{XIh2n9(6Vof4cA%W($m2CQ;IW zk=aQav$aFS*P5VhcDX+g>OU@TQ&wjyz!0NF2NsE{-O}o<6#}%xri`EI!H|_7?!rHD zWwXv_wcsz30L_(mP~|o`;j9d)vMGB(<1?URUIcB{+X%!xg3BCR78^!Q4+?B4y{GHBGI_hCb)>GCr)4L*?vMGWPI>bMMT5AF0OQ>*~?;ia3# zT$NIB1FZc(h%FSX;2u8XoE~=~%9EyO{=z-)XuimF=4vUHFOU#H)RLUzgJ!TPj*Ns} zErmr!tRT-!wSM9t;#MQMJ>cgR1t3!bb7z&&G+8}I-%9$xFz%J&$G*PaU|OaQ;-WnF zF$?F%5+|s7TAn{vbdfr)R8>mOUxKM+GkmKFp{z??14;q<8Do~atXS$5dre{sxBk%| z9X6&#Y~hkZZFj^B9_3ff#hyKyTg*5F{`vNuDs-4E)bqzDcxF%bULm zSRZ!>`y8edrP>aN;E<)NW35=4X+gDUWt@wR4byn@7j-)3tUAJR=PSejl$^DOK9`5*S(-zdX!}ad zs;v%`>qWJL19LAcdh)L4eirLU214aYr!J#SH8rI9T);6Nylft#J0l+7ZLe2Rfo>MSE@0L)P zC+%apiQwD>dsBgpst1oV6lO*aM6Csw*3e*he~f^TauLTtvd3n~yGlLj>G~P}Y2DuV zSJ*DyiFnPc;=IX*bxJRN#E3T`u3L4%LE`CXVJ3)st)*!KG=gqnRV-*s)5GSPoLPdA zVMi8d=o=}~SXcL^h#$8_axZi8Per|pFCOIzS7;aiHr}tH4Kp)Ru0p;}a_8ZZ0V9Qf zvbcUmlO|U4!;*ISyQ;_fQElZa7CkYZk#okvclxa+mGhx@fK=vWkMgWck9)vtL4%5L z)@dyv1ndamjaAX4d-qv)Sx57gWMEZpzdrh8+xMZnBsGyeh*yi|#w@VD0<6)w5FDni zIx@kdImT17>$nbs(lZB_b+X~{5 z5<({uZ{bRxiho%Fsiu%eWJHU4X9TmE*5>SAtRL9E(KSvNj!3fnb?0lR2w3fwf;9z< zRkYKN4<#J@q{fc}y>4OGPyxFjr#m~2e!ie@EW(ybTqm;&z!j0& zi)AQTfFXikuflsHa~e82&EDbUD`KsY+;RA-neu(-ljl=4svluAjT2jj3X|zC-magO zS{`a;_nkAZ*%~ub3L_TZd|aYP=qpkY=zE8bFNea8G@xqk30(!_5`T10w^`>dA=*nD zqa7)nu=XzmGtWt9#WeV406zBQH$|{nqSk%G=z_6v7WPht{(h*Ja}Hy?{OWTjEx6;H5ix#0B*Utn1zoImGq$`C9d~oJ zb6_LEsI1zFVHnjp+oFke1W>i?08L$D&)&$4%g2c*@zi>Yo8;D94$Z!^ z$tvzH>342``S++tJ8Y%|mKLe;5SnIe(}a)YT58^cT-?Unb|U;O(Q|y#Fz3weqc_j> za$T0jNBe7}>K2N*SyCm&fq)Mt`msAKxp-%?jW7yPo8?58#bo>CG~j20%+EJ^fSU|S z@x~k_SYG;b`Et4`=NzUo)ZRT8aIkq(kNs5k7b1UVbio@>tM;?K;^b}qvo~}`bsJ>8 zq0A8Hui!-Q{wsxtF!q=Z+^$#HgjYR&;R7wAvVtYGozjz3+(5_J0S+#6;LH(S=4Ee) zv({Kv4{YjOPYmh{x?x2^MX$)*HLAvIg_8J*W>f2}e$}&b)q$P)aWxD19m|Yz&GKRP z9L*wU!sc*efk#Mk#yeR(@7n%hmCs0%K2DLyDY>#v814FZxx2o85QX-L$ag#^( zjplT*y=CyK$el_n*)dH+F*PzSl`f^dQ^8y#-l8$_82N4%p4$cvq0v~@D?WVbNLXC& zD^0}C4k@*2UaaJbunQc{Izy<~NwWIR;Tq9q;Qqp%{Dtes9!FINe4gp@fbLR`p{R8n znD!sH_LYP9E2Y)N*-%%nouIBJQ1vdg%S3nJhgk8voJX^hEz>*Mr+em^lOE+&p45OuP9re@I}Lo=q9_A)$r^hEqOY*G zllEe@=PQb{Tpi!sE1Ii0{`g~f^b^U^-G6tK`MZA6KZFrS?w$KI`e1o|UHnK&zx@xV zjsF)!ei3E7n&z|H!uz(bJ(!hZB)i#8z@HGBmyT}l{&$NjdC_xM-kaIH&G1Xk>zSquHF;!pRp%&s(aARB0Y)&p&PLlFX ziV1jGa#B}(lPr7L<(9MAJh54SYX5BOCGk3lI04@~XFszu&l^7-9jJ}_7LB`ZFR@Q3 z@Ma~PrW@-Ut&~F8;n;re*-;oiF?F2F*<|#dr2u-+WM%>eMram$?s}aP`uBbt{Pb_N zuykUqBS4U`kE>urbU#F=Uxa_Ng|_)907+qEn2)BUTJC}s3#j@=$uwUp`C3_gMfLt;7GSyx||(|tGjT@;hARz zke%=1YL2T!jKVL&aafu&PG4`eNMCi(%8t`2H8R&BPcQFWUvVkMhK;MMazd7+_HR8}(lnf1|ID~SD6 z&>4viBV_AI_MAb_@YjuBc~WH!ZgIH>jG=6tIEwrI`XOnk2X0;H%;~$C)0}UlXd}H9 zHkWfG06o3;#vUVz3L2QDQ-X+8@=icBoyw}f40>S261?+Rq6d0y!dYxykZ3qIff~)G z9mP3yYb6+{>)Tr?kG-K4p3^YNX<=zt8p32ner69*9?7%_^%Y^L*5xalL5?!zSZA}3 z$&BVXgcXP;Ip12(!zppq#YbLY61I_s0$F3tLPPr53oE%nq~=1oAf_U0(zo*dOj%D= z7CCH8%)tT*vIDiQdfyL|0<`LSKd>1q>Wjg%Ff*C~Vu#m-`)!TFx;^An8l05;j)wKa za&+Lfv8Q$bE-0;QJZs#F2@&pPd=WgxLylc=7Ad({6)-Vert?Gvtmw!yEomq=`HfNV zuVjE{u~Om{Qqfu6@@DcDtZB!+YDYH4@cl&Py+?_`8u3v{*avMnkuzgNB_E<%J5*vM zE6qbAhGp;NZOg=y^ik?mGrRY+E=5|>^&tlem>w3WcWyCu=ll8$N<|<;av4wVUSmDrEc5K58i`Td(p#be3?v;B)t6-^_Hq)V*G`*J)=yjsgq@v zXqIm)T;G@*z9Lm=aTV-BFy&Lb=(xERnG?)bK)jKz^*wE{P4eR6DR*l$)95#onus1a z>y++LKtBqnH#}b%O`aQc%0J~R`mz6y-V=6;&H1CZn~X)hFMmKZ@O{Fv3XA*LXxh~? zR~iUp@WU<(Jla-P-X$|fAHK1br++JFV}uDYzI`h2>#>iNb^@AC&xDzuZw_qVF%qHZ z44sC;u^hp2KWA@AaA_XzS20neGg(pF#oq9_GJ{sK`kNM*!0DFr1HmL{ZZ!|l4+Bi_ z?)CMmSO4fmu|?PEP~Y;^D1Jb+M^ja-*i8ei#-hN=IufxLZic?jDDY(qAPLgeMCOUy z=_{B`RcSc@m&VU$7jO;*u#|@Po#%X*J(awPQmD;woIaU@`+Dc5NT@GeC?(8QHVm(2 zAce?lIRh5UQ@8I5|Ft!T8Gl?b$)oqj4xWj?41Ul;FO$5$OZg4^FEruan6CUWVJ<0 z9=?L^@+FUwkJ%P%IL^sMClTt_T@&xJx^dp=U$|m9;-NMX zwir+QJ^y$ZVp-?V*gc#Jn){S?FK$bbg?|oaZoB)dudjZHq}Ewq*XhFfv)e$?7^_4$ zRlNph(!NcLfVN1dpR*v!B^Z9BdX@8G1&Os4v%F0DI$_0Vh8bvJD=$cRqVv6E-&J`o zYtN97d$6& zea!TKE$vVJEIAVT{L5zi*XGouGvisKRp6TG!Y=RQ|AW2vfNH91(}uAt3J54lRZ4&W z0qGqcItfieClrBDLY3aJ(4_|ggsMR30coKLC_Pk#(3?^P>AmWoneUx>d}jXloA;lY z^{w@-e^^xz9t#6|l&TJAUB#J-gL8%p@!)O)G8$JNnWq|0{R z*ILmZ5~ZxiWJ09v3V-_IVpl*qIvOV+KwPe4;V&PoxgM;^#cHxhHsD%RHC`G`;EY1< z4Vf9xq(km1+t};l`J&uGhoC$8|z2TNlUYb<{>RMj1fO zDLCC&E5P>$u2odI*@7Cda6ovzTEjB#rk_H>XPZc%icQQ1z|{~sU1w*t8IbQn+3*FhwR%R-AZB4io2?%O zJYpjvr^5X?vUQ5AkW|V}xd{M3bGxdRtE2dNuv+;>lrZNo_wXS&0SfC_p6?BQ(nDdN zA|kh*yR9OY;H=W%O=PuN9=~5{6WFwLSz3 zybTb+nOTIEj^Gw?XGnia=N#&xPmbW~s-Cf`73D+7NuoWFeWrACe|CxoWnyelIrI6{ z+ivO?$wXCR*>OLwxwjg!dEfZ>?lVJ`z~Xx6BHSZ+Lap_4`8Se?`E2o4xd%Zj)Bd&v zdNFjLu|U+*@*ikUt%l(HadzCM}}okKX@3Ee!N zh#5AQ4)d3tkQ(}XE<6sTL5hbwbDru?T=aUJ1qZ2>SF5K{BI?+{G~F_wQ5IfibAht& z0z_D)bp1JC4Xu>;9`oeTI32Ct=ZHK_cEpBMw7M&Xy0Z8p0Gxzei2Bi^M?;ya=&Uh# zivII_g#!Ge21S%fzvoyEow*u6?6n=|I=!7Ia1q5hYB${%-6ywa)`e*9ZxFmFY{dJ3 z+yfpo3=EH+KnX24egl&M?%#roaWTx^iz8_8J*rxTxk^9) zP_4$gIeymba~qO{?apiMV9WG;y=!OiG*|TU#j2pIsyo#}=hU`Fr86Iuf_jNJ8TdIe z1f7tY=&_~l9>63xEYn$mnj8I6T!zCN0g{r`%)Se7(E3(!VmT+}ladKlte~jI4>lX1 zSCxkjnOw-Uw36Qnc+Dsj7hSbve zmKx83cMCYK+2HbMx$>qp+nmbt$fs~h&QE5skATqfI!i8dF6wR1?y?u0ypPIK03I4i z`9>xu5R+C#Z?!>Lkr9KIybM~Fg8Iuzg}s>GsqRH>A88s{6}PiB_N7m_)ViHa_z=8u zIoWjjXsYf@Me^A?tFbho-_@{MLcV%+0bWIEHZ(4`GoR^^g=m+dTRbtOBc0&#bGg9Gh3BlSXn&~a*?D|)K%NaA*%q0Ku zc8%kQGQ$u3^#vReE`!u=%UIZEzo$CLVreDT8^?@d%UsT#e?3ir@j3UMM0a^6YEE=B zb{crHwpk52m&qr>fZ7Ya&xevHyyQg3aWzC0H2_aN9g)$T`JFUZBS@l~pM7yO-?LL& zV27M)RO3){ilG`g7dQa4W!x_6&mJf2UZaB*}OTVB?AFBzz zYz2sk%nS`qrsgQa23uxXRg8hI+AIK=$>^ox!P@1XCwVA3+`PY+CH5Qo4rf}ylq6ko z_lk{s#w+SWoGKVHsltfeq(yuS%pDY29P&Zs$=tmL?DVvx7{%L&!{CRAT?ib05m)g1hAMfRx_l3_p zTTW(>zew)K^W6P+;sPWjf~x;o4ELas`BysOQ+Rue?^9x?@B!-QrCjrTb+scD3D1)+ zHsUB=GR>&Qjymlzk$acfBa)hjbn|mX{Ap9tG^Juyc4$`SaBY697qHJ4Qloz3@Ast4 zl08zP-@4;OtYK7H2%L5GSu{YCtsm5&LGQpK9mB1^81(Xnc9Qk@=7Ts$&n`?zp(IRo z!~AQ3 zdsSXxb9o`DK<^WmLF$*3dquGIM1j8E_jJL-x5mi<1>@B{d(Y5ualNv^B{Z?bfw`MS z3R(7U5{h^?FsGhcxEq+F;fF;;FI1K~zcZY)L`yZ=7X#7OIcS?QMZO!lSLzfHshD>* zwNH1`q$5oekO?a!?CZA19=%T#e!T60`SKT2`|UK+e!s|I^<0i=+jyE8L#`Z1&bm1T z6)}d|z}EB#$+7o(e3TMFAYdl0Q0uiPgczSW;{Q3pBSun0xa|I^KAJ_Iy-O z9bYBpk<#Dd9{LH*-nXc4$aT#wGf}w~W<p^ zJlp(P1W%h2&QPSTAy;gpVG_A2KKX!3KF*57qX9#>%Amkn{*j+eX8>v6y#)k~9qL|DNEVhA?X&UOH{L4TK$np1xjl?B)Ql3g{7)RgeUDZz66(==&9 z{@p&yz;-G!QfN#Pk_?_GxGAhGfe5y5oMfak(=JH0(Mz=F-ke)T87X#IsH`xPVUm6F zd5{K{ZH_{A#<$PLToc0h-=0?GTfbf$kM1-3`Qu@EQ+|Sut&FZ1Xl#7I9>&vwRBv8` z0G|v4mnZfdTe`<1F0oD@Q0M7B=wI+UVJqD2%khyP$gF(;4leCO=uE|}VE-BK!b^L>hmb;l;y_m;>ZWH5nBuNGHse_b!6C2aVc zhp2Jv@i?VbEx007|L{(K=~@Hvt%)K*3$5>SU7q}Av~fVk7ZX#-78gQ z2fVA4nIP0IdqIS`y}I#A(>7+AouIN)TeM;WO2xr;@KIPg?H6`!t#E$+dnh19E$!jc zz5c@ZGA<`l04*)e%CwlY{A$p3<&k{RkP3y!1?YRkIDzy1J0yKL|0=deA(Zx(n$4IH zP>om7UYOQtg*F<71&*B$^s)|=Zd91)aC*~Q(d9eG4(ap*Tka@E@-_msr(6>~5+;Zg zaw1@RBcS~ziAQY{bTw01YCw*LS4TEk?gnGRwTDgV?(^M9JE5ZSH?dlIjlz4%8y8jm z5OJWQym2sp`a|t?)_42DfIO*?=GO7&$Q)J{m@_4abo^{6hnSE>RAFwd(A{4s{%i#w zTRo$B@9w`@*seI&Ok{CjcI4PdMIg}^>I!<|eDgL)d75AX(qi?4Nr|_nW$-DmqNO|@1 zQMq}(+T6@L;$!BwAO0UPAIF9V9je1lH|BPHUwi+RAOByUjQ>GPxaZ90{6qqOV!U@A zwziTUjUCO#WWOjfJy(8mx%Gb?8_fOlC;iqJ4XqvjOX3j%l!<}Qr>V~8ZVsi*_P*FX zll1*?_%n8-=@-f9R5dul-HMpU==#0aQWkugyic+wF7-U zJ_QBFS9My8ow70bpfS~Uig_Cq_w*M5NbnA5NOj&nYtcV-i{#SwP@*>HkTPPQX=YL8 zpB)=sO08z-L`~Au9^D=HRW;5ks!23ui{YrJGLNkmLvC)d=y#fS)!&igxKjKv2OO&7 zg49whxHVF|`O~Q+QUHXLUy_)Q&%V$_(_Ea9#}ljQPEtlu($YG@Upzb|p@@V$MM9v8 zO4G!T!uP-Vbom1j{mU`yU1UzM{CWD3*!?d!xjvgdT4QC;0pR0EC4tAI^DD%J9rKXB zzKBZ}AODQS-?i>5iK0bSB?_{P7VAPczc(Yxhsk|~-)GtoZBC?W zh2Vrot_f+;!wxSmnFX}?5oH&-pKegSpk-q9tbcL**f)rXri`*(j~N>w&Y2O>lozWi zOEiNM^yS4jQnnznoVcLwnZT$2jHcw9O$yR>yhvy`efxj=VV4e0hI%4~A4t?E?Hq}; ziG|{^9RXm?9o1Ud9ysxmi)OT>_3-DG$OURlPtVj+K>`);Y92in>IS z+G)>9pbKn~Je(ff zMn}K>tMmV*Euw}oF{LbNUuFO0+@^rar=zlgCkeR*$mHa23IxOG=CwFWCoM%}SRavo zl@13Y3Z@28ZU=PiF45qc>GX#yh!lk?EsIgVNEUR>n=bB>Tx!=c{H;MEEOVCor+@tI z8?vat!>!-;{%%tIaV76BG%4l{4mLq+Qe?p!n?;b&$V_%t&*rI%KWKAu(!v+%2-d;jg9XTJClNl zb~db6H)6BA+KCKg$5sRs2C(IoMt6VQsOlRk(A=Tt89J!v{iE0}E08~4&11-(?Ih1e z(Y7IK;qkzpag21kN0obj2c|I%BeaH)lMYX8xNHw^ic(rwn+gc5J)3?Og`+g%?(snQQ-1peGKKTW|~{z znp?>l0+Gx5)%5A5$K^H5U#uQt7)%nOY5mn@YtDs(mH0=+_{>gtuTxI}?8Qe|96UK` zn3J>EDYmT|Qom`UKIneA&q{Q=@`G~;Csv^_-1S=I&d@HHm)aSJ%1ctTQS|4mWf9yu z?(8B8temh7nVxOUOmnuAiI1jA0FviN$4A=NPkR}KAdFQd0>5yfL_brLS9+ zlh(}IXNqMi_L`Kt&_TN?Od$j^q(0j{Cf>(D+xr$T!T?IuJpb&QHdfVhH_+|lI!Im4VZ zUs<=_STtc|VXsDC=v?_ubdV0m?O!CAyYYQP)(A*$7bJdHm7tj1(u8IkLV#+aXLSqY0w8cnLJ-3%eYuBO)KjV=}$+mltB&SgwL}YqCi&Xlv!;}pn@0C?jUS2IU zBI?WRGkb_XAqO<*ym#EeQ}#)Uw45ZNEKLUqpY*3nXR&E``JHX5{^{N*e900Zhq&!s zqv1{S^joftE{d(6y-Z=9hg6rYU&g;k8ie1IvS*^@y2n*nm=LgG`X(Q!!LD!z(2v}uE=_#qsK(dKe>fc|~cwaVcyYYkndP{NN*si+6~=X^H-0A9$pju6uTWg4n^ zsh%_FJAX?>=99iJVc#5Z@48D7EE86H)yYF;xS|~B8}-gLGSlh6EHRm}-2T(eFt8MSl0#>lQ{ z8ZKPl7MG?poi#uHGU?^N+>8GC|Nl)CCVy%8-`}{uRle0kFY}88+ISpoVR7v*xiS|2 z>o|qesa&*(PW-}YBj&;OswXvty~16+XOq+AH6c5JxU1D~@rsj4V5o`antYTStE}Qj zv3{MA=?BW+XRiX#tm0fw(U^;NpFR=gS`&k>EaOe=a77!EH|Zl~RhEx) z)mJaS{}q8oQ9k_~k%TF|jQxzjbs+2nV?JrERBa`g5_tW1TpT8S)T`)4te*lGI#Z|Oxr z+i9V%(vPtsGxxm%^CpV_`T74u=I@;Z!n}wVPZ<@g3DDE9!mcM5GMy`eZnLa9mW{VW zh9?&tkKs8G>hbDa$eB6a!eC4J?DAUNef*xCF)ZEUL^*SL zRGn9mGyus0(|B`N*_pwJ4CvU@j5uuU_w4quJ836hB`iVnKQJv zZo0fwXH%2YT=%)%hRSwU73?M*)i`h-LNaqAZ;w7I$OCDNU(ojz5VP8=+OG6a>q$SY z5j>cDQFg13T5$R`jSmHi|5Z6;wf)&P!l1sXAg zOtfb-tQd2hkg_$WkX321D0i(;5*IH7w+^}qPFQ@ou>XA|uE5V984-${$t4AQ_eY zMh7X%5Xg?VBJI+x;;cer^y!A|*k8N;8b(#{w{zjf?;)g4C3!sf+qod~NSiJLPhaV$ zFhYf3{2`L3D^bZ8_lDGFO}1O!gXF4zp7F}7ABz6EmV;WME^**($sTUI0-Bzp1z}f( zb#zvH4kU&PT*Niron(WeRF$t^%hRuDt`d@D$zP%|v$(<9wHcE;uVdL6`pp&J-@H}j z$tASaJU!@yqxste(|=P!%Jl*s6!KH26T8I-KZuX^&HRsVYDumWfxF;s`DwP2SF{^* zwHH|l6dwLXY3QPHc0ECTTfN?HPC+$C-;Pn0bxRDt?xYV+WrYr`+!SNxnmlzBIjwkn zq*?tCB{k^?6YndARSqx?ft}H>u$maQo$)-BYoW`)VLH;USM_x+bY-{h?C7c5<9J812K4x_Sl_bZ*B!T?>)S0 zVPdGLSj7L%Yq8hld@=8hg3||Ou+T2x9T&h^S?T>*s3p0@dq-yRWcY&>nE|~7X?LDH zafU2&me?;yosO`dOE@2ZeQR4U6ki?!lDwYmkNkwB)ep@=pnmKifmh{GG|% zrr^5*(}Rt5N${(#`E!9~)h{YsU5=pbYurEYyXb*O*z2cF3_C-wUzd$;;ZoQ{(pyvu zf*?>rAf83b7kw^oIy`;GKsZ+>ca0PWPDb&5(Pm7%-@WE}%Q#_@ih;%8n(JcXR1>?( z&P$2^))_>G{6a(+ZK3dnpD+c(!E`!h9XYmjs)@iJQ!k+q%o3%eaut{kH{I*b??hscD{Gx-bfYY& zw(HD^UGF(ut(qYy#wrH05wC+4$=uv9v&uXUMY)}N*1OHid-y~_R zS?lwIYN-+~?}(r>*+)X*%iOZq8^77NEicU;B{Er4=p4mm;lfeYnmZIDT?@Hnto8~;+djNc@a9!j!Z*5FZGn!?`w?9t%g?9d95 zEq{ERKzPwv*C0%xr?S8&d~IQ8Pc_K0>h$yPHtsRwmM{PE(*s$blOJ}z`np?pFHC}D z5EOm-FN`UEvuWC|G;Og<^)MA|J(6~!;Ofboe^e6J)z5tLX7GpHZ?^pSOYf$|F0=yS zVqpdQZpldJgTjI9@kyR6ztM%A)NeNIF8R@i)ICnW><fl!|Nb<((j`iKI({Ux^5?jPF~tc#r?tFw)-H_$G8HkcH$=R2AR{HlC0BKHycq{{kX3-K2mxSbw^70?5v6zF zS357?=xi8YJ?x}*Tt-(r-e$3`MrFw+QI#x@wS3o6<3J+Kx|)0B(6o+Lu1l|m#(IB} z?e)s4UV1_je*Rv<_~gZ>W?$RY8_74+%=wN*2YD_YU;TLb(eB4phoxU6*H4zR^Oy`7 zK>;ma=cKCkUs99Ye9?OaQ=W28zmgmj@B7T7X7?9KaKJ86mQYWoY8L~z#(cj}t|60?Bb515>pNgzVWwOYLdGX+`HoP@@_Sp|vf21FqgoBhm! z`dEqrkrd;|`*psWfeGU_Ufc8rEHC$m4!Sywm>FDYWH0*jN#zWKZ_T>unu>}>jrgsCmA&qf_p zkokuwJ8Y0Sx+6p=g+Z8#`rFDhQdZ9UfossEW-KAlBHs_57L@Lpe0!9S0UfLCK!bpq z3JQuc2vsZB(4tSe>W@-ey+MRX%5_WGq2^Mtj>Zo5Png#BerhIw8wV4st?m8Z79^1i zyGsSr6TssdN6mvfN0p|%NKB${#FzLwHt_oTuBQ#W&MOZJI>ZiWaPv$WpQcLG2>c>p z9(mnf#xt)}8X2wRl}TezT>K{d-mr}uRpp-kH!){Q%2P1?i^TWS=92>j3B&zafv7P} zuY0|+q_yfpUJ{cErBpO7*>26Fs;t_l>T&L}s+~=}2Yj6NlC&IZezkVf@(YZDEc0>H zfSKu7^=zt5-ud$@J#7F2z1`CiC%>m7Mitz5^S`MiT%mTY!|P8+Ru?6*QSN9G8JNaf zNRk-}K6>#;1C6-s!4qmR;-9LgJzPW(-1ei`?=Vne#30mu1D{{+Och;v@Xdj@EI80K zbz$H~a*lm0HRbAYhcf#&z_qj@HJxFYMKO)d9?Crmi^s$W?9 zyi)gMeB(_{yh*j^(0ayKx9)CaxPJs*(%D3{4;hqMEN}sFp4B)D1ldnIF2B>jr)u+S z&MXpH$3TH`0Lm2C&A?wI56_{CC!4YYY?Eb3Pl&`f88@T#xAQTs#!=R^Kr#SWn5_<- z*L}D-4K_eMBrTzqi0RR@N-acA20uJ_FdWe3!p$pvPrA2 z=5~p57P`kn3;1!E)a+@#NxdJ}FwcnkDrdsDSW}FY>R@2=cie;j@?ej$xzh7We!~O0 z+V#m+E1b7;QQ8~(?x++C%WAY6yOD}7(w8zV2?2IiYF<`!`ya@&N|8f2xwc^1K&*Dw4gECrzRtzLbeU}7VS8-3+4s2LVOEqJ9a3tO!Tu#j7u(( z9+gtO6ytjVs#qqp%4d_*BG2}${Ic*kvwtA z7Qy`dMH1o78oIM|KcO>~&Z|gV#H)urwN`VpVsDdG7 zm2dPIMYuAAl@**oOG`@`^YPy`@2RWdL>1kuXHD|M(q{PToz~YOIud;XDR2UB&^0Fi z%zCOx+Zt`h#EF5Mf^6%G`kS&a9m^-vhQ!&DD4xpKPPi${%g~SnM=%S=Aq&eU3-j53 zlVex@jN7a2D=oOig=i%6xyKL~r}4asj;7lt#N+>8CbsYPpK9tv&h|$>U6qM!okSn|4-P z3D@`v7aeAMyV2J|Vr}@TbUu+BfY7`*Xeftuy!))Xd*C`+>j1poe+ z)+CvFI%LopmT+%g=ldcKFNK=6u>&3L$9Z-?N;=o#I*gjAo8%96b&HA$0C^0zkhuiW z;PP`F*Kazmkv0KwQhjS4M-(-EoA{BAl33?-^@7Ao z20=DlFPOOK-9?ip5^`>DthPYELu-#qGO{04iWxICk-kBe>#Dz1V=8@IwPcLTJEq-p zj0y%h|7hx6bWT*Mpb~u|>Z6%YSTfdw6(5$KU%RZK)moK?+~NDW|L%GLvL?m#FhE~9 zXLC=z6Q}ZYlA9`4M2-9D+GI)U53F)hm67P z)0mYQp?#K1z zG3?fDp?#APgcsYvIw2-^rZxe2(S$?+B93a8rUs!D!-!L(kS=s9i|L%o=B}Ne;&ONH z{cyObNf!~1m`=7)Onh3NR*>J2OB(zH=l;d zrc-%qC{_`b2v-%*T)vvfZL%MGAK<0HDpkrUvL#DLI#dI4dj#*xv?j}ov01wloixV; zhr_K~o*xlG(5)t?ka6*hTW)Tg2?e7${6+IYLcszS!$OqEyWcR1p*-D52yL2WWmXeg zxvJvm_Xn4I23~9Y(3`k79w9%&xw8dgg5$w6RWcptK9r_oQ4;e50a7Oz0EJ%0q^*;U)L?-=>QD{@h1(KJfaZIlz#lxsyO?wY-==Q zEFi`@p2w-UQ6YskbXUmB*~{4xTUQdCWqFL=Hb}$9)%o^}#E6H+8(QvGekU6zRMyjQ z3O};FcGCf8;Ji7L&aOc#UP(}C$HSavS+meWOl`_s%@o~5Tr-ugU`&{((Ms_{H;(FH zXoi6ms8anZD8NMIAvE3r8a-Ig?)QW2KH+HpNM!hWt|juZ$s*ORShCiHOiC%?qSdze z-Y6*$li#1^s=G$BlBzng1^sk!H{6TcMyusyJ`FY1*< z<(~Q>D+qPOuIx)X#>k;8Hf#7{_OgRnRs(J{$4IO`5?rKCZQ`%+9GPg8m)Jrj960C` z{p~yNyG2^5jNGSrmc6F4-bP5Hw%2=`)gz{!E)TWog{u=i+qEDJ!kqCaR%AEQM&rSo zY6U~0YRt(Yzv$hRp!L4`F47+LkgnHWZn+u27^D$2+cP45(%Ol;3g8vRR;P6IhnJz& zQ=gwkCZlqoQ~NcQFY$&f{fmP=5lgx(@FlWYmFNEa`?~6&` z9jRW%lBW8Ez*3~WQ_TZEm(9j=3~`tUAYME4H9Wa?&l6RmbB?+S~y#+xtIl^&my z7;q6Q{L2KmVaTLn--`Rv~9RX ztptabusCS14%7?6%7E8bG2HPB%0E<9Qi*>IDmTT&got`h(BdH)u) z(!`w=ZRBn0cHT1ym>T3*9Bm$aHu2;v%7_ln`{5g)AR?x69+J8pn_c?M++s}Bu;#v+ zF3qruZS}00%&9v5=hf8brn*JZ@46&KGLsZhjvAB#s;pb{S3|AK8kDr(eC&6un(W>m zeG|yfh9#1~y|?E$@2^jR+nFO8)T-vo0=)Q99ER1c>?)#E32CG3qd5M)tS&p7rwfUU zEsm$%bpjJLE67oJBA*=k=q6wwX*WEm4Krt;4KIn&wz$HAb?2xk4J)Fdxgp_DB(|O% zF0c@#%_SAWOio2V7w1bpbR<`Kn^BhVCUr&8d!ku$<_o5aMjI~oW?#26$#_Tw4%NWP)BqRBH*yG$pw2a*Ta{FsJo#2^&f;ovXrhZaqrFasDp3cS?PYQW$fwjl#&tYtiZ`k}hpwP*xFRx5>9r1pbEt+_s)WUM6k=bepeBlrj2*J4;2+8qLnssS>QX5C5t`_krKdtaWSgUVwWo2*o}10`GOijzvY z%w%>g#?{<-e4|IjhCWYx2+M>@9nu>;vcdLWRaR^@1qyny#ZNt@{fjF0=nHPG zXlNS9$wTUsXlqx*P>L_@<#yzQrr{Xc2T$1iOrH*~$8l!{f3c3|Fw@73;KO=OfLDix zrrK3%@6$UNQ&ob|@6^CVR6tnE<1r9Rhs&ZBEz{fKF{3tPN?;_#_O=yFaZOS$uos?3 zOQ)f%ST5%r*cQCnCdVV>>ypcyk3~ta>3xcd=U~mYVz<(iaghqT&0Jk$mO`WI>eK~| zuNBq4=uz3|zxJV`UXnX>NGjb)ji+b(q^?H#GK00SVUMU|y;x{hxiQj4RqKIIkxK1j z$?%#%FzZ4^Wj*bPyB>0>@e*;e2;dMu#yis7*IT{#8j_SFbW+5sy>7Zd|I~8}vOgdE zt@nJW2pCk<8=Q>doS0+W~xj2rdG%2nw=L_K{JJ^g(x`SLhNWCg8|H zptE_aTb?M>gI1gd-bUei*u4Ucd_xOVb&d;KBr`*^)Bf|sJ~ngA>X6PhVz4vMa!l8! zQ(R_Xg@@|>Q}-~9cY+&ouwley*+EtnS`4| zjCpFy^W*W0?mpwf>xz*1a1FLHE@K!p0>?UKf$?o?NPPXVKgA?M!&s8I_D+a1&B%bfN|vTtIpYP&n8rR~M1zBmi)jzxYpN9tfd z|Ie4JFE8~SzaQ30?6X}?{YCPJlp&%3zbv??{^G5~GZJ?h9--mIJ=&9e+0PU-bckKw z==0%6-^o)+7zU%rgifS7gAj51S31Jd0R(c;fev$`PpPt|lc zYMhDO)S)e(M%nVr$dZSNW`=Mbn3>R4&6{EA-Db0sY`!4bZMC63i>}&ld#8l z8Gme1$HMW9sbv-m2RVbtxNw5s&0|Vuk^lTi@89?OKlsgm2N>kBOyjtEY@+A2nn$im zVJ)Y_!l4f?S_AH?m_G6gd-nnwb2jyuEJ$?ZFxygWkyJX+Z}3uI$+$D9lpZp{D^mOU zH9(>->?zLF$##Wps7gBS=S+BtEaVZ7=UkE6k-Rs_Rm!Eoq3~Wt?=|SfXJ7aIce`Dt zr%5dy9M4JQm`pA>11iRXFnlxQ=3rrSEzj^$sTB5MM^t)G_awsL+60-<0z73HA#ATa z0N|(xv@@MKt=yJ*-1Cd1CG2cpN9Ww^BT*oXghV!l)&ORF5xa7dE_}Lj?Tjl1f{xa) z5D|;I8Oqfp&?qvxR1AIX5J{VdS6itRr(O4tTNBG4ZNVQUEK)3wk927Z>J<2HnsW2u zIxVayCw%eb%_-10a#)#|Vis9JKScF-tG!l$1l-_SY}EgY#2~E+lRO6Wr|qz}Thf!~ z>}OkOWDy4~c@gJin<3JN(CDc7@#pS)LAjns=APUd`@#AS`-6-_bC<&ERpKff*e5;0 zlA4mWjzlIkZu5z@Sn_GHF0|kqbVtH!s(_;!kJh9=-1jx%I;MIn9?tJn<@!)T^~_-_ z`D}+s$I7~gBWjy6oxFgE?+SBm>FsM30w?|bQ{|%`0Kp48>kEbrZGq`-FvLh+>hmb~ z&ABEO+*HYP7Kz!)gA&jX&!hYKao`(zZW*VV*%~$5R$lj%l%V8_i%$Z31)N6XBD1FU z1o-6DO4n}iuW?{5Zg_L|cPVNAGy z4vl=Jt0nMF2<>&3Y|&So-HW@QPZ#ZWXQ z$yrU`9jc2ji`DEH`OeJXc{lo%C~S=wOi(?A z=(p(Zs19|i$SNbY?pja`ENc;*x+jBjU*9Yt#q*xNOr9-$23=My>VLP8FfLcc+7BZi zL-yC?zM=|RuidNN)K1v&NWmnw^9lyc>3LGwiBPyALkZ{cJ67F=jrK=VN7!LKnW|gcs*J&kzTM>>m8yl79&JGQ*)-=)iE>ndDry& z&v*Fl4VjpO1x zxJIuJ0(uWf1A3KtmK4M)IqBo^fYd^#dUBB(73o6*LDqSt@1ViLVaC)F&@1+AeQYRzA^M+zfq**sNUbx50uFuISMQQ_)uf z`<5!v~7IzF;bRMZd;Kzgg3WgNA5{p9qH@Qw~7a zE*r5v7z}ieDlfVPfz~px%vot4u?<~aDyH2qOF;~%71pw=P%xAvSkB)$6eX*sLrw(g zaYh18bmdDt!U;0faD3nUK(`)?i6ubfr-7Y`JMGb*PyvNg*j%SPfaLpCT;ifKb`)+~ zL3xYAke{KTgop8hsk&XzNuUtL#dD^1+Xq#d{{BrH-sqFdCmtHyv~e9zhESSnnl>#F zp#Ra~os(F*unH3)=6;aK)tNxgOw$p#k0!+VTcrA-9RTQ(Q0-LWnUKJpfDB~t|5zF* z4&z!i=~m;`;1aY<7~fN+BI$j9O{oNHs^c+V@UZhb=o zLU(TKzi%Xe(j#bR)52Wlsscp#$JuER^^rPm8#n)GbO*ItBp(3?(*3KqNH(5=K zB+_{q1M(KMZORI0XPm0DZ-~UWVA&_fTKq;8k*r%=6@D#WL|6+0Wi8XlB9@>#?Ax_y zubi*B$GRb|PMx#MN!T*w9f>Nl9_1dQr09(m>2>fO!Q7PBfI#CcS@^)I7Zl=m1cBi!cm%J|E?Mo} zoav-QP;}fbEy?3FQM;n+=Pda&1Sd=At0ip{}Ax)8D=}`TKYqvIMm|6~8wCxQt zofjVtDy+I)cqsmDv8UU$TdaRsFYta3b;N*7>z&yWM7_yVE$ryc?k|TOkFJ=rCASwP z(ytP8lD>77zmVwN7%##k#x1O7vkNp|4%-7Glpao5Fib=4CSk_T3~ouLumU*a*t-gN z$OCTO*HEtwzHTFE8rd6je;GL)ao0@rApn^!Jqm_lX_MQXK2(m!Mdp!_=i8>mg(7G& zpV)E*c_K}$Y6ONag*{y_=o@DFbQ5yuR0*S8Vk7udBeY8zBk*yAR}=EDt|w~otSkw@ zXZU&1f`;AATi(Pxrno~<6yX=?s${AWiVy4Pl%xQ{lJ)r)q-H#F zpHF$_tM_Xm?}ii&%dH2yUTcW%45$8~b-UkkVtm06o0%sva7Fh=IkFu!-kr{@t{nXh zU}7nQjU09wp^C@vJx{2}^Ct5+i_YAXw6;!|ozS=BN722Bl~Ys~1DXKj&F_n}vhgIf zjw**XO*LD)k%8qdDJv3+d zeaWImadAy)cUhXI&gaL+9a-+o&X|uJ%iflx~)O{INM+wFkq5x zf&dXs&Nu)9iDZe4$XR3o0t3bolcNwxe_cx(Nl3n@_Z-PXFyGizZvubr3pz_KsvW+{QHX{>G_Dh zVa>rqxq$jU&uy?9S_4JM!|_%Y$qIUp#e6K1Qp;H0U7{D0Nl_lFXrduGrQGLzm(Be? zW}F`MwPM?Llq604was?#oHxJGjM?R78FRD&X3TtiDIvj*Q~J%P%Cx@VC8Z!Aw$WU@ z0y7<_>hI>$GI`_=7lgwPzE=Fag8!W&Ha837#X?pQ>sfk=2r*K(s7Yj9m0p56+VLs( zjQ0>GZ?vqAkHig6*-~A*BmIYroyHBWIzViO^&lCT-x!Auhx){e6GUb@Cwvt29lst-2DdOjZX-390MmCG; z%Pl#jpGNUIJnUMX9WDR0)LZ)IgD@p?=n$_3;D*`9C$$TFluyB@sF(I6d|XS#s)dagv9myZ7k(M6&tT z=vGb&f%~iHC5oDz{O8mPGss&nik@R+D`$5bQUl#elWyPiLq5?LlHK5ft=1L&oP8>r z$}CgP|NE(~{SW%;7$CwSj=nPOaotkh-p(%fZr%P4*~sZC%-B$i`AV7TQti2x_l8Oi zw3!gSj?Fm1;HdoO7tIfn%SwOajYxc2JGh4D-4O6AAXaaMUh8uHX ze@EPoebrRp`CsbyH6DC064cTZ!)6$ajBIUx_0F7jaU^dM$lK1}si;J+Kl%6RkdS+D zyleV9Joj%ZCJ*=E5ar+Jo0(trUN7w2D%zJl8N8kS_|rjS!TJ4jzS=ibN;R?{|NYGW zH+)~Z^EcJp@XbRlfuC)E`QA~cU@A04zDiUqQd3ca0sqf`{;z;S{uwsmq|kHx5k$E@ z{m&u(Q!jgtl1P3XF@C+zP*ICCzc!{Xvr=epYH3xM$Y*4m+Lfpt0k>|i0f2xvl~%rN znEPGI9j~Kx`$utMfb9^D;P`m<2M}AlP^YhfXsI15OQyP1hWJl6x`aD%rd1rD=Gvvt&yM zm!s*xF$9XPU+Liz9qqmO>^k__DL^JiD3rW$EPODhmAclc8V}!Oh?GSEwX?oG-!o~R z=(?^+LNFpsGA6Ee_hgU6VA#8$Ir+oP%(`zIXq8Xy_=Nat-H>}o|vT3dXIMobxmc=;Qh0~2!|?e zuwq{*8BXC6b%SR!Gb&q@BAMeg2BGf!E|xyMUbboxH|joQ|4mh$(dit#mlxkhH1QP^ z7kenzo&I!Pq0wzoz+733ji1S;O0nSv;>$9zrY~Ghmth5V8oXD;{gvZ}G)DwW1raB}=vtuHT|=>mvvO2Gc+|~^SZ>Md*bIN^}r7X z_`;znE1FWdn#t2@0hFie!2^eR)Uc&cC zm@Q#4Inu4LJ8nvdtwyMcTP`3nCnLNEn@=KLkAE%Xkrj_LL-C^oYxmIPAS_&%M(~&5 z5Xkl)NI!vuL5R=ygQ>6|1n&tq$#|YDTs1Ora-`|9M^5FPk?{`YC{2Jgb9J1IBUHq5 zxy!ZGwJilASQP)1v26TiBEHtNCQZ2vv}Gz$EC-7RR8((;l0fKhPKw8z4==qgtglEK za?(;t_%{Aql5=ttcN#p`UBOmvbx7EN%j}Wz@52XvO(#sN1YvdTJjYv*C+|Cq`o%Qq zIg1<0(nE*4h&9RKh}|&>h}~*B#O|)A%TSQmOM_e;Cws&*Eig=@&HT~JVVHnn@Ywoj z=90G;XU5RJye#!&W9b3T*j}Ly-;}(l8xZuS0Kgp|QQIZebeFubY$x$m7`QucFZcT9 zy>xd6$+d%2d>EbX<%g5X!W>HqMb}_4*t_omDfFpvRC4tLS>H9$owzfn?V7>@H9Wcq z@9c8#dwvdnxY(Z#^I4Jut&zS1c&SrMNgYBN-B`)OajW>dozn42d1v>Qhei5|Q?4i! z(qONXL}z1%SYwB1I!O>$2sedy+f7+5cCG(B-bliKLQ+#Q!a9*4k~K=mn~A3hW|0bw zeeDdv8k}y~!d=wXgxw{E;cO3dDsP_NDD-8Rv@4L7WUPJ;N3J?xzp1)lBj@fOg+9J) z?2`L*D{sp@{Y{)lpzpA&D#@(9(zwQLqS}0>GRIuj?mDg&2};c4Sf}Q|5vw4zKz^l{ z3|+5y3GeuI)N0}umZ=x$T##F%%Z*9`;+`ASpHGi$q-oj#g(3%5#DyX!%t7e0yEwks z)yK7v=&64g{Qsn`Ubf+&_XFJkD?BU!?fa)W!7AaA&8?lSr3F4~A)m0}K}9MUI zqDch=GLK3~ATtEr$S9OU5(wCK8IPsq-R{`apx{YYiD;HY+yhUT3dr7JT17dIINaqw z({D_c(Cq#g^*E~{T}v$h3V2?&FSWF%lHj)v)4dFSiUvDM7M?9g?ASgrV!uw7^{>i( zVV^e`LUPWk_K&}8)%r@DwEu@hYgsl~4`XdsnKBN*m_fWJ zXkDE5T5Q?Z72Z0Y_`~CqE4NAz=B}kO*h=ccJz_26KW!23nFc_W&UD7R<&bo<8f&hw7}-Ibig>W`#Ubqib1 zJSi=@u@3HGPn|dxbq#D(5q)KSpSjWlW5%HaX@Y%mLXi$DbjT@stH8n!%w=dl{bEL-Z%)I2~r0P{r00r(lUiLBHmmEfOwQO*X_iMabb zV4RGSam=s{lVp{`&HJc=;xW1bJ+LU_6u%u$m|$&4giji>+V8igL5{(aJ=HsjMI%Z6 zo`vPm$RUkx&uR3J71I>Uzo|a`&?5sQ;GD{V)BVDIT(IYG-)@sAwTWM|GK0GQG5*8; znFzSPMX2t2uW@=cxBci@!HDAVz(uX*a=_%#^T<#lM5mRSY=P`FPa1ZM(8$W~Xl_$r z$$uMG`LI32MwtJGM9p;)G;J##+}KtE(aSeon!xI=95YUoJ4z+gPZxGY$tA~Ejv|ep zf3*S9W|rFVB$~jv=Q%W7`Jo)cI!X z>8{7-Uyp|jp>^XX5o4HKE=c@mQ}vjzZ8y2BZqc5A7f0R&Oe%W}zIol00? zu_x-sh6oPl@Zc%v@qwReFJ4S(ZztWmmFKZDTqn9uua_CQotAv0Y8{3b6-P0P?O zhUuxO#2fPshNUnIe0iBRkmgdp$M&_Mi*Jyde0nlbryF^caYG-v^ho0k3F}wdNLv6U zsbsh{Z}i1N7M?f@)u#?VGLlCAxT&+*{2)r_sm)*b49zfzN~*C=hZ}da5YRr4UoH)b z|B#ZE#+?nqq^379R@&-Ma-d2~l0wu=eNyU%;6Y?x8$~5~e&{2DS}Yj#K@krYY6L?) zg;n5N@E0dnag}=|f`;FK6&*DR_gzS0ZuTvfOQ9Rja%*)>{a~>CD1wiD(#@&`Qy=yV zmiMZXl}~)FZfN1CYh#|FJMvanCVx;B<#7EWX?yMq(|BM9K-iwiDZ%?YEaNm?Z5^ zT>tqL?ftV5oxI|xBW&6NMfD2Rv}iv_G#I5AB)OkA^gjp%1y8^qISq`OMxQ546xeeK z<%)xgoWUt^TL4zq+rt6Xw*2Y())ux0))Rw#$GSFX`>y%%9?Mie0K@(kc4qX2kSTrl zpjFh(g0ctYrTPkjDiaj_EhCt&l>j=tJ61Q*d0Un>Ivz1Itb{nIEuxUN(<J_Ud}pTMlEf`DZgo%7t5({4 zk&pHVBf$PoePZaYVH}k~YQ1Q^M`Hc2$lbE24j-;}Fk{`(l{=5RBN8k(#RT)!!+Xn% z*q-MRr@c_1g+7dZVj8FR`tGzNDAG>O%3auYV#{E-!Gz4S#$i5g(jf~*X_yY_4RE(& z)N1T|5juYRl3Ve@CrXLm;&4xr`Mk zD-JR}-u1Troi9E9?P#(E036A3jpshrJ$-Aow)OV();(B`Ct3SVhb3rHQ&2%j&%c znSQ8PeudOt>5(fmsx0FbaooL4ssmst^fMV8XfYaF1Mobq9}QILt~baZF|}rJdz`S+ zU22_O0EVT8mw=522~s#i`Re-dw1V(SFNwqxgvsLTT7okT6nKD-`>TsUmPJC zZ|djF3wX4jisFy$rPz4lj3mW^QC5huuNQ7yb%*w%_KLp32m{P2|Kg)n_3%USP)9vG ziBkcM{-P>ZR-gda&aU|M@n1JmicN}QHfG_mX^;kpQ-q-WrNqGx%~RTgi6;yX=f&~p z1e{c#kh4jQ_s1Y#7booxrNef+09wazxXb##uzj;I%WUI*@sNjmT;a7HPPbQSB_`p#k9Epi}ao=5mVCIrACV<;jK)91n&d^~2 zuQ5vYk%NVNv|?OW$mIl(Vx~<^6!mEzsZ}@37Gri!nnStePONvnPEstSkcpir26`g9 zsgI}!{dGruD(|!b3%6Jzx;Em$o+Tucl-Pj z(Rp=8NkgY#-5r?%15DYq34Y^(a4@UKfm#o{VC!CAv4QJffQVz5iN7rPV{MB3=LuAM z?I!dq$4I`s6Wq1%%U2HG&+JnG?kA|MKb*<`?%j6}PEV{}h2dV`v(#R=_Ih{y!wFBq znbn(rT|Mu=v4?+MTl3wu?Pw{Vp@d_Td6AdA<&W{!b7O9QQ$_UA*ZzxF{_EOi?(TE4 z$Og0}z?L$|KaMWQzxih)WV<1x`iqXV(&b6w-chcQ52R}T+XuFY!R-JY>EPM))8J`C zMa@nQXAym)m1O&u4)eA4e8y?v(y~U6XR%==KG|QTcY9tBi zn#jt{arx(Uz_jZ5#pw%YhjYK!DM1M)Bnodh>#xE z{2iC^UW`doqcwCAaWgI48z4?orHFRl=L}S>^EYgHy-fe6#LQ_P;~yAWH3FiAGp5Q7 zmt6DGx1+@nm!rJ!bnS(nQbOsjuFW_m3i-K`Q#=X&7V+d?cpm?UVs~?YNs9i>sKqc3 z{Iur@MH|bnTVunjy#qzl=qTpu{Im>cFw=?P66%gwn65eFraELxv?%_`c-^*NTlYmF zIz4pzUF@{<91o`y$T@Z&`wZQtTAAcHkCRWF+MSls;^>fUPK9|*DXa=-r|8J7$dgoD z5I@7ytM#Nv<2%A$dA$e|nj25E?Qz8UeH~8jE|U@U8S~;zB`)s#&7Kq_-WsV_^QJEDwQJdmCstPd{7^=Dln@2 zfD_>93O2+#tnZF@>VMc))SH~#6p-1&nX2nSj?6sJMLGanWBwG^DURM%rYYhK!}ps* zTT+0xkI#C82FFnH-A1GP`>3b9SjL&8YN=;g#>_~sm4Lq-O(51_|0eoQb&2%wx`Z3YrD~?^<|S^xUXjeHYwp zW`{NEQlmSoBDZl~)ZipqVcPmOoz$S7(M&MFzBe!a=c(i?DuKxl<`v9Z@6{ZqRR;9T zevd=RRZnGWzDWX9&$;<|vR1TLh9zyF-Vsts_lr9XQWr*kX>pwrmoOGW;y1)Q>hD9~ zA6;)wp&3Yy2Fu+cfw8JxA74^vW${{Sz5I``=L*)gMf+li0dVwUqNhAq>F#2Kj z0oQbV$7Q@a`iXXmxmDOV!oZSWz8YN3s0Tf{Jctfv0TdoVWcj+IAc~=AvlR+3Cwaf4 zDosGDI4Uq?SyK8AuTW=l{0$hpa=CPi1a00aIXwm)2&mjqk#Wm0`pgjZe8S#87hW*5 zzH5xnYO3^_-qhs~cm+Tqa zyL`>XO_>@{mkqkiOXz{Y+;DCdMlgho_ZZ5fOM6-n%Q&=ilF9nUz?Vqf8tTc_!E2@% zKiRy`jCe3q!1hdIzcBbk;T?R zCeabr&r|h`JW*10pw_TX+RR$Q68%GxTAbmfqK;YZW?L2SQ;Pjh1f{Lnlmf$tT$|U? znD-_+Z@GumIjP`p?hb2;=~THVMZeu*G*E&pJ?`1dWJ>3~l;m6}O(lTdbk z7qWdk0E6Nqk!zg<5-Dr=;yf)kk(2Z1X~>sbPx44@1L3je%xMxfE#zGyx zM2-u*?FWjB#Du+~y2QE!&v|`t@Ys2)>AN4}TISi=;N+>u%YPz$RqJ{Grg|)X)Z9CF zHO?*UZ>qt*^9uZNt1s1M`3?E}O;_o!k-fmLWzfE?YPxVwAxGEp`}p!q(?w7ikXy+~ zDoq1|1RFCS$1(Ywg?Fxbb;spkU!A`8ToKZvF!w!eckE>PL$dF1ee>75VV%Ac_h~GZ z`oCGe$7wb7;sui_^ z8{D@6NiB5u5ec1bj@)LX5G&qydRjRQ-eKXZpCMeZd(SW!zorc_?j)ZTcx>Bn-+~k> zw3YPomN!72If9Hcb^7J}sqd z>?Lmp$4}YZTRdfAxMyh2%xaF%y>lUGx8UExQ>S3N-F6!$^Mh@Tfy3gJE0W@@ZM*pE zTW#q67R2`|8jf}r+ect!_>HjuD+lr^(fmS4P*{9C3?vw7Z7k@LqL(Q?8fYWZP04U5k#yk{gLEWo-c8oAl^eA)Sfzz%;0(oyhYf(#EdgL?@aG? z?|HO$KDxCwR%FNkZH>fhbyguXw8Ja{G<;&z?6H8{16SW`ooVip-f6A{g=4>X*4!7i zTL_8fXMglje%+X%1H_Juw}47BYQe?x^0~wsJW+{S_Cs}O4I2}>1QwqIEx7L%*LAey zZH8fNziE2mk(AGsQ1Pver2Asd=2pk7y!Y&YE@|%Jrb_*RIT(BNa(866^}JN{Tt|b? z*}^o2VMA*r{#zQ{V5Y=p#G?VGpZ&g|!b(S@+39Pb&kxoI|K+51YL+>4a~Yd<3trEx z?0d~&b3fg>L09uVyu|9#0rZp}kj=}%R)gf7E?0;o-t(xBC`~WC9M)A@q3<`_5bd+< z+_;aKD7rG=#?z3z{oECOYIaI8eUY(XL|*n@Sfy4qKsQu7PZ%rF&)H{K+$^l@vM+GB zwp;W5hqv42^sBS~avIsSZPJ$*{3O@}Zg$w1oA$gp&&7##f9tXqcYY>6&66%}GK%NU zi>txrigOD8re*HbDw>$38j+B;v^m(I5c5YHQesL;i;(cPP>&Qxiclbh@^em1xe?CWPs=|P<;Rn%3zv|hw%{z9eZ&ph@Y9b87s)3dd_lu!!>n{|AgpTG>UUo~V z*)0*o-$TX$-}kk?%ymq?MhG_jO!|aqD42TXXSmI~E7t7Z>c@8~Fs<^(g1!A8cRTFm z#rnXS)r40=6pFc5^dz+j^M!|hwB`37kBr7Q_TlXog_5KA#vj z(#o%yT+j3H#|suzJDIM(mMsVr{A3&2l*>x5t1AO;q&tw_9`!rVZ#=UtTo4s>;?ByRVs=|TdeqEYj3a55-Qq%of z+r&;M9Uul}Y;5?H3)pdZbj(>=R!~C3qY8{7kqOqAVm3Y82yN|2SSN;1PWVR;g3%zM*$wU_mtgV#c6c zY;P9Sf!>2gRCdn#XswfDV#0`{Fkbk+zk*MyYuq5et!cMIVCeWU%`a)L=Z^)iFq|)4 z%H=TE8_5-ju8W%BCmASKOqu8kh+H%NRpP4JWqlZTZ^i-ZPA{h_n2CpD1z!5YaAWY1 zG*5KTXwQU4l!(Nl0AvcOf}rHp_n_>Z(?0|xCw+ywaF8Tl~pVEXwaCpiW zqrzB^e6fkLnp8zx{pSNtB}ZYP1ol`*r#MhWx&Y?M==7cSWl9=ELd`8tCARmv)i0+- zM)APn&>0jcpPZxPd1v9ampC`8hD5P8SD|j{RIWe%U2v4lIc-m3m{>vWVU69m4iXIA za<4~{UlL6cL+<7kcyc5p9Il4j{c`EUw(*jZBkYj6vD&x@j-V$=R;Kw1?5pY%(t4(x z_z%*PKK5V$5`Z>%;$AJFYxb$YnvAN=n+{$T#{1OY<*SeybC~Gxe>RJBa{(h1(XiaZ zBu6-1jBT2)&PbWwq&+?$?XOuZR$IV}qUOS>ds7-)vIQ{#UxTJ_#jo?sD6LA(Spy!9 z2+UgQCrzLX5Imh7DdZ6!$=EM#p**R;;Lo+cJKu|=0L;LryX|3J(%>C967BCeOfn+8 zRP-#TH@ki9^g_6LARf-JIj|)1n7kRaFgkJs>~XjQr6$l^*V5d%w=VHWG_(?E+lgr4W6OMH&Bo>G?3lO|ltrE( z?DRiipcU|%vheN?nq9gz#r|9MFT5&Fu`m3ZNS9OHuY?e`Tp2XHB6s=B;pon&7Pj{jwMWNel@oXL7dc?GJR%&R=1e;%^FH@ zb7-q3PoD~R?)6#YqsyCxZLm|Ksuzm1`BfZ6fJqjC%ikB=0r_0bcrbD$52t{JEP=7~ zQ7r|_Z(3U#-(YM7dawjLXE%TIv7~EZ?Up&3i#1Apn59gku=KqZ z%3a?|&2pqnzr&_XTI_B+hwD+{%H*UW&kX7YWC}(j0A&eez0eEkJz$r{e+VL_Ad2c# z7sXt;y4)n>$nEbRRB7x8wJyBVaXe6erh_URRjorC;8)7)8*=T_jTr^rn$78}wmKh7 z>_0ycd;#!o9-l&hO=|SwHEv1}(to+wbvHzwY?fVDJ(YAH-^g$=eD1uWr_mU_#K>d( z6un{UDc>HNH*_tEK*!kYD)UZ6l2V8BSj|7+Q}fk^uKDs#8q~u2^Ciat35;5pktUA< zkNT97Xulw{5dTsxZ~8*sPR2u|pNO9Ox3Rjz#xB;1kZ|kcN230uE~jB(ZDL@s1&j^e znP}pO#Y|$V`NHsaGyNc2^l{9{v(oi7dnn=qVZBDekojUM+o-WMo7^ux%Q^VwIHvau zLo~o-N}zSZ=pu)QCcTeUt+xrmUV>=)D!hbUEMwPOz70WA0)DabRDEMbTWSHF_jt0v zW`g@2bK(iJMyxW4rKuNhTqRZtZj?j4%ZQu~QUQ}B8^`Q>o=@$tRbw&gi$}!TY=wYN zW0w9M@f)}DTb}BMh;{NZu@=?&bgGn#4V@j$pomeFEVo2<4uChXdo;nl2DvG6lB^HX zw^26BXCi(t*$?(O9E{+RgP6sC{K`RtpCp6wa2it6CQ(2&dV>{}a>d`$`6J2sb^%Gc zQ{%+hXJkEav7xocmnEO)y>7(7OqT>7ElMwx)$)F#Bm0~MBy<=`Q#51>rocWiJj7n( z?O9iqA3^(=B%1zMIF6u&qM@Z2doC|%;>soW1XOC6)z9|p$M-f;x_Px4-s$a3cDY2M zVEQaN_9u~HKY&BeSX>E3U5t3nr%2<1932>q(Dcj-;baE-h<|yo%5q7}AlQyZ42}6T zM_9l^i62tpfR4ug^tFQy1tvFI-qCBf^Tz9H8 zF^B^pDPYIMH3vMu^*xg4^P&mb+xyVxVzvG-i6O7P(2eAz9Es1oxUiw+B9*>G-^0D5 zV(SEpq=EUrO*^-{u5ZbU^or|9a=rpETQa7-RV-#A%~Nc@G{F_WErmHQ(ty53LyW!F zH&y^R19_|P^`-TlCE7wjzC;NRXZ^6R7-JtD_pc44>2r7a4h6V7%F~d6)|Gu-nwmej zxWvn)vq)Q|<~>6AQekO}Q$Hs2IwnaochA|k@i5I6kC+~UbN7X?hdT}w*-SQB#2M(f zT*lV*>!sDqmc1%7YP1!qtz68T(py6lZJ*Xxj91UPSwRtZELGVDy3f(kqOmwV0pe3B#RA}+bGJk?=>rVy7{s^ z{`uKG8a6srYY0>wna}iXrZ~0<^Yy62V`#E37NHoK_kSO5_y2pKTv|i@duGkq-UsOm z5_il~?WBq{!kE!BUFirgiVytKKG)q^By!O5Qt`#GAsWN>AMY>b_w7X#dfjN{a#Ob} zfVqKQhxtUwyi@#=SJ)RBa}sz3ByULxF%4& zbk9EVTfO3uuWw3QrkK21oVcDA4=31-i8mE?yKgCQLuYz>)}AVpO25&oyf`xV&ai2I zi`FW>@mL;oonJ8Z68EO<{)9UR;#oZ4a-C}p&1tXaIGFtz>MH%Sd7D^)@2Au)f_+=z z1^2Vw?cX!)>JA;n)i(_dBe-+`Dfc@GJ8-wWx588vCpIIdC8c9r!!$0#%>Fh>Hs0Z+ zwD2Ff21xD^?jzI0JTVNqHWR1tifg=YC`W{bb;ApWDxS>QWnXVy5XR0aoj&)R@ZF=E zCw?t$aCqoCnSDd5A1DSrPI(CeSsHmTNKrkau9i_PcLt>R;XKh7tK)sl^%Y*)$(@j#Ov!)0F3C zkGWG4yNIic$kCPO#WeSl-a8sj09No%KBnHn87n`BFt;1nN^r5)j`x+RdSus! z0&|(ZzzfZz;r)*Z`uV_D1Cjv|<2{<-N4&r#BB7{NIVeX*v74lL%KU(yi}SA`s)$ET zNRUN^NksN?B%7)wEkBMuGuR8*%<@^j3;)e5ljdj+TXT5qf9ZnnS)NDXq4b5(Uvd=8 zP+eK$$(W_2FUf-?vf92WEeK7@6H`Evl|wAP}Uy9^%@Fz zf*@@h9np+diouMckMENLD-<)Jyl8ps;qi`vAk_s^iL0fPu0A7;Nt(&68TnD>?M351 z>Q|4pm+s{DnW|a1vSYzbiD)s((-T+nwX4M}Ptn_EGB{zwl#+Xxn?$==J>@0%AHrhP`Syp1fnB ztN74_nL3C`HL~5I!%CrbP@Y7fYMrR+~gFpVmG%V za$7s2C0R(~Y%u?vl)Xy!o&8b#ASLN_ZKI-`|8Gme@PSh5G0z*if4jg9*{8={&U35m zzTZbLFaD<}F=D^&q{w1j`PbLxrLpyU{tvzltRFFB+T!x0G!SG~XYk2gN*QR&)_o7l z_OcK6i8rs?OBlqq2^D+^9#R2=Up?dGy#GzS|8yk%V@A= z`l?m+gO*;zCE-iL(@B$rDYHy3C&}IQdw*H4T~jmjk2CM{JEX(d`+@%Mw7Z zR8I*N2xRC0@l;B!y}ykTu!tN=C)>iWD{bXGOXn(&ptsWUR&@onHusWR~ zSkcnbV*RtT;<@ClT_OgKTzK1m@;FdH%+;l(+HVy4rrj|}y< ztC4*gjm{|^&@OhG6EAg~Y>~)W6M4ce=yyYx7Ph7S7XVOa18eY6D&Onjc zn%HO_Jd{J;a~GPF6?p^(aDzqsOzPe~YmAESsm72eZbW`XmjLLIGx0T+-I9&bpZTP3hF?qQrLaR25#lS+ z1`CyGSsh;!*wk!2iUxcmR^5DOD&D2OboK2>DA2g94SC*!9e%Y_(cHOE9RKa4tAy9M zuSR)S9qF9mXcBd+7UyH`q#ku0-~nb1>`{*#9Fi$yd7wp8eB!%5o#-P8I~99qGnDtJ zP?MD(fPW8_(5anrg3$0GN4RSXJjFOUXAvv8!%j+^oi_S6h6D|N5j0_DXfDsLq{;wcKG2Td_K(%SUnFpDF@kzvw;pw=C!pTY zZk(QJjAn)eGgXsN3Sl+|Ty3)kZE@9h584>M&UL@h^vFJfJKj$@d5PgWua4)C85bn7 zPz3DJH*Jy8lkrpttHrbh7xht!Z{$%a6rLK%XnABX<6hT znhr$3-sE^bWo&m%b>P-9UN<^LHvl_~t$Nivw4NwC@0N;?bSvnnFt0~SF$ZU;_LOnh z-r1K=^IxI%&6c*(BZ5aj-d_rsVdk!#%8Tk zqc34Fx-KN(q+BYu7|omnka3QVr!2d)rtV{*kAjj7I(&)GcZFwEhz~235UOP<9EFi` z(O|!k>|7qKV$7gd7-LuS)cpxR^NMdqrX8P~@adCyBx&)3u9(w3>5-10+S+X)iaxjt z7{JUD*=xPa!G>@x@1hn&kb8!EFy=*T+qlWq26qjm#Qw4YBY1sO$=;Z6gYOL4=ZO-B z3ITxN;)K#BF4?~R5qBJWBEe%xb>Yc>m_w>R1G@hjXqi(x57lZAW~Jt?3>g{Oi~^_i zF)`+4-{By5@&&K*WF@3^Y~B++&fJQn-AQ)3qa z{Q2t+n>{Oe+oirpYw63dyUWPZM)*N{Zi8%UzO`5{o~KjCFJ6dsCO%w}1x#k4@K8`x zZaRYf0uQV7hT;n#tg?B)rHlAx&dnsN-!M&jN-hgD{qKB9y zF*1UsqJu-J@0n{)_oK}L0iaGsV0L`8(q15mwxP>rD()?@6@^db`bv~60p?T6VH(U0 zO5D1`w0SsVwWa3iq$Rzc#o^WKSYpCxGp2sqZ8pnV(EU}gQ1>!G-jMqR4RBbdgqiwL z@aQ4hipM_jxi%4mLyEzMqUs9KuN$I(mEDI1;p(WgFy8kQT{9h# zqN8#6vGQfa9bC5pUHuNDJgC+5!(_Q6zyz-I7HF2un!oWmTrc00 zB#A}7JCH99Ps7$p;FF8G#_r~S{!V0nd}|qK2i&(x;*wgYe@S;5(xGDe?qVQ);SD2l zji)QHe6=ZL47e4H3WTM;n$sPY+*8(D06CNjGlPWVN@=lU_~9MZcNdxL<8$;vo2KE`d-)Z7A<0S>H33&rjo} zhF@;hiu>*oiBVmL)r!2+{|tAmEYt4sN=G)e^V#t5aD*yH%~!EkY-{(p1V;_I){qu> z(1~f`3$I42jD-T%Il-C1MQcro`d(;T$-1g)hB|ZE2kfK;$>&A*rLitk9NJ1I35J|C zvG0DGX}@!ZMt}uF2W_%s4o@2W|EA(14jIvnXY~=m!?kl>8Ea~185*>ce#?L(Ihxa zEnh_IW+{z$d2V%mMIotF_a=K%!V~ThnPH#mQXv9c@_;N}Z!+<~HeNB3q>{r0&KqP* z!fiV4J98|_VZGLnC%u#PFKC@!GK{{WJJ8mspa5KLr6x1f#2|DzUax>kpho?}8qCDV z1Q+*+x>u=b=SF2QPBc30#dEIKWSAzVE2u~t_q!icpYc^5&Coss!ergbnJOw3XAbM- z@7gyK&+ZJ}?z0y#F(NT5vw#uP;5nnsO_-Lms zFvHDL_)=dewSo%R5Wu0hq!RG9XEpt~%@jt`NAoBFQ#q z2|V40kuXsnS~3%=_+$g=O9`8A zRV+(4i1HOO>#Duj+ESP%Subr`7v(=gmi@9mqF3yTO_%cyzP=K!U~>?eI47;JUCVJ( zTE@emXBPMIW^0*!a0xFy)p0w*^-0Y0)ga>Y`-rd-6NZ|*goAE`1#`(O?i?=B_oyuC z7(2Y=MYh4kr`)7{utuA;rs!vaOB4)1+bPVJqP`6ROkR8%MkG$6l3M#f`J#y#W~ z7ab_;ljkii+X&pG>@z!`A8!Xt>vTj#>0qJD+|yDsYSwtz-TBuE_$QcyGH})>21B|Y z$@C8iQv;%A#)dR7T#wWjXB7BZ;I(HOC?OD7Xb8R!Q~wE6gd+s>;ykZ`DL8OQP8^1| zE#I%B8rIwEN{Z&SmYO<+kxhxP&sq%X3prEnU)l*cGu1# z6^6<4+KVTZecYn+D|oIA_;KX2FRtBxV3CYA&g(bpn46~BZ2yBh;bL9In4)yrtQN;q z!9%t70`|bju*%V1vmzEcGi)T$d`Bj6NOcux)~;5sNs&RW=2goDg%w8643~LM`4bi2 zYx>~H!6W@^OJH!L%7=$!0<&LmI$8DO#l{PtHO8ViD&Fck$jg_n3 zfj4FzJKK&-Ym0>6UJy<2E5%wx&Gd8$SKSjW{T9b>YO{jtcuGvV?zYFp^95m|nOKGn z*lu33rP})BJl>KYA0WaUV>z4{%OxtNsh6>7mxNg`F=^%munNb$bokWy>A!O}{)M*x zub_UQqPhf6^KKYE$}?5(_EO}}%XAP@siUX+@bRFBa|%5=gmzL4jqsZLB3r=`jd`Ue z&|;RftFl9NjjP)R_aLrkcg*he%l^G}wI$Dn9}}+*wZBolQwWxW{$eXX_pxYVYeuhw zG98bNQiR3x$A=|QXz!tNugXbO*L9uZA*Z`d)36vX4M!2}S;wjc>Wb5!?k%d4k2|)g z=#%rCD2|n1v0Ov`#0RPOT>8i~pZZb7Cb4~)!==TTY@m&A8d5-*et5?@BdB{{Dt1{IXZ zxI<3}QIccW8HSLk)k(uRO}{jyHl7H8R5jk%PPmAk`BcM=Z)yQ;KJD`lW`F(~OR56z z6NXakd5K>}#iknrlh*v?`gD_gA&%lPTpb@>Y*jPZ-le;%#im^pZR!%p-lo|^!O>h>z=gLh2% zxx8fXrVo3NT;qI0D$CX!i+-PC>Dq&UcsGfSjIBG1wv&wD6?jSX-&CHqK1l{BdaWN- z>RvO#L#~=_Vx?7_9j=z6dIq*0=5c`K4kQpvD__aD0x#@CtIpc1&hm0Z_rJWhLtpu= z3t_hgV}WS4Y;sb)R$P-4AN`qP2B7W>#8_VI*!1-KGT@@Sb#RZMW}dvx>FcF5716oU zIg!HHd*5#kGTHW6=LAtoT>mBaW*|d%s-ve8XZx|Fmk=lz&-eZPi_jyO-_Ar~^H}9} z?eB1^zv4{SSDLL^QgSB?aDjrkpY{}(%mv zsz|n(nfxjaC7R|e6}7HFqgqV%clzo6vEk>B=n>7(uM!_Bh`)bMp#7M68-G3U_YpP~ zl2)1c;e%ciMpIx8Wx3DCXPZ(KS#(v%Jc2gO>9PMCd+#09RQv9EV;2;W-a$&}0ZHfx zJ_rO7dKUsH-B5ybk*3l+giu125_*vUp@T{dRYLDl6{Jg(qLVY{%*^wyb$;`nGxN?_ z@0ywYN0PPfBzv#>-pRH1b$zeTS6fiXC*B$D8?u zH+0f>zmHl>bY9jQSl^f1>*PfDe#}l^6EF=+H319=X z*YoiMd85|Y(>OoHt(c=|qOkSIX$ z_8?XhcT|?iRY;NV>DBB_<21)xM(nC9O~FO4lWrzy@0pRS=k*KPnwaNVbSBff&uS>- z^+taHDW!jbvs%m^@CcAjf9$=yJkMXyTpG{XpHYK~pWZPOR&VjRD1*eiTXK-5mOqM4`vrA9Uo*Z8y)b|!rjJ5 z*5%sjl{31Eas^aSV;sCBLbKOxYjyxRSxr(#Lu5C2+cxA4ijHEUpN>11kwo|}UdJj} z(hwn>iT_s7UcVCZF4fMCqS#o441U)oE{L0ZfPf{NKI~VM==h4L{MA3SH z^o|x8??3G=P)7V9|Ofc=@1Up*EVl5YnUO&%Kw zUkU`tNzbKU@=z&XFfY6zJH6N_VoXQeyq0Y~wy-KwCqeK+^(n_eE9{Q?oH1TFt1cj) z{WNOFvi?a4J8;k|f)@P`zD!L8V zQ0Mnd@)h$d?3LFJ0kp#x!_F_y+7zTD(hXwQn2gD(C>yP-HS%oVe@tY7$I%t)!lhP9 z44_-+fKCLoJlY1NcXo4W60F?G;#nrhLC^+6E>!!y?$hn>Z|iCwJDTz!%{6Dihe*rB zRwfe@BbjaUjOJOUy$8x_FD@{5c%^21X1xT?Bbs}igsh8VI-4rVhum9Huj^Z1><+ma zwv{nc7nUdsc|+kBhxfN!*amn^W~Rm`SLjfymWB&TJYTJl6KT#6kOzcZLdypI_E~Uy!?}Ua%KTYK&U@mr-5>`6ww~#Rev`IF-CxTcuW8RuS3lAIL&mTQj~yKbGbi^ZY_cE<(kJSQQRsqb z4={L#TkNyc(~6o}9=*@C?!#IvKDd_%M^dNRN_2Dc>-pvcgU=&w+V_6^AqzYigWM)& z)J`~1Ypc4tCav86z9N=>tWn5Rs(<^r@9^x%L`&4g#yXso);JbA8R>1< zG{!y-JY-Hy;+9BFxOfqPaCjIqG5TVTm@F6!>$jdW+ncIiMD9ynL)SeVw|k@sF5xZD z2p;9u-rV)1Ar74tn!>LzcL^aUHrsVtPLozqCbZgXZ9N-YhX_#2%Kgt~amN-YlQC{H zbQnEw**Q~R!;8k}aLeM`uUoy$LtKvOp<}DM4t0UZ!n*HqN!z5BTSt$mktTm4tbVS) zC8kxOyXuAp?Y7@pD7OjRio)l-hfueB{#-8&$0r zkz(UC-zp`fzIM9j5)*WK%vJy|%s8ds+}xn$I)Skp5l#;~pH(EfcN^%g-j0?kfT^s< zrWmVLW1RqQ(B1j!v|e2A3&H$q(dC1-J2b{A)83kBiEzWJMi|M3BmZ$kG*S4b0(K?| zY7o}fPP|4y>_;9)#KsG3@=VDQ)2z}7Nd(bC+IY~rl$lrxJ(R^0Vv?yKk6YoHeDYUj zW1((DQSpFqp0%#P(qqbAr^r|h!$vWk=@L)6{Gi89nE!5d*gdk z1Y~st!?PRcNF1|E3Drxb%LcNOulA4luV7Lb?$C;s1)eU^M=Bwo z7=8=JU}(;pn@@{->lbY?+ttibpIq{1>KWc*4kX^TG_Ciu5emT1gu1ETjjb}&WTBT= zy({-D2Z49FTxi2oLU}xM?W>dJi<}o~sD@uY@}N(ci+oxr9&cy?Nu=Z#!e0Z{i*g&U zWX__NWMz4aShr$Qg7t@6AKZbM2J)3AFY*hN$aT+2nMf4YO#wbD@+Ens(ts4V>lXu; zf`c8re7AWA-c^LB1=CFQm58=?My@?UZ#h^%6LV$;yG=TWi7IOQQM0rVcW2W%>l4lq zr(3NBi)R#am?HGTu88-fnKJ7%ZG^SRS+~PRIs)QqLq%$7yUHP+AqEkUmeR? znr{@E?9amIx@p~Ohn-T>EZ&(_ML3zU%&)fy;#O;O&5tFiIaaTinUHsdiaje}{5z)xs`-yg?>8^TKYDQKW2KBe2P8 zq~aJXx0@ta_n^JX3q`&*!EcF$W?aQGc%`U$i;b4s#8n0-JAwD|)Kh5uu@t2OxyEWO z&^ZsRfTBbLRUCh!a-di2L_9J@bIWd(AI!WeE`cRKnopn676ja^*}gsO>5P)}xlnDm zu$exlPhN>xn>yCB0OhXFzQpDpqV8*x7@a0<-7_&MIg%mec%)mWIrqw`>Hp@tY_|eN$f-xgU{W^U(Cj8t`nHP=i682QbCx?l_{G z|7N}%;Q@8cx_MVv)vbzG>5_E>G^n#k2B@OpDE1oFI&epW=696;_=Hl^TRt0gX-4)E z0eB=-FQMjmejt)BL<}!w;NZOb(Pn0x%Zlc<^b|8GrSF872?)aTm-mms;+2Y3Q0VuKGlriD;Gp6de3Ctd}2%`IsR2*y!ap zcYb6LM#Hy|f2yY}YL?PFuI0SYkH+AXCZwV(=a+zs<7t@v`)|oCuCc;=jnQ&&2N1g- z_48V7^&~#Sl$8%8?+V^nnA;emZz46)-tYdqvo|A@h7;4i73CU{Xsr>OB*e?Ui6~mE zi;#-D+lF?@knJa6XAS$UW+WG|t%4vLdFSav%_2i89R#!Nk>p5KqE`UuE^{`9AJO@KOI$_5+_ zst>=x%!l%-7@F+cmo$voHB7HhlIv)vm&Bp6;KeZARGQ=xUYP5)OPTxS*x^cmYJ?;A z`(-1d`%@+fHr5-jono2?Ba%6sBK0uG3I!V=no3AAr!7nYWov&Uq7&?FpwOMWE)%c? zhat9Uw=}E*trP6pzO{8{uaG=CW5Tyv`tN%;N>kby{&-VzgC3M5n{T@ao~;2KtPBV4 z$nQ2p{|tCshu;`tAP-y8e~u+DaB)*aIFLrIMS)@WsA>jyQze0%*0Va7tdGzUC0E&C> zFddslL!@YV0HrThGoa5?#cPiXmx?oZcLIHQo)F|;Yf;R8u>o}t;j>v&q$a3yRh?|y zeu-l7Bl|uj5S1q-W!Uq=>i)C#552hcdRnSa0@$|mJ8+5neL_W=E->Tm;*^Ucm$}Wc z&vzq!@eY|z4A^kt`(BpE@#QpT>3mDanKq(6dl|S-_)+F}p@&EyEwOP^ef;SRP;Q$iSGqf{YT7 zwoLfzF#Lx;&1fmQ#GJ{s9hC{=YFLhlZlqAUaHw>pa2+*xNY8uat;DMy)nj3$4yBOHH1bftuI^+qkz{cve0oPeSnVT3wo#8kf>bHoor z&m6dyko72ccZ!6d%Xmu|E1{ev{^_9)YAdWyUWAJ_auiBq;ZGu^oQp}@e0V_i_NM~v zusg*?Y1s8r{IX_0w>`Z+ts97SxGDJgT-dK3=ghdQ8B^?WrhdfWtqxBNn_sr|BokXE zNciRE-Yk@M$K~5_a@JEq%sKL5rU zU40yESQy&~)00?w=(wEBf^yR1Z?(}DFPFPlpgDGjVzD|VTSCB{S0l(Slc6j@E5-%b7>SvYkY3LcH?EuQdHBvw?a?dTjsNkt6PFFJi z8+}|$nmesjn=@2YHN!%X(~TRvjR6+Xo~j=|R!U67kjX!!-w2R$5-}=?vaFG0jt~Iz zv1UAjiys#40oOpVmn6gBgkP)QsuY_rlLJP9->+D`G0EXq@I4wEYO^joQTo3_|0A;z z>i%1oIi`73a)*wPk;+^bYA>%ve}g~(bEFX?Z$o{G36u@3(aTElLH}*4m4n_KHb<>6 z*8=qiaTob|V8?YvjvL)jaZ$d;J3RapBHpMl(^VJ~F6*Bs z?1vlGCp2qNIz)KyljxugSVaE;GiSHR!^+9m0cA~ql3$`lF9M}mC40a{N%Gts3)L#j zdfPN{{Bm^R@wn+u?DZ9m*;ZbJLd)1&>%UBL1SA&@fS+>G{=H!%Ke*fQf=qx+;=yIIa`OqXiBWgznk!Zvv|J#VQnL1GS z)}Y(Zrs@qW`L3R3zd1+@%3Fel1dTMI;J@h^ml&A-nz1T0%Sd%BZF)0VELkU2_hF&5 z(=BR4jY}etUGI1o8o>YQmkza5($YFW09X(H>5%ZxYpoWb7O-oU`wQc99^EloRJDFb z>oItv-Tg)2(Qmp8;H|N!?^=S>G}WwOW-frn3dgVw-iS6Z7{rpD0+P!tDgw&jicqN- z2iCV&I(W{*S8ypDO275p>QXQNBG-J$(wt;#=)64EFqlCPa?cD+x0_L5%F$#PlHW~_ zqyRxH9N5y;fc;4&bMapSPA68MPROQ?JyuHoVq!i4oyCV+(bF(_*vvI@*^*jSBKn&f zdaszjY#HV$>NyZn_ca*BMQbTFS`}>wt;jT1blI`vB0POfI=McXZSIL88rjfT!E zG@hgM-r3+aK>E?Bn#^Dels75z#YkszXQX_hYc=)joA4&TH*ISk!&Y>6EaWUy-IL7- znG#J9ZEThFwk;043Z04%y!=RwkJ>c0OLUlKpiOQDY3P;+?JPf*qfUxT)~7egLugAr@0GG#^^X+2U^XAHw1wEK zR1N|fk@f+9$U?g0|J(borTbR~mYSUD{`PcjlstoQgr9<2$*%1G8daY(D3+9(1axAp zrH|a+t2FRuDvu5I8V2nMLZvOtZ1Eb-g9zvYR1yHl-+Mgu^GL|6%fg;dz1FQ!%(hM( z+e|n1`57#W+wGArGm3t3oh5mG&fcc^N^RH}%F{?)wG@H1P4aQs-1cdYy|kC_sPxKc zX$AOp_3emI+`XLZ;7PWz%9%1~>Wp@ni~RbSTr*Ddm24GmTJVWsvfDZY#jObVFgiXz zZ}g?x8n@sKNj54W)s1<9wHaG;{5VenpXF=Ws;E}M)e8JvSN4^LoorAN`g@VDv{MCr zsX))Cc@9ERzI$6*yL2teV9B*|-(`2jx^bpV=992>K)9_b<@oqc)|^=d?YCBm`I6M(J08`klAmeFNEBrC~f?fm(QPsybuKN5BrW2U`Cv^ z+H>hUycuwmL1Xmu>*%5YQh{~u__P!4Ys94Fkn39_ND_B!KO!hP&7oraEqscsQPU(i zu(z?}#Jm?Wr{eH(72um;{Yx}of4#}S9mnA2=!anRrrX#~cNfG##z4M`PL;q_EW#OW zZ;>8vz336S{!BL6#&=E9Vs83K%nIwiqKlh8Q9!3G@|bBqIZZd`8$X_4ooA&{A6;B@ z`zs>4A=(Og2R-&uNGR*(L2+@tg0X4ij~4~=vo$zey~X%T+Vc5tZ6~8xcu{VEo$ZA4 z33D*SNCsjma&e9AH8EwZ21Ae?7IX#Nj!`!Vw>@C9{RJ3j(ba%*x@{Q*WW}nqc(8sT z?AjWYn$L{m8i*#GpZS1LsVPO$t#bKkI#r{JAJZm55Ey|{t&tr&p8|so$S`QK+*%sYU z9#$9fhGO|lmKw?-;Lf}sy%{czrh)@CcS|>7b)+O2Q^*stR8fmZDG9R0+kAzHSW#1B z5*dSv4&MIL3x;nS%uLw^2A%pPP(3G5P;K_b@!)_-KgJF=?XDIOE%+5c4l%9wdM)jQ z-L5>Je?!KhGW}nJ^9#?%y90=SlP~{dSS7l`L)y5Y?k+U_0-k4oIU#BP?ISQRX`7z9is16|T9a<#Y7_j{>4ZpXAagZsaCnH94wJa0{-^ETU0Y)^Sf1 ztkL~N=`VsGh+da}1uoL5h^f~)o4h}UGPv_ZANxK91tWQ#|0)Fz^4Y4*`Ejg* zRgJ4Ua9b;&cgIf3DQZ3DjRT-rnG!)wy09ryZ(1>ocOT53hjk}MJ(fYiQITrxT6+-e z*kN$=Xp$)f=*Hv-_ffI^!M3%Mx26XU5nKOi(NSoX>rvnoi3TW8!HuDMBbZpzP+)5!nRLWj4C_hl7Vok(P;3jHH^;QmS_HDWe!Er^}@6XBSctt zEgvra9aA#Mzj*Mo-zVY$cfq0?mY{KS18?!I6K#>6!jlF3WVg5(ck7m78M@@D5&OCZ z3p+ZrU8t_?PVgyxsv(y7IHuED*`X1WNHR&=Co_$9P7jMMiJO5#o_|$xA5wOF*$i36 zs|}8-&AS0(>StZf!|GY{KW;rt$^BwI?rh_;fA7hUs5<=9gaNd>say{Y&wBE&4Sl~8 zkhr;5@RcN#eN*46rs$C=hn9hI#OTv%BfvzGg~$bidEqJzC2FhB?=sKV9mbtL5DDnD za!3;mikY+@oAKe~E$$jm88Pm2ds$w=7FZW`5NO^#ZPiK2A=t`w`oxCQU;#yb7vt1# zU5V&i-Vk0CI;B$uPJRFEf$ZtAR)}X_=_8g|AzU^-!2w8*Z|)@y?Ehe9=S7Q9&wNp`a5m^deCj-E=JnwS|A7{W2dy*&Fx4m@1Wj7M4| z^5ro?K=}ot70+|^mv(hkxl4U{QBTC|urza>;-;>KR~L%aIcB&OrXR^`nMC5}?4tw+ z0;KK#kO_4yw0v?e*q9H?Q?N6-`k68&HUC4DVJCtB6hR+C7Bi9Rl8>Og#|U^>s7%EX z`c-!|ePHglB;4IWmN(-8<&vL5W1sf$r686GRr29NlGI|D+hKV{Jfjoh(OHkWw@N)OW`+Cqr(=fRa)s3Kc~tgU z>(9PI>DzQzp+&M>QFJL|=<-3xUw1xUMd?}Nw!uy^sIh`cJ1V?Hl<=V=_OZUo=xoMcZxEsJ|(Wb zdZfmTN^-*#+huok!y{(sP}^)CUOz!s*J9nP=K_2PeI}}wWd)Eu-l@oCy- zk0k<3QGUA-v%Mg5FO%n~8gVMBuH$zPKo(>wDk}*HA;35cwE8uDA+8Yq43~SyqQ1S~ zs(1V_eg8G8{71Za5dQmj+Os^wvu-BZhQAS4ulx&s^grSl{_#HE$+4eLi@W})59(Sc zpHpJLce#~Te>B^^{PX`85ZHfw;Qu`G&{g!o${3#U*hxaUJZg(T!97PDCHYtMw!e?j z<;XhZH#n+}ZhbM@s(~9}6XvT!U24n6HFK>hqDiU5^9Tci%Pj(5j-)&?X*rAtPo=g} z@n$(4O^!X=(pZRoLPk-?Q(%Gg4QEk9WdAiPXda3fn%~ek&4)$ldf8_8nCd$uT}}6+x0(X= z6mMKyf9xuQ4>ZlZiH4aX16Co>!!sJ?v%CK`n+i3#q@1;eO10?mk>@82dgU%r^1I#M zmzbECUgU=i?KqY;mePV$YXqUf zyh3=sMBZ{Dbvr`m6Ia_yB@bFXyQ;!6s4wj5}RHPPr7>V zU~RtLmTGUj8?fwZ=cMc`z{nMF?!9aN47uySs;24LTn}tce`3(H<5xc$FyOR7@?`DV z;(coHDTk>bX|))EPkEU*8{{vwoY5L=UoqTzn+G`TQZHVgGCQ4IENPaBH4xEv;jhSc zooQ+*A2r)>GOgWDp=sgElX&zSqq@?lQorV|mSjD_p$Xcd-FkFa5)o-S62~R2xng1cX;u?NWgf7*bPvfoI zPnln>3vo-wH{&+e?3FnA!!@|TxdVazPb|>bYWkDN&%VwdiYN;kk1JUs$+^MC>+nQo zi)YSTEt;F!O_{4>2lY&r2hCAF=&bi!quR$!csO^$=j8`^c|_sb!W&9UiT)rveIAdJ+e_u#hB!X8bfwGtpx9# zbtA?)ZhkX6^;hUo;i-DWO|6`Zk(SVR*5hf8hre+jmcCR9f9Vq;^^*jP9;~B9k_S1x zD`NQ|&lqO!cg#G!OpT0AzAY(jApQ0_+7(C>J*nBGAexolhWnBqQMfHtkup0!J8BVF zlQ>CW(32=j8RbB))zMPb8~^Hltt9I}f3KE!uEHXVg()FxPlQ`l7i^NzKO}l?mU7Sr z#TN#6|FxXniZ^fMb?LIPdq10U?oZ$El45WB0?e&_UTR(2EhxHaGcu!W7@_wp5>PmH z&`anY%AGKD1h^}TN`(4gcJV!p%!wEEUB#}2kQ|q5^?^JzHlVg~#PV1O%0im};#q}q z&z<)pw?oq?Yt-(9$FaD%*q87-RZ&ogk3jkDD|fO|zO^zMf9@-oS2@5GY=K~}$pFkqxpi^THxvitgTq5tlSz20_w?~d6 zTbW-=RAb*S$?qW-@+G=M6h@Z)VYV}DeCOzVJrvQ_p`9DV9-AHaoFPuhw{Lo%z4F~S zTzhhMAY^%2`f@wfxTDyhS0}J@smx_~FZ&Ofn1quKNF(yZ#|Uhz;52>e$9ZQ!1?tbr zH#*a$QP7cX4OMifau>bq?)G!Fx9GoH1k;b!){8>!7_P096{WyW?EA}o{`!y>ZRU;A z@jy=0SWsbKSG7dvOtSScpmkSAL>&v}Yhtq2&?e%)lIXOn|0Usc{VdJp@J@oOxgV5 zZt-AruJIsnC$fwBld4lpcNVv>Sf^6)myQZhZSENFL=DUs0$ufUX*jASX40%1;K~K( zO?%6POo4pPAz&e907BVSA{oWLtS#H6!K1GQL6DqK*p4f^GudoldhUaPcGAhR-Nwt z=7k|b10LGBaw6sqplQ8sYpu;T#9zb|M=$46aaT4}kx-b15w$~Os@lS0?Wch~b7t}J zFL0tH{%hjv+YwDx?`Ot8q{SGPKF?dZnV%0ODarX?e6QR&JOXwjS?Ia6AZ5*y`=dd+ zWLGNq|2aJW`xQ=>{XBBnnME2{WH2S7vP(1h3VCE;WDQNlrbz7y~} zf=&4yV&+v@CgETTmJB6M2ali^;{i^;V}I4UtzxMCHNcM}yqiRi|5|V@kpS1Q`iIqU zR##RcRnx!Jp;z9FVIaXz^wID8G=u628n7yPz*aJH7E#lvq2|QcCj`d=cn5IRq)xn! zV2kIOccDJeaZ4>|6R)HDcBiw(NmIJd$XQQ;rPN!igpQP5bnAzE^;%n7sd@dBKr!t( zr0dwf(<}e7unCL_irkvRtWR_f^US0-qMjEYc)$v!G?!W>yOYYIIB5;Q#FvVpq^E`f<&vNLLW3MtF z!ACmO6`Gu3yHf6c6DeZST(*5sL3lvEbY9qI$bja zk*BoXN?ll!S+jbdRvLUI6&_B8LbG7dsTNWBKbuu^00IpqIH3QfQZ$wYuBUd(tRnGh zh8C4zMY4tM9D!N!ugZ~oQXNtJ^2TZIa+z)gj5K@M+Bw(Lt{%n$_*i3CCK(xzB)jL6 z&x^gg4dNSq#G;!6@mUGkj#JTzyXC}wLyeAHeHbCh&nLE;BmK7t$RLNj zo*ApC%}mYsA|TJ$)?y*3>pT0)e?I%JnYhG^ESMU=XFh61zB;TMu5i=Pd-W*v!k&VRhuR%_PtI3?Te-vqKYB{a*-$ecz5)G3`cjs% zm@?*`Y^1``Wq|u!G@m2PFhuflmT=Q)RPfdcppzL^ zZT%7x&Ns-&te(yrj>+E0&|um(uR-hPVkgP@xqd!L(IWN!BzM)7S)ZaD_U}4rHq#Yg z_Pi!^46d50y1QBlIl0Jw{mvuV+6Cj;B0}}jTW?hF1bn3YK&h>evfSwDW4DZvG#b4t zR^v7EtAg-^5r1LmLb^ilY3r8yNJiFu3Ts}vTC#V#z*eXYg=`zlWbo!e8#@9{4?v=D zm&Qag&|f#{TgMe2TW;4K)yhqYFzgW0&X^In3n8jab{`vnMWdrzF?5QngpjgQ}2FGcJ1rgKP{oK zXG^$&T>Mx|$E{D`X3EDO50AU(@II0vwaxGhebq1*LeeO8D&uJ-{05%`OZBu+}E5+Ak`sxdq*yjhx*r1P+=-=Q8&HA7T-M=U? zo|S$+N1T=RLE&5NqkqV3xK^wFkhOR>`dR%U`=YW=b{kB1_J{1%w)=6%AF}u1sP^Cc zGi%%7q~J%JM)qJB5Oldx*FQ`3`yB_2RI2T9O2u?9`S0@(M{D?p41I^9!xJ&6+x^fSXE;75soCW+D3N}d z4RYzB?4jgCCxVs)6@I^t<$bos>{{_U_EE6@p|+@O!kqPy>0Cx(f$qF@AJQ(Bl%pby?KFh;%Gl*1em1&onFu2R z)rmV54b;5j{gFtpmwYAVU83{Cxi-!R$A56wtM~xWQCZ}zmvQfdP6k2We_;?mmSl!T z%qG6}JHGZ0*O{%Y?kH+pk-89fc(tuvq_-~W<9qAy=h}6Y*OPOp6u5X1o{*AGR9Ogl=;l)AZYk_18}lF*$RX zG^T}ru&AGkHczyVIC7-(K3lZs4^+=Ny8JMzKaBg7G$l2C?5-Pb(Dh=yaM)%Sq=$sx zR=%|v4M)Cc(#)_)sr@mNkMLj>)H8wTgiCY4bVsOZ{UmXk{obdT3%@t~|6p>ppOXdu z{j3tkhC}(By#y<@WK`G1wyK4fgQ)l6HBRY_CI5yey3Bd>}}PyZ=Ny-?Q#8U zbf|ma+@MxvouCrv<@VC_->^}CANcw&nW(FO@0oek#rAKh0&8>1OlaOAYc9_11v$RL zZDEtBr1`oEO{kNszL6hc)!MHasJv5Lp0``v=MeqiU?MYdhh~K$>@p@Jnq}q`XT|0~ zUaNr`RoR4oyB~_d4kk9H`8RXO&&+ABsLpqEuqd zd)ucsZ@NZaVvIgl?EN9TwkIFej|@mf^nc=$)GdzCom3x8e9F(XZ@P8-?Ra?4626nuJd4~Owz<6} z55byR^i68W^#^uk9P1O0%%F7zKHoEe%SSQB+p~3h6+Pbyxz&_SrwMq zsA*eSJeAkfx)A_IjzZfjEgxoYy>?ZUmjUVjU zh%K$UWgvhevYJQUnkmllOqfw`NJ5-fNqhOYiq@iPxZO)KFzXk$WxC?T`9P-2?q(@^ zP;HuD0p-1U*A_WC0gvm#shBY6t!eO~a9ieHH2@k8XsRd_w9Bm!=G5d;oboCw`%Z!*+SkiK zXrbKNfUYmzHd}o;+`_+rg&|L@X_+YNbJcY+_@jX|fpvG^v(o5mt=7csy*{ztn^^UtZ?3FR{Vk zDjd4Y5i(oe24BURMu(`|5U6#69tbD8tU&amKH0ACl}zR68GqilQMumxHbJf1@3ym4 zUAyG>3W9US>JsBMjba@Di#N$O8?`DWTX^rcn%>hc$udcA&!j2bTfyF}W|f~%eJQs* zGs+kG47C^L zQJ=j}x+#iUJ%&fxTNG;c5bcade%JW@v?s$k;{%s|X_s0yrq@I^7fyc2a8F$_S$%X! zFT|V?r8Y~0;U@q(PnHG-2Jpao@fDx^8hgv(egvr2WWI{qiM!HwioIDJZ4~>_EgZg0 zgUofwwYP^UC6?pQv&O4^uJz^gmf=#}XU4Tp1^c2ylAjPtoJ%^~gsc>)n<-#1{X+%? zwhoQ1)ub!M&)wu5!tT*+NW!bZvT3*)eWSGZT6;!`&ToU)i)tOBWmQ#0-WhcgA0&sa ziq8mA?_WgoWB6Hu6Q5S%?|P3j2ygGCX1lm10yHFKY)2ivR6oIyDiB}qru}&3l&#n1 z4&1$%cnwDGKqL5FR^X(fHVeo6q^{!*LhTc;6KFQdXc3R+h**8PZQ=PyOqZz~m1)_| zP?vD27D9*5#n(!570~u>S&EJ?Hc8XsqTLncvv-oze(X=|aE+T)+%I=?9IymyIH2++ zDQR?5Db1s6CFxLec*>yy9v|QUIHuD3agmS2kyrBlJZQ25=<0f<22mF&F{_20AdvGBg!Iw&4l}$F z(KfnMtFI&ZuOJ)Caqs#(D3Bl#vFBj+w7X5qgw}I@Ue?uYi*+<$NDu@D?~0ejV@(yM zr6loJGL_2!@8ECvrn?E>3-3oZVnn&8{mSHM@*OVx8MA`_TE-o0B0z<5BNXmyr%_QCf9$ zN4{8gJNmr7dh$nx&*!Y?c8L{8mS&)&9CtG#p);t3)<=iD0_ z4ya$O|JfKYF+aOK$4AX^iv2^jow^mi75s;6R}8n%^Xc*B=w49p!M8tTzht-nkd^gZ z-#~()zat{3t z^OniT&gRxX-Z1z92ltD9M|$??lpc_WtEuwy_44Uw*uI=|Ni{KLA1~=-(OnJG`bN%{ zI^E5?5AyVBgwA;p z{aXZG2+0TQ9as#q_KRehczFN3+O=(d{ek_iBp7z4sjcLSZcKEDBdYE}rGWXaVf?gO z4@XeyjqkCFDB$@qNKA?Po?{@^sX}!}iP97ch;jCj=-w{?l!9E0LFM--u-%Kbii0Uk zDM)}FVQ}D=z&RN3)*&LI^~T0kn1(fPqRSkd@x956ix&a8RW0-iZ&Z?zvi^(q?!#K% z*i850btzYYR>0uY$mlR~mt5?l%;C4Q*3>Vg0vk)XJm)aqx8#QHL-k}$$vvkEW%nTi zw|Q8K4#XYITOcx=m}_oQU84Q~_->+K-ouP_n+^&bh(^UPUlcyJzB9?-E`O;)biNtR z6MxlER?5^RHYTFvRYvqp&UYEDDH}>8PZvAr;vj%G85emoDcbogZM?)xcgL0DEG*md zafrd{9hLM2r(PhC?&M9UhNGLpJBAd!c3Mf6Rch))i9B6KrW2g9a9dLB!Y7F$wr!)DC_Ua-!3TUw>sw*W1}}V%XAMffP79K7lrC*u z?FGj?I~VWph_*UMh)3~^_u{lF*Z4Q1iD{M0fT+t=V||>;9FM_zn?-({`tvzS-iWbK z>~$2Z{##ujTT8T&NBKLOJ<;@HWBDRR<%sznsrsy1%-Mte-XoM5IrWLF!N+e|ZMO@k!Q>o19zKU`M4_o4Px8&!J! z7?L)QRl_aq`%gNH(t;flqV$UAN4=I{0m71aTL0^rk3X8i>oQ{XnI8qs>CfPWQVa=+ zJHdgC!?%IW5xIgEJsX>wbyN=qDl4UeMXcfl-a_A`!!-Kw5NS!fk)?Z1ebH_tM_aJ1uRO7fs#PVqVt_#W$y`69F(sR(f67+G(t_AFUREpVAQ+ z*(H^)c(g^<{NQm;wWP*_!qHaUs&GH~C3Z-tutAGJmR@B&YXS!5)~yrp#lG|Dh?&Gm z5)N+J)AdAUbX%mMX-p<9xso|{&6C9=#)c{61i4cn&P{jNW7vpewxiC{3^$%D?zXLG z*?poBBs1`)o|G#458M8x^!T6k)|HqpG`B{kpq~k^z7qm0>sXJX#D2T8tCPvPvNMa7P2>kwtNtMyQd-Fe9K9ZM zoe*gM)$8?uY^}x9wynx6_N;4p|q~kd#RNduMH~oEAFBu z&@||jExH=u7-#QN~VdM<;&GV=lqHIv-uE1%Pg8xwCAc>k?De z^GGhfQ~lO%e!Gn>ZDjhVEG1=It1QW>^_dfmJUY2{cfx~9zphR7$6gV(CT}Ohz|vu3 zv-(E-cwg<~RkRxv1;I+YyWidMJB=%waez8jv34vkDz1pB1-i`V(IdAcxvo#wrg?1_ z6XVojr~-y=4aQh3Zo|90;ttRJBb2S*mOUK#?9B;h9ye*5inyW!HlH@NPjHN#udJS$ zT@wWCT+yI%=VNmX$|Wk)knP*$_7nv!ubX!gyt@Ktu{q~3DF zG_f*DqC6t?F<0<3U5Svjcx_e4{Y^SI{clM0LoGp-P~oW128n``dBEaaz(T@uR_|%b zDx7-zJDEW&P2c(t(ee$iv2CU5UI*p(wSzd=fj+$j)NzwFlBKl(dt+LGz40dt+`jTl z13;S*z}r3fl!q|zZ4)Jw7EvaW%H1u|R5$ICJ|ok+wKk{_L4dqd4H+v(**Z*N{8g-{ zfZe9N$I(y79eIm9xaZZrb6*rnFfGtWeI1^k8u@SmZvWs;?5O_`u%0Z~@pB@Cq$A3P z1Z+5?l6H0u$%Kz{ohai%ZKZ?;MFqYpXuUC=gB0gS6b~$z%07rh*(I}NdUdl#RN6yq zLkN;(QGWeUzD47N@yu{9d2Yd{tqY8ACwmaxgp%VQ0z%cPJf6%=Nt$b@q(=U2QI2i| zv~XbyzFY50MFMdi$V1Fwqm8M&*=vm)J%7j+c^=$Q8}!(~+{|)fKO8i#t{5!6^WZHN zbF^awR+YTuPACn0fM+|?&?2Tz-4x?|lAfnTO+`F#aJy|%{m?}vP>S-e--4las7ej} zjIwt>)a1-p)t~V?7yrzaNTvTzw7q9klUvvC&9ReC~`(1ZXA zgc2ZNKsxAFkQ!PNNf^y2klm-PhY@2+p(K~HZ$9-%p+J`P(@=)z*2aJa77S)K~-kd1H z*_#>C<}vBUj9%Y>5R1EAg!BRXF7U$8iElR^GGHt81qSbT2I3kPTH9}*Ri9wjZSYlU zdoRnblY^so_hA{2xO>2eF*l3Q(a3Fjz${RFkRocE`eB14B#FZt4;A+?;D={$gUX3M z9qttot!rjt;1GbnVFmPhzuHObkHZ4NzgalM9G);Y`{=E1@|j$Kah#X2NV+ybIuDIa z0gy(8i4gz*xZ&FS4-{&iJV-HLp*Yc*Bk2@p@MWj)rz3^IK?(Lv_tRGP*Gn^B<3`!B z1?@}DGs+QC@uFb_Uvro#;F;h~&t3Heglca3XgaxZ;;imniINAr)LtcO`{S`iGt216 zcbQ91iH)7@Gi(WlCE7E^c*m(^CZ&NN;R2U~^f%)EFsMtkko~3J{j^1-VZ5o!f~ZN8 zzqrR%_qkrbW{x6C0W$^>F_srid-Uk+Sd+mymCHp&`-TV53_VxU+RRxCU74cKzETOQ zdiW7qhO5wbqaME@)kZx>3A`Ju=xO6i`6U-UcQ&w$ra|MtubVd)Z{E9BSDO{saeEzr zkXJFl<@u)>_f`Bg{ltgevjpKlybKX)>&?&gHF7_QNA)#;f(o);DF)#!oTirn-Va{$ zcgy`ua9o)mn{PmWEZ6%p0gAKshj1nK;&oRCYY4)t{n~n+UGHzQfrWv1edcnh;jcf~ zzb10~d{xdDug{S4+-|nrj-N4mKVF=j|N4FMI(PI+^DA_@)r9h|;&czTiPqA)Qx9OV3@3#1t`|wUZ(`s($IWMSV9TF8 zPtJz!KO9nf0?qlhe$(O#2)8_S+L}45z4-RvZ1{~sMGgs%Pd3vkGdhudE9z@pR;(Wg zh(t#b`RVAB=!UCe_-^dhi}Jr&s_y+L4w7;eY~&sO@%2jSpOR}Z8`s?FtW>L?X6JBwqUO5lL&gA)O8V(E`s*U-zrphPUvb+8YJ~yF7bGq~G?d0>R zzrxJ_IOAv#XE(lzbB82zieuTu6XRzM6?hXsdB`9MRFbyGBi{^PP4k3Xq}iqtBia;yf{j^&tv96&v=!4Yg%^l7NzPa36!!DzhD%(}>#Z3PBPX2KbET(0lN@kDIh1R1` zb^(5dlGwoSo33j9He(-0*t^rZrXv@;iu6hMd7~3Y3(|xSVgtL{&lPTl*vQkoGp1Kf zBJSnp*Z5H&zV=>R?03R-yO)%ZdQBBz*iQSZCgvX}`q!XPVrmPu*FUhI?fp{pmr^M& z6zdITcwx(Gb2w3J0%kfwbtQS$xsaW;WKLWARk$#$zd3x|rQJg`+_m0fdd$W&4Sf5e zpu4~lrJtNIKIEthDjbd8+#bbiHi)RhA!A26(C_~{kfQz>V5c%RE~_P3Zg%Sw^E3{| zvxiNl&n#C!T?uL-cP$N;U}E#uL4{0qx%P~|zA>F_?xN-Ka{2JBs+?W2pejk54`p4LR3Buzknf8{# z#H|_E&lYi)z?6ByBXpJJoSKGj`AoYHX@5ULab8DQB_`Ib@ae$pHd(j3Qd_V6Zx$c> z7d~(QGvE6c^2Ucy)7pLajiZ!iA6!u$YdwQx|zXG zs9-t*f5HLhIlnHBNA6_oI90%(jBj$HC}36dd|+}iAkf1ea8ta)tyIjETfewT;6m2t zbbWlioFzw}Rvb(u1~{`5q6NeU@*W8ruP8k-@3jU9Vm^{C;WDW4I9rstc*{rkAA-b5!2{mS4N z-eG~FiBP#B*7nrH2Vbp9`@D^cU&=- zfAsph%*p`j3(Dz0mE9d5nGt6nR-i>s;0K zca5ph#?ft|=i27P?G@d>n6@Cltq)GT$vDb;PsS5w;KFJRdm7Omw^r)?GW@EW? z-FK5k^_&e7Fj)8~<&I8G5D0}{*ljuddOl9DtGYxvJf(A-T{Xp-Ff%*Iv^XLf_s9Bq z-A1G947SI|AOt27ca%tg^0tnxXp!Gm^NFV?oHcv3I3`Ocw|dRCk93*cJqwx3JYR7* z1ks1vK^tGrEI~Psv-{M=YWPmGr&^b`6l5_F39mCNvt1b8*9bKeGX-C-094BK_NIdE z__p^P`#G34NT`s2n=4E&zpLWlwFT>DOh-9xyGfF1Jr+s7U!hkWK>8x07T0P?#_60r zSN&FD`BrXNChsh_#Uj*OSA}iMp~j&Wfw$)qsTU)f+?rzKOY03pdtsh-eu1avE(V5D zFVl@=92pV{Q1a?D*OQ+&-(QOJqnTFf3EBrYSaY8Mu4N55fG6YcE(y?s?-XoT&U=OQ zKdriXVbe6&wxOXEE)C~vUf}Z@dGxHMLC&_b-h^|Y!b-=+KUs=!wVaEyuTon~FxCaO zL*7ZkW6w~S)%LDT+nqy3l&!Abx|~L|)!jzp!pXv2Ik9=WIh&6%n%$7hgh=%Z&tDoH z8*C#rd6BWA-1M>;T}+9}_pBeSuBZR97bDiU>lmFvNpu=jz9<^Y72*UW4@yfYx0tXa z+82Kh5Dh%Mbi=wut7rq4rkr!_isvQzqzexT0dNS&ACEQalS|a{?-8)}b!CE0Q}-S* zjuiSeXsSW`(kkBVzvx#AnY&yhj%TJ8IZ--`%3)WOM(YX_6zEG9e#Y`bg`zb^)ieDT zR@YS{cyYK{ggF`k3-Q!;&N|Kc*`1;DOY^N)#clDmq+<~0=FQBV=NPDD#ph@@I73|) zHZJz`rZWw!E;cWc11cHAIZF&@WgJ>kx5;`rd%Cowz`o|0I%2v)dKI$d+#$YW)89KEB!ZPT;R@8Hqk%fv9Fofqy~xXD~mXC;rE{ zPG|2pTzWs=$D3WE6o*v`K+h^6H-Te_3Dq>rj1Ek3$KUqL#5LbL%*J-TvZ~Q-8}|ON z2IiFem=WEJ>-YV7wS4S$e~B{9$%z1UVjUSsJu_<{KAo9y{n{C!_;j6c_w8l*CRKVx zIUWi2Q2U-tJo1|0+#d*=`HFD1rjl~b4QX3cG@>v?^`J(3kI=Dd9+_sj>ByFwHB8~2 z^Of!QMbfN{v6qMFTtTB^c51Ml^{^h7L{k-?HT}6=dzIc_3oI-nq{sjH_x^v*x`9?K z2X4XZk>=1|i_H@QEkkpTVJlQ>!}FG%q8TCf-HM;%6!n+FH=p%je{Cjyl-OAxPI+`B zq0wkkW2RC)cUV;q!F+ECibaI6qJ$xK;}EO-@rY6M;z-LQy;n1z6tSYO>zg2llc`J9 zgQB)MKL#0==6Xr&(STHU*9A_x*zlR1+5Z;7&&2u@)(`!)Mfg%8MhMqa`$Fa)(a$WD|_1zR_q0A!F!LP zPni7=y=q(X?An8%+M_$qf>K75tZSZwFgkf>)LY;{4c+Y~RFSJh#v+!h{9UUP=y3s-7l_-`OlQ&5GMfdh>k zoY8N&8F#+zjP)406pHPlKs9>TnjanXHa#=FFzGQ_hzhvj**%dsKGt1E^WJYG zVOQQubt6Fi-53)ce@Mq>$`oB8sBfSb*{^0^u3~+tSwRx z;?mqU`As~r$oN|y=mmwxGR|Ucs`se-#cpec7P#4ma-d3)q-!VDNe!3f2I4)*xQ6X< zG>ojqibAG688aH4ldFDRMSVS1?r6T5UL1bPV1i?ETfbLML2fjjFPBYD1X$$A+=bT+ z(m=?C-u)TVoUf~-!zV`b$Z>HRtn6ziONcZc6dTi!#sjHn^tmPoL~+B*CHpS^N8Gq7 zTu|HQRpf*BIc8QklSOXd?yitDuB@n;0j&O4*%|*K@Yy5&IIktdpIMGhH^whsM7rJi zh60xg=TK9H+zypVML65Q~qc9*^L3S530cxzVaj|uC*?Q4iYDiWsabm7FB@+4yL z)dkN*ydja8&TScGtnfDrSC%N9^Ii2UlFj8ucG{_mQ~|6B_`=i40wA%{9Ziw+J&3OZ z{Gwrs-hC}>5GW&4U$BREI-(J8zq!kAP#TF{3G(+kE83_Ky) z-CMPORwwgESKX@F^f!zA6dpwr9U|Vrs8h)ocveeL&nTw=kkP$>a39^vDU{ z8_rzV8x_hf={mkZ`jab|B?>SrhOm@VpO(R}o9nQdOhk()JgD}sd&8-thI$hrWR)W2 z7|&~Ya=6pb5zD`KQX{OU7lNjY!c6l{;I3ZJHAKz1rQ63%PUz8a{kik3uX60_$3}H^k$7tU05<2P7CE>bZn+ zX~nmtMB6DR{k?>pECNOdqg7S;YMQdf_^3+}XAlJH3L4Y0DZEtOo=Zh#5P1FD%)ye| z{9zGR=q2do@j~O!5B+ld_$RrFZn|53tB%Fr0l?_tG%iNc0K!8%{kk6f>Ji@Yr7LW~xd(Y4P=r+VsW1``c3X0Y>b{%1axF+#^3_B~S-H<{tx4uMvNX;*NqO7KTUzzjq8@3Gs2f3%BW!0T zc{S=|JAgiJ$!lQrt#d(2oLy4c&vX)G&A>AwW1uH%>jM%+x&X+bHX9hvHN`VYxP{HB**AMpPX`E4~fPsM@N9OQlQ^C znJd@=n1f71HHRFvsD^Gtt@vO>%|0;wd}<^+=c7BW*!{iRW#z5(VlzFP;nRP3X+ z_(HGt9M3m+?n5x$)@j0-T>D{t=)BC+RVjIBHC%Tb@X>AI{K58#x;u(yopTi^vg~sRH-?;S6 zI@x@(XV{k?UMbDvbZGqZdPi!5Cx5s(>2iatz9}c0blrP|K8Q(^_B@)g)xRiyO8i5X z>F_t)Y|Qd!+Ot!oHuf(dRP*kp1VquWtS`Q zP^wRMCQQF(Y?XK$B>m0e0dW(tPFAfyd>;Pt4mFDk*Kdiy{b!o=|Ni6uVoUk?gW@-B z2WD^Nz20C0G0V&fc<*1Df3q~YE61HIKvfPDw~lxs=QWNZ|7IDNIr&rY&wu;w{wX#k z{CvXu<>v&m-?Zw=5%u1`WxDpSV$;7OWd8lD__r_jzXqO;nDWdXyEu|LCXtt3Jefko z7u>VqgOB^f)NVf@mNgCw^CFujr*@jZyfn(T<=3tAqE!gL8ubU! zgj$I>iSBwKoE)#2VX7U~;~nDXw{lvCC@q1L)-JI2acy8?LqNLtQV7l!^NJ+d(pvYr78Y`Qp;ByZN?vIAOkli>EDbHc zJ*kT-2Ewh@wQDshZEI9aW({RQm1J&#SXSyv7M;q5n1TF z%obtk*cEQ&FrJKkubFWyL_SQu7)x|F^*@u%$BP>en_G2poo6X;_(2*Uk~K|Lawa+L z+Bi--Ls#;=m@OP(vOauX6>W??v^%=eLqoMibFnj3J7KoxA@poeiD6}3u{#Cu)008l^e zl;P`hFg}|Uu9@fy%N0d>YP~DxyWj*4xa)eQz#ABbPuO|yF$*qpi}Parn?>-$STzTx zeeUgrEbnq_dnBgld)K#5p_~3&-hM`b1?Q2l7%MxDm3#2{6{ZoLkD0_HAO~N?_Df;S zCXDb(^J202!%2;@B&T;VS)U%xSalj;5zJ|7uD;@>e!lvTP!6N}81|AL6${QxC})rm z$l2}dm@$@wFUPi?<1bB9Y51JBXz{uu@o-Z!d$j-On zh1MQW!EmiL0H1s$AtEQI)Wm&L{-{(*4nCe-y=f`V;FF#ySgf+#YDUv^1@5@yrQe|<|w}g;IdvB^;%m)vP zj&DX3mF%M2tRYc1fp`@;rx?N28f)x_`XkHyrQ3ezZqdpJkCGn;>G}y?bC1B6c?kPU z-wT|i02()w!FYudAYsALcmt8*!cjEM6?A61zE$*vQmSX56kV!<? zA1gU@{$h(8D5#UTjXe6HyD2Recb_=E{&2!PNOmCwTttqX<))8X^l8PIa<4R)yHJW- zCS98wSghb}_7iE39a0-eDzexN)r2wr;NKuE8Reh&MstaL{JZiNQIujf}Hj|e#I0UY*<)z?G+b)UBC36SQl80S-R(3CpYGfK)5vAoP*1&gS}&T#5fC% z3^$C^^cpI9MK;={CMYV}T~Ei&dJI0(MdX2Uiy{P+E(O48NL{IV?<6n!_O{`~x}2|+ zbnN%pF36KKSK9%KE~4K#!)3|jMR9cWAw4Ya$r;a~M-|*z3QphKY|neUQos47;+N$5 zQJCCMirds>h0W1dstiA6BT5(E$jwBA9=6N_4S}S7n=g0zL2>d+aA>xf)a-opk{&zJ z+JC*zM^3;MLmM^Z=Cn@`-HcI%btTW>(oVlSQ1+MIe#4&MT8gVv?&RY=MD?KEU=#@h zFW@KvSOOCf-W8GidZUL>7>rf)@He}C3H#lXxv$v3D046t=JF8ZOMolv8iCMod}5P` zu$=Jiv(Lv3=@xarqBr|6;C;iYn;n?_8pX1!{if0ahJn{dMAAg$HRql=8hMzKqj4>9 zjWt!!aLdooPsAocJ?6ro-34qL^SUIF2=kY2THKKZNJ44b`CQUeg1r3}f(VBP#$9xZ zS)%S|Wnpj=M))5h3R|N>ic1XbfcWDL1X0(;sMTfZ{O?aL?#4aycLWe0Vk>J(+y4gIz8EXw$rGSVQ8x0d1JoM;EVSip(ibG>O@wy4v@=o<6P@7y!xp>Asuc z;aoGs7N}fL-*Vycs_PWHI!rbOzeIa+uhmR4TLXkSivA(VrhOeg{i5)Ofb?lAWy9XO z>R1(TeIR}uNy;I=1m*%=P()x1n2Cr-a-0v(fApE%`InsYo7MuR@$rA3ah(nN7lr9x z2uz0GCxfEU*)mEB!#?6qi76|DWJSbrELWzWZjy z6Hi938eBe1WkO#lU8dw9;32|XSG%A%aWsVk5D;*M|H%C7og>Uo2s9>yP#xj4f;;o`(eb|I;h>p9lWwmhNe*+O_)b zLw~dT?2~#}n@92a&oc_oYhY5l8B&{Ve@h%b8Z( z;@Yh#SKRV|ndl`%!pb>MQfye>iTYH%iKj~n2!#|h-e+vU0N3}Cn6_f-09BrmgsvF zp{a3U)jEYW7v(-4r%W-2x0b}f9ti?K#BQ+%qmxAdWcMbGEfANgQxoS%Z$+&-gP?VVblEN zns?u)wl3+i!l`SskW_V9>zWqt_AJG@5W+w+Z^_j4qy4;tEXyc*^d6MQB`pTHh!vSW z0*a05QRnEF*6c8;<6A?Zx?2qhVh9J%qWtwe$>CbYg%;7Mi-;>=?44_jJV^NKJkG!1 zXd_BHupg~S3aCTiQ<&&K!k!%CnV-9^11!dM+8&nr1}`svK6pklg`0xJC|dp4eS* z^_XcpT=aP_E2>&ZTd$%XxH8}vZDt};7xh=UjIH%XX1rOS`|DX%#m!Q3Jrym%FsQzX z?y>rtWz6{|EAo-xh?|D2YN!CS#GluHtE|wYqw(9gsHkz!XtMpcrzJJ+7ih;@D?Vh8 z*YG_2;e9YgIUbjqCw~CbWax>4jQsT{U%pyOMFZ*haQ|Ao``w9IjLlxSB>P{K!6yMz zKJBYhW|PAPTz{Uwd>NhWoHm^bF)E!h{~|VFucD6H{IN14jRNE3JV|__?;y2iC01w1 z=BeY(U&PQfA=v{$s-QW6Iuaw3&ZT#i@nIJ+QlB*+nF6B^!vuZw zaeC4QOAh=8CD&5+7S{>n7HjFbO4?E|YZVn{$-;-#n8mKTnRCN|ouxL)d}!u~t;y!q#rm_9ih`!K zo00x&HGP zbxD^}K0E+Tm8QshdOFv$-=3Nk+|xx3Ho43)5J~h1yG_e}-ID|f4ft`VuEH|A!v_1} zk1&(RWA+!FMcE0HRi$@D9W($&6&Dn)jbUNdya}+>Zi4CM90jJ(B{0#f%2~x1E}Hz* z%!hAFY1QB3)%7SFw?+{s#?h0hg(L0RXeu4cv$1C}iX8;U5VkB;JZyhdTh@%j!;U+f zFtpT2lG6(?`0*g^5_P7rAh~GBIzN_r>TyP{vV+|9Gpyp2NhO@F&vnO5NTFr(xa+l7 z%6$ynCij+ynH%E6VllA*kHekxm|&xwNM|dYCk7os21Q_S1f?PrDMsI(J2-z_hQBn{ z9DOLFMr9l=D{LV*ecKco$2YFSE6OQ8=sTMrB?*O8ClPXiZqh>fi7ql3qK+=PC@e3` zSpF_xHymZ;3^I!(FQ(myD7jpr?+nZb3W;jJoF-IruurNKG+N5b)%0Cd z++Q6d*1b>h2vP1@jfE+s#bu^tn(n6Z3NwadIPih{R??TPECS)sS395=XMHm(iQAGv z@dl_V`{gGo5U)yEK&xreOE~wB7;!OMIM={;?231AMtd^W`uU+)P=e#<+w`paK_;cj z^L6FUM5ZyO(i%9*awXzU>cqlo0qzL&*Plen)13mJO`!)(34>nktQsQRT9@pMonyF56cni@mxqvLKIvO4% zx=Du#U66%^mE9b&rih(-lR!-{74n4gs)cZ;Wb&cUa3f3|Ks<){!)+ z#{OKP#&MZ1{L!sd6Bo=^Km(UYY)JAfP|J@qpL6cGq#dgWk6f;!G7xz%Jhp-E5EsrPj@;S#$Rl7bl3#-J;6LSqsDBG-w(1aW!hCALes2 zH992l?wx}KzF0bDc5VG!KdOF96HCmVAS9+gPIN{4rgR+=yTaktd1IqaC3W14MM| zrH^&g$&u;lLCCx|<1R#w!RGIoYq|)fixUqbi>|upG;lNSlgLE<+sa5ma-TKXXn{> zC)r~u^g3F)Ek6d}LKSHp54cL-rc%U5=_LpQd>n?Dcjw2I2Qv-@R|3{IW>Uf25>B6`i#E zZMSiNpoelX4y#Xlkf1_tOkMDMecCT)gF9z4mVwF=GD7AufmjP zchY@p+{VW;lkzd5Ot+9ArMzax35B0w<#ynz+BiE_v$NoRF_E`$v+EV#cZs*~I-R%} zWLcUtd?B`e^U=0@&B1n&q0H9)g$Psi!3#j$XNHqIg_S^UYJtoPmD##Y%UPm!_RXhg zbuA6+`iltJcz|RqzjG;L=~9vRiWUbHd8>k2SeQaWik|g1 zbDUf0*Ek#GY-RDFs0aW_H61()owR{sK|!%}M>(N{9!pNB*#+`$nq`ZXy7Q2VsRf$e zojo*(pk3Zq9K?tk2KEL`IgyI#b2U~$WPLWo7310a$S1$P+B)h*w#RWNH9!g*(<_GM z&FgeEvlMg>2VFCfnsvoh-B~XL>RZ+V)&^I)iT1Kh=HI7$Qo zJ=EcrmgKKp6!P<^&hE6tEJBXctM~!)jeX~u#xe|;I0k907mA#>32IEmzqDx?*F|X! z|FL8FmTIl)md4c{bJ{jDL`Tqc#50I`2Y{(z#F`AWDE@5?)!o6{>>7?J=Pp( zk+f&5<~$I05r9wQOuhn|%Dso~f?T35f{YIJ70D**p%!oPp%rV^>EBw_?!EfcyiIMu zC3e%$Kzu(vv{jk6PX(jeXEn`NHwZejg1Q{~T=o)wS4= z_-paaw~N0&o@1`JSx)mhr}+6SD>*pu$`z`b=7ZJ{;X%tHau~jx%zR++aoV%a=YRfz z|6e!suDZ3bDELwc@?p|kZIY=_?h!sb>3hMr(Ogu%&(r;|EuKl=)+Swe+;h$9SRgatYeI+_6m<7 zrV5BZOD?FzJzIh!wB2n6PGZ0!ra4%S^a#R~bXMtI+rY|+(H?W{x)luvy!vG`*Yziv z3uyvWwc{U`{&2eIGc#HAxY;#QEoZIiv;xsnF{R|YzKx|z-ki7r(m(deZ&8|P3id6pdOx4N_3I)inH(?Hbd#|+Z};{h_bKVg%Xzm4xof(? zJ{P14=lf;AQC1gXDP|~@k2H+e?x6u zJUU6{Z|oGNMd)>K0T^fhFAG3gL2vXQk}QAjH21xg+|LZ>tQea!e3OEkZn1d$OH_J$ zeW%xZmoTfCFY`9+M6g@$H9>aJv`0o^uiLNOL)DVRuKp8Ga}@CX!iRQ;ryh6-u8Vwh z7tw$lCN8u1vq+MaSI3at6Rxr7?y{9}Ht6%Ozw<9A?O-Wl)y0{n6r`8lZ<9_(zs{Q2 za{7(3{^A7Kl&y{z$}CyH^9}~0ig(AL;KnP@0W69iMJhz5D(+6sSQ{E%=-QBW@fJ~^3Z)70WDn8G)2R|&f`(%JV(fJp z-eBz%kURaw-kKLOm{}WrM%H9PUC1?Y<-1yB&ZeSZ z+*|&S5;8ICz-OAp7s^Hj9Q@Ttb=Xh8n6JrCXt1$qmV+D!0Nj+(o%?u^U^EHU?wVVn z+swxHt>q}1ifT7uZfh5vC%VBA_2m=Q+xko=Ab}4P_KshzCBMmeeLRM*Z*!qf@08t# zf+3BwmMDU-Jv}VVV)BGq-lzH(x>#(ge$o*eWS^=gDqb;oDbta_bl2VgiZ< zyzG&1RXyGyyOx3m^BOJ8(eA@1%DH{%F)AxA-_d`}af*Dx))gHe zZGWh!=T=qQ=0IE@hNoId9oUn;ZTI?}H<(u-t;7i80 z5)20F8rX-WJiKI>O$VTDy|yf`^HHz(cXZQ1RhYmVa)T7h8N=yDuC4EhY8ZKp~`adbQE) z`Fm?WqgFzYq*YE!WInt%l>ldo2Xc10?rPF!KDBX+*nSyDF9#C>%gXLbSIQ3h7<}1! zfPy135!QZeB66ZMu&{_Q+@#jS_MkQDVRF%(LJi~SiKACmPh((7Uwg6edamox&^vW> zcdQ$322eas;p{yG-6}u_>lG35kZ%bI6hr&^vvI6_1~f5pR71EDqh=`ra|C8`wVF8g z)t1$SKU8SFmk(?*Iy3>agUO$kJ|c(1016GqBzQ1atK)lwIoygHQM-V`(^QgQ#8ueT zmfT2n$oQ2oR4(OvZmh>!RQt0M4^j-ssDwgI%*mawbigCx-(fd=lE#1#E z>6YGc^5_uUy{^r&pRC#~=Yaj$X&H(uB@oC_rZb1pktI{L8GQp4eMVxsbq%WZRTZMr zQaH0oPQ3iqC0;{jQgAQe%jBE&4h@{6<5A}ahy~=v5`B3E@zLikbGOE}<9S}8;Xkr^ zi@NHvgfiHqG#0UBOrfCjBm=%&a^AVEMMUp8DA+vzHm?rR1(14?>(&tV)q`2}p9O(( zWGn$2p3At!fy3za0f}g1M+7plRRHY)r#MSzp9i~b({MLYd1)0Qv=hrdovb`HQ zCUv~n%ufKKL1-krVw;a}w!~VkyiQ@{UZMaxtcjC5YHG0PT1q*wfT9lraB}Jt)1IbI z9wDHJ#4wYLd*S?>^N@9$=gzO<*a^x8v z*$ko=h|M~`20;srxd^Ay!-#t8vNnEV#y?VMSnB6UYzv7UYLZa@$CIM5`tzB%RNJ)0 zJylm(Z_0|w442r<&2*O3!l!3|OdVpC9FL!c5$><^qAP`RLSrDf+PeDFNoXTcZJ zAXMG8&p;{_x{7qj!CN*@n2JXh#hEC1^n_G5d~gc>(0RC9N`H=F~KpUnzd1j<@6^MPf;eB zl4q)~g-oX8!y7Af3&;jT!k-u}cB{54bAeSI)j>`EwT_m&-bh|seVkf=v+WY7QuDYV z!#wCR2UswT%@zip(`Nd==FNe8iqJ^f>yb}B1dd6RLtFXv!wQJI=8Qa)=*;=SrGi)k z!&Bu&828ymF{a7kF5hlUa)rTgm6l3qoTF01m(>AxLq1?XnJ#@T${N>SQS+YHZ2`eq zWBRK??DeVVmQ@?pSs|wNV%L*?y}#Prjcmqwz$vdEBszZgh-PGL7p{mI#ek1(x{8wGO6>43c5L?I5^)U zT#eZ@+f(wX8W~1Fgt%>P+hHLjNT<(?t$Bzig)=07PU-{y`y!skn6yRHR%PAT zrDn<0Hes6WNM<5SYHUV!(X}}x&dCRqU!O41 z)*0yG$4BykDc6xwSDc#yycx=Y`y%}b%E4cR_?=Hg>1`!rxXC`tEACfC}%aso6?PdBVoe#OZ3)( zr0f$w!byKzp-IoyP3MZ=rGmD0a?dm#oiquF2I0$(o*9qlrBP`-hB=9o z>ix)H=70Qmo|}t(?#8W}n^&$?k5rszR?9ct#rci*w-XG6uRELbP`wP(_>+vtgAj;I z>ZmPK4f6`&_!f5Y*CubsFSyB`H;dSRtz)nGgzqIxy%|#8TP(lW{r^iTVHZTFba%J` z?PyUBVlT~&@~%IPiCudK7eU<6Boc$#Hsye6{;SBOuCdIC%#&*`lJYJcC%h3zX=jvf za9pf2L))~-g|R{N{Q@8=1%I=!CE_hFv~>8-TTSnId~O;Ikx~(JY;W0q`c{(N#ELZ7 zabx?~Mqsw1uN*Zx(P(|@{CwspE*ckK1+CoumJ@`v-VWiBI+1=oUHUHH*4km@d^4tRkImZZ>hhn~XsquejZ*YK`ye<;maaP96?;9TyYK-NK z+PExTF3cV)*1C*6TWwZu9&vhW$<}7>62vUQV{8lw+4^|<6p2ecOh&=c#&uaI+*zW( z7duNN#TvgCAhBvk(rISRfB7271=cmB?_Auzm3uu_1Ql%;;jGa^A|l)dM#D`7HxWSv zz5NazEA}(n)g7hi@l-Fbz|~a)$>ul4yV+--`Mv+-J2+@LIW${VDRAbi?1-KHTsU>` zT>@#ih7o|G6c8#E!VyWYWH5tZC4M;(wdl!;Hc^Q;2<2bdE%EN`iaAcTw=9?Bx3(ys zWHGd;8Daw&@gXt>m~ZkNl@AKGw4(!d=G{%q%F*q4)zOB9`5E@U4)2q;Ltk8|c5PTb z?9vh)7$y3A1*6)ayU&DG}_3Cf;A(#$e zRB8wvKV~u)yt@Umo(+33GnZ}T9Py#QK~B>+&DaHv^f4e{kS~% z1vWFXS^1k@u#IcuNca6*AK3dR*$O+=jm98J(2pQsVpu=+vIDuRMDa&TiJ#JqImsLI z{Cj8g{U@X{9SxnZs^ggLf~E$L)Idz0TI)`OMmNb1>|A2`of~WyJ=XHv!t8S$>(>6Y z{wRg`P!$opVL?)!bpfuIsMr39q1pE{n{FCWFz+@Re^;?or?{kG`NYO%^CuG!&pJ>p z+h{q;SegB7U*WyT+<$>VvbiE9g;m=grpV4`xQxtXvbIWnKrf+p0uShk{`4OzZGRX= zEH4@Fv1QXzf2w~g8X2v{GjzK3FOJwY+{ z?{A*+w^2I!`e;+deMfohM<6%U;*O%mh3ieco+5hX&eTFCZ=O&L*UuCYk(<$eM&RS) z4}Qs*x2P8g9ZLp0n7_-Bw1(y%_C?!9AKlLhDmZC4j&QcBJiKJl*;|_lx@N(hKHi#l zF?l-y`TGi>Fbi8m-cB-VbbCs~!&CLDlSwzRYiQ$o5z}3eX(_ph3+Hdm z*ff?jSj;HRw1z8qyJLS#0xr7LCV=-lT_UOILkRaLhJxwW90P}O zT5e3?aSN1SYcT52U@9P`x)iWPxs}|di_GqJZt`Z_Mb5cVOG!blSJ<>$&3J0PO6LbH z?J|jtsFB8H*%lEoJ`XBPe6SO+-3|;5`MojF+H-6C=YGHN661o3T+yH7Aq|sb=+Aem zJh34_#e6GI4<++_Q?Tw!Y4B?4*vyF@dsG(M{7Hl(rC_q+&WyEzZf%ikxp#;iF~yyW z9BYN42Nae0$NOayOrv6f1 zw$Rt8Zf7QidimqS&4RHSM(|ofMYyeE^3i#>JkMW>#Vy0PbM;8U}V|W~JLG2D)9$`j({=mQ|B!Q){7D>sL znWxgP#w}%W-YGU8Sk@CeRvaxMR(EgUzbdKN zON#iVR5U#BZY(Id00VqkH2@}Gxm0`ac4oU#Caq@2u}!%-wOJysc_j3Pg%2Z*W4g0& zTzgS{5o}9iCb6?dDjdlx?#5Lg&=uq7)BM?7l-~JPx(eOS3LC?9Bu~Q>-PG{)8nO|| z0Lj!%ItJJ8>iR#}d+(^Gx_93f8;S@DQlu&+^dh|pzS5IWB=jy2N`M3b>BWZhmO=?t zN(dbZflvgM4gr+jL8{WFBPibdPT9YA-@DHk`|PvNz2l5=|H*j5T63+HWKHH=&-^~0 zZ^>iW!Q4D#%bPsGAnX9l7P(PhI76&!)d%k@vHMR7gzQZtejiZ<&8(r7v{R?n>@3sl zZM5IN6sT+MACB)EPk)Y7L~UAX2<@}DNr-E(ee(8fdv8t{X@yJVX4d-#wc)?HV zVZ0Oao(Fmg9GjInODPe7YwPd4`#w5yt`zsE*Gndf+nD#-ZSGtyKC*SW%zWag6i1S@E ze7am<&eiApuFfW!q}8Tbd~!dRkj1n#7G-dg?|~-BryRk5sCcn0FT35V7UyfFyiqithBZ>H=e5@ z`B#xKJt0GEr3c+nGucsp=JkH8XH^^K5q=ph=K{@sv{*Z~V*L1Q$mXS#8)4~+0V>nI z+W)5fPz(&Hn3|}BEqjK1ACVjUeoijTyk+J+dsxoZF!SsCj5TBlQR5570v(B;U-^Uh zJWp`VxnbJ13|oxl3Z9nwXsl9x8okr^j4w|phjX0Bkr8!p!JBSls$rX>dODu{X&DZ;~a)F;P zG4|3`*y#;#He8f@WdtD^TS~(U^EFXKw!EA52nE8m&q;2R#Y13VI)?y}kKR?lOGuM! zH(4I5rX4+76ZTS-yX@=Oou$bg3Ok=d-j%Se-jibsDzN9nXIy3u?Y+?jC}^r6HWFRd zdGngYMqxtBU>F5z5ufRj4;PmBjDnD@MW11FJN%Ri;q140SHS{-Gcy%)-Wc0Aq2~Iv zCH*Zc?hsCXzM;zgkl?F3=6;dd7wlgGt4hJ#n){ODSz7}Vg&=7`uz$1D#$1rRaE%UDu zx>V}S+IEu%Y2lvm$;x0o` zmH_8W*5X+yw3ZW+b8WM|v_SMH|0V#}Ub`UKY0RJ0XcF;(xicZ3PtE0(_|IFzCVmf- z`Vf0sb${pov)4LTNVNEt`Bf>kl2Mg=HKa+`y)c8`|x}p8nxO*vGV>Bom)jB9EQC70gPmE zVk*1B*!ttCTVZ+oGBS_`XLo7^)T;8!SD#!;^qm4%u*?2tv|%mM+z8o7r*@6dgaOTs z{uU2elx_x)0gFZIzNL!eJy^o`;g0YsFz86>Ok$;m?_D_1uc-$o@d>mTkt-)I{D_T> z;a-f&-9o3&YlmcIc`px9dQ(}0D3Q3AEhYh z)hRRV!Wu1lSD$WMeVXa9gvhM(&l}w*2I3fscRi!o5`|3Sg)#%f|G@>r2}Ka7u2mTl z1=2v}hN7=2PGF|E@CS2Bc5#+Q@sm}~HdFDv*09u}5FCwM*?>7y#)ykeq`;*@#?`-G zqugnI?#*#~JXG{)c@Q}^W#>m)(sDRN3T16N)(`w*(wZWm`D9bOBwt(9tqQS>l)9nf6;Mwk0U;FhaBKe&^Nymw$~61u^U~km7&Ga>akrE`MA2{9kov{STDV zjW5entY{A86+Tc8ygN}ACA?>(wJnd~WOU!0(q8FtpLP^K>+3eAyFYg5j&N8aKPF}x zOPfDwIqb3G}GKz}Y9Bhf`lFV7XU_p{vy8qE!!F*+{MS^E{Csd-hK zii%F&HP!fQV8WP3vg)MS;9udL5b}_|>}#v&xFs3hiGRG@C7;p-4tct~rdg-jKk@N< zQZv~gVQ4nYNY`OHk^8zQ@=XTK)yXdS8-CKH0C@U&;vcF*Nt}{P-IM#ou~$P^!t^Uf zn4tr@6ykzU9-gKvBWW_vy0Jk0S_8W=iC1QatJCzQ6I(c|$4e`-`=;`vpH1Hn!%>0K zOD=Ogf2cAjR_)H;rSDw{SO;cM(&r^=neXCiWmIm}Vtqr4OhOC#QyAH8V_odtFzVa3 zhjkiYxz9HFx1Noc(cO%1GF_uLM?>1HY$MJ677Oz2;PHRHP(MpGRGo$v?!Y6U0`Bp4FNO#CF~ux66n9 z9!P~EzURKQ`{YA#Cf~H;S-HO$C=7FrVhnJSIruU2n+Z-iM$?|UjFZ()33Wc!n$vbP9Z$^r<_57Nq!mp1>uAt7$lpek{<{0g zk)fW*;jzUDYG43}sK&ulDb^?kQwdsYiFU3<89}C6={c%7YY}cyNz2|#=ot7>K_32A z-RQBN$|N3Tc>CotO^Y;J$X?(ds4?81mQ!<%@d1xDBeC34s;q zxQ@)5JoXdr18z!7#OY)ki4X=4QrfXK8H=`MLbF1N7{-194`~QVc4wZLlI-SUS?7E+uWxrd(a( zQ6Sv>$Vmz}+TH!Nfjag<3STQn`bJseo1h|?jLwzj>bo;_&%Zo`8~gpV)ZXS_sf>sA zY%()9O<$^x1B|e-2eq^b$@)j?d`O4po^>`|SW}VDbJeKUtGbu}uH#wu?FxbWt=U_G zGxbYR%rLnA?&hQ*oo0ch(1`2m)*Mv?@vez1E3kG0*9KiO)FI?o z`#uxkiU^?T%#s6$Ky;5FH+lr1DP@i_>~n9QWvY!e(>tVqig(Qk@$cUx zZoYy;2g`?C-k@%Lu|7rscP4I}mxmw04S$VQQ>bitTyAE0hl+s`IU`kgk!MSIV`I*( z^M1>%>#UALp@N^zxPJPFKX)aE{hB?OlJN*<@F=l*h=u)TijKEfmL+WDH;KE0h5W|) zh2=6JIK}|AeSYk@1_Sq%N8@I9wGtO4YV+tvhBRZW@WujPmnBq$z%Kb`mV#KE$}g=C zU+-N(|4j;koKL)D_b{U@_i*9-sQaSpzhm&e%=J_GRogPk?nnf&2K;fi6 zLo6tcef(ZA+N^CT$6A~;T?a4GCi1vHLYY*=qg-@IPcKq+$+<$d5kJa}g+@d4!aM&^ zF~+4B3R$$tu(W5gLZXuc^!)OO@_L|Ik4ccs%m_4R%_q~Y#KmlJn7aa#>Z5e*{rIf% zUcGv`uxjBUDx^u_0apzOJh&h<50Ce=@V|PMi$o-+4%T=jYg8sF%Xpy&^9A?>BBZ#q zroJ%KY{)AL}R6n*M^P1&mI}+a;S2 zU{I@dq;}{?GqU}Ll|eZVD9?$fM|S8OStf>H(J^K=y#K4ALqtO=Jz)}OEx%FK4WmPi zXy%)(%X;REXSdX>tC|JHI?5ajUGb2=<+&rt8NPN?gw96Bp;i$MSMmKOj`Ivbq7nSf z56I6kZrp4sde!ezp(Pn_m36gCl`bd&GP zod#=n)43uSiN3T1i^V!)YvrT4TzY#MImvhk*gzhKN-l!T2Q&1gYE{1^|F!;P{arow z@+SCRz2gV&ml)_ii?^le17JJe5~j-se6YvCC&xqBR=?KQ4+4sYQp9TCwY?SYB)qhG zXl$FR)i&SsBNZ~V`hhDAOmO>5e431}R}5hge2Ham>QNEh&aI~1=<+o64x+9Mej+dx z$v2g_I!^-UJyF4C;97^CS|%u+kakQHFb}yWR#jUG&7?2m&1J9lPannFS7z6&6=(*YGc|X zyzSj|of(vxfJ>j z01%#}2Ro|PzsuGJVo6kH*c`uK)FS9wjX^Al3>WYw26dIJPX4;L9b&Te8HeD7Oe6kEfV?{@t?zAnG%|=F9a5 zk#ggcX3l6^3%enWg5X}zqWqaqobXTm2Sb*Lk;yymKiP6G0nsgh@J0mMS(bLCus(HC9nyPiF$)Yw(MT|`d7}8KWP{8hV|OzB!I)kR-M;0a-Kn|An2pJCU#mRzY2T^Q*_#Yf)0=@=GKX6o za!6Zfh17kScBm^XZ;}|bgPL8NR-kMC0_I2kT0kZq{Gp0_Y#0TY-XL4}5q1EdDzl2a z*p;TLvM^y4n0f+0c$>PmL=(aZ5VrHWr3ACJ7&do85y+m`kC;8nA>mGm;^XWJ5^uGP zX(lp337(1z&(!uSBGXHYZyKlD*MP8}z)E>m%kEn|R@`pA- zQx!Da8A&clh^+hNFqRqMRWR)xvj$6!ZJ_OA$K4YJ2TZjbe9u= zo8W2&Fk)b@}Um)DO0(9v@OOYTcL^^RChil_ zCXyZ4YH|f_Ainxw=jw8SRr*$R6U5nlq0Pzs`Jy?CQV!?xnQ8s|51XTV#uZz<-cl3t zQ|IGzWEfZ*C63W1hZJ|mC&lyzhjsJ9Mz^uBBXa?L>c?pwcgq`7$x^TlXoZgCC;A66 zVc+OPA-AGtWl^Rx#NCOHvm@*N;OOrIxi;VA_9XYSO65G6#?>`EMVyJzo%_Y&CwWEF zpu66nhZ>%gGaLceM3r+=f&}UkGeKL0P!|936uOTp*VZ)LOXR0BdAg1l^#I2zyq1Up zX~WFM7yz$33wHe5mXTQ1*6zj)m#zinCid4)auXN2f~AKv&kTq#V`;8O$hSw!+Gqd+ zn{OcA;Eo#GgfdzUk1%`jrR!H!&EMVo-E219hLXTM>|5^&#~SFN7|nX~%j>7>26 zcutz$z&l&D#+o0V@&cwx+8cB!jxNN4=<%$I!UryBqr7JFONMP1A^>7L=LIVY4CgWO zxQ;MSn-vikv*;79%X*iS-s^pARx??b31SNxt%66RpcH!zNa$=XXhJ+j?n-%@&`VwY zAzpnkC8n_|dpiF5Llmr-?F=}R1 zUuuCC^$iuG`;}4BYJ0%D{%1_qgHOO!>j(DGkkAXM z4QsO+SsYtDlJJ0x70BHU!eY~-8vh?MD}|x9*w|^0z+{&x8_PTqv)OicN;TX35LAcs z_5Rhj*X(t@4#&t4-wDfAg~U!=f-e_QL|#h*lFu-zVX44OuMU$S;wTb2b|hhJ&D)%7 zwDs^|rII#{0zdI1;YMDR>?whB+O0zBajbI_kGrur(1!4X#I()`(Odn ztg~EY?_EFqCSN|nG8I+yc|yI*Z8nObIPpQOBB7@zsiMYPR|BQQk6H>_u5^d^^KVE6 z{nmj_bQ)q7re2~ySLMV)m*SVb-4+augrO@)g1*+*s3!5`t_X$#WEqN%KaGkdJrJ0E zoq>o&-+3}`?=q;oAeixoY8C$+c&^T1hFpdaPRo5hcOrC2WkuHJR;JMV9+vMig>n8K z&O6j_@xA!M+-#0MyEANY9j6XN(N*rup+PBY5}&JK zoR5bg+>;!|CuM#aeKDGsdoSW)b0zB%H9f6TJDk$hx?_P6eK@lF1oJnE+17X75%Nhi z4xNvO#R0UI0D{p`@ec2N5UmCeRIv2)v&6X39<9X}!m_4gzN15+jF3-$#p8%;KXl^ zyHD5u`j?sa6~sDkQRP`lOV*mi#?nAlHlJjYfNW$>XAaCHkukY2*Bgn%vfbGrJ~ybn zspEr9zbxE83v=kSDHSXN$-FCgmWn!2`QRItfN_UwhrC6>WF9Jn``Wae{&#(k*!}-{ zu8Y_HR=59;Cx(Ak@&AX4^|x~!uzE`D8qm7zM|H&9JfQRh`TWz#>+9m4LWf`OfTi+@ zk!*0a2p)b-OxiLpFV7}5#fHbVnzgX2TM6e$`m&a4-oRpXuokg&Xz@l<1>8~d#zwdC z1>PPyvauuV4zZPXi_ztKdc==19Gr7vvDXXajGe5$)6kvQhsN}fmnJ*Xr+vPD4n-N< z77;i>hw#V64psHK&Jxi;lo|AhJ zHaC}T82N@LdGldM^chp>welFbkLVulf}qL=y4^i6X^LVk_|8^X1(7206-@*&Nn&G-aSzZzM&gVR>`8}9QH$p|*AJisaRIFho!zG)Y%#aJ6b-|`WJ|32yzHcc z?wD#DqxED}eYRrrr7Z159A^^P!3mkm!$RJzk9fp0#-Knx%Yf8njfXd5R^-_w0t4h= zie)-SnLh3!GXQ-OoOk>pTiXQ%*tFkasDkLmps3%>@HboGuR({M8nA}~WRHnnmbN3) z(J>)+4g%YvZs@eJ`)r8dl8nK(mg!CBvmRxOk9(f4=8g<>Fdc~P0oA_JlbTFTjf~#t zq2o)QeSO{W@t6XGULz#KuI!QdR0lqAQr$FRBe5uyX_8p{7^=tLYChBRbOIiR7Ti^A z5#CliUz)aRwsU6hx}>j059^%e?5Xg>B+xQ zCB1HyBbHM{W|Y1)zl||WF0=uW*%AYELkWGoyYkGcrBY&NJS@ES{rX;?T7m3avxojZ zbS;BvQ_19JOeDW7!P>Bn_?sMK1<9A*-k#|0?u(540OH~GjI&7+`_SW=T@_vtITOv*Av&0e`qaK&nMn8Dx5ewD1B^D{RGs?7VEK-F7#&m?GTE<}xVqkX$3_@4g zLod`9os2_~wx`1v`kjMU5>03S>U85TwMGAA5>cYw3eArNc`x%E4GO-L#wVw+{QzK* z$ZgwBt%^^Nrx|1078U@kL4lH*o0$evf>4|b+N`-Rwx$)I1Gj~J7{#-G@dCgM#2v1u z61rM(8ihEtdL)-spl%FL>eSQhNq!fXNc0>U7*N(USf|bWn%oBQ9?xD5K;G)*>qu}b z?$t9J{?1=g^td)>npKdgW+Lvxkc&alqDTumfn&_=U7{hDrxzBj`5M}woW9MW8FL*N zufJniWPmX(!%mM;r0T>#Uv&C2HD^U3zwnV! zF73S)yyp)VHPjy)JluDz9nVNzj>*$T1$`xLoSe;V`UYb2N;*#w4H+P?c$|BIq8#L1>pc=h)=ub9QLF`(8r_x*IFg26P zc>#1d{P9DTPd1wtmBA1L_iVcj0XBrloQ9iKUte-$_ej`c5Y=TnZ#-|{Z@(n>$qib;us za0NRSsY-~t@?2$n^nR%byu9E^n8CpKD3bbH1=*^04>NV@o*P*|u#Fye zOV3~hxr`LLoGkM~qra$IQ(Xy57%~=jhEYs6Rh5UYI;A%XJ+ZoeDnI&Mk!Mp^I)cu_TB`{hb3PDWH-(J zYp$o|&{N6TaYIHPvw0_|#|>WN2Nax=HiqX78T1}BHEe6bOi)zUDS8E{2bWRX zxt~ieNp{gB7<6vnShq_=%fx(c zfL5Ma%8ed+1e_ozG`yPxXTz5D5=$tiA>bsTp;M-@c1;_3IWLEg7j90v65)_h@>=z9!R1>NPpo0u1AWnxwu=F`H2zRM z*OMHSty;SA-<8Gx6PIy;+mH3Q#XVpZKE5qU_x||~-5;uA`(rvCqvs*3_;8om@Wmg~ zUFSdMPv88ZdV1;0zx&YtM<%%4e6yya40dCJ{6QPtFBv>#p=~05U0gxS(quRFkbbaV z`@I&5VIY<@aGu^C;_cx%d^;_E6xfxiwZ1!q8@C$eqtkUXQk^ zmbu4I;#Vn|-l^y44tHBl@&+54FAr7f5QQ4_@0Mf33Y}>zqo<(^X%e>6H}2Zq-~8R@ zCwrK-Yn)24vXkBvO}T^Xa4yR7KEUa8XG+XccrDQw94l9Mk4{^V=X^hV2KsJUKGQy`XKkd1~3KpMwdD%6*;r@cOx5J0h z({2mj047)Ti08Gb_g`r!0fI2VNdf1$qS?QO*J=Hz&xHeYw{>8y)Y**F>aoVR`sW~e zL$05wQ{#re8%I;-i%a{j+;DYRd+mVhw3oU6*iAqW({CCO zC-hZue5B6?j$QO6t(o38{OP;iTvyrJ1MC}RP#B-v*kez3-u=26--&N}@`{yLF5T|h zd6yA$5O80@!^PCWj=F8T16Y`%8Uda&jsvh|3@up;pj{2z-t zUD}J>AAEODl4@At%*CYX_H}ooemNR)Z!bwhVzfwCj79{)(#h?x?|hE35Z^oU6q$_| z)6dRohlVnQm_?5|$|JeBaC-Z$q-?4-MvS!7V#b!U#)4UXR{xqM=m?f(Wuw07ofw*f{6XN}1w-6qXjLcG1D!Um0ZN#QswbS_# z^(x~*CrvHS7)ALbSAj!d@Xp*FVcOL>n~!2c1u| zzr)jav0(!3`?H#_iv1^V-ufh7zVR-vN4sE=pmYSB}3T2T%A$I!cs%Hsx9bi%G!2@iAsX*aLLbLskj@C zr{Y`#T<-p(hM*TE)k>wD(XsG}HNOHY{SpIsM|*||P+Dz?F9}@K*O)Pf=WmmP7qi~~ zYNLgf4!fYIuxt(1EZJuV*?VH2&A?D@?+@f7Pf8>hjxT(qnVD?$xC@ca5;9OP1y6x$O9)xtm5O%+0pYM$oUwf!@)xYg>@EN(E02 z#hXXBnHH~|@X)Cc=v-O^aQrBDIfiR^j{mt)rj)Qx_ejvTrUN<** z6@BDlhIO=$#_;D@%o{dg2?ivVMw_G~9U8pP(Icf-z*?MUuK(i-M)9N(X>;)KnqYKZ zmu6v>9K#-vtzu-;xkp4c`BDJhs$%IjFS(>(;h4Ev%EQzozt!?yM#efj+~5bVjBJM4 z0^Vuf$KWjW7uYT_@139rbY6=7_Y@SUC&Z#ZT3;l-B7FWy>=W<)vakUHD2soE(k2+a zP0;Wv$_R0K`KY_p6?(%;qy9rpV?0sACg?_yXBxja-e$|hAcJWwj}~M-dF_$+42e_D z;KeHOgkCg4XQb3m)L{8j2t#T)ydSG+QKbbEtb=spGO&T&Ba_tx6qt7&{$jp+UD9yk zr6Er}X-`aI%XzR9?@}^qArR^^MoPC(u+!Kv$E#8UDD<5^14P#(7;;xJLAVU z`J#-jNB}%pr)ksNA^ZqREhsiXH0k-Jyb-0PjDb~;u$T2ydskg2XNx)`_2ir&38`3E zIw33M)2a{r8q7fFNdt}=rx@b>m@&N2gv}zDv^pPa!BWxxwW=IKE-X z4N$2(4&y9F%Z!$8}Lrg3Um@w(u1nPGwdVfa8frr?G z>IRQP3ZuziTz85yXq|GPmd|!-u6eu5SSgo^>UxcoYAm-aAsP$ij}aKOmz)6DT|LtX zl?q;lYQOFnNz7PqmzmT%6D^%&^b#)>%d2+QGrsq3G~@wC9N@K|8q-jgk8#vD1(K(A zz+22)ep{6hb~^n|G{z|=b;c)6Q@&h0S>w>*~Ig$qvf7TL!yUUIzdLE zF3QPN-VNJWlT}3n!{{-^Ikj!mqn_bBZHnaQr0%u%qJ%0*U=of)CHcwba3JBtpcl`| zVjoZ0O}Y^4uasMNGnCop7qm{6;Eg>M@wfH|%{CKf_vAbcgu~sz&_a@o(g+p%^}b72 z3iXwRpY|#rEENYeMs!rc@~bF`%fe3petkv?t&@L?yfouzMEP3o4tzUm}VZdswCC6^28EN}9@bFD}^z*CD@}^D_Et__l zl^tM+KFEubDU+G@Ik>6UWcWk%uOxPXpf`_~Jv9PI@uRiA(nI9j{VxVM&6I#(U9XQ9 zdhB5I=;cG^12%#5MgQ6q3E_!bMZ?-b(Q!uGIN7N$ut!+zPJ3L~S!aK|D198JDNDOU zc(OY;+H`xGbfvJ*N0^OG8XxnZpCDU!g56q83wS#F_v!w{_Zw;Rw!=Dyn>8mQ9g+X4 zjPyU8X4aD%h~3xlBlC@R?ULC}gK{+^wrbx%<$6p3<wo3ZyhZ>Ap;CkADNQTw69!XUjU#!kM& zBL+(}3)tQHi2IQ^$=b-XP*`xL5X*){9<$wv8ywX!1go1_Re&_}`FJ6z=s#5D2`Xs^ zcYX&!4vzP#bbjZzME?Z%1i-as9Yp3&e2=;KNbjotQ1#rkI?kqMC=3+1;Oy#^=*0A6 zWunJ>F<8z+OtB3NhI#^Yfp=Y-pcTy=xo;_}LHR>uGe_rgi+>Ozt1HCsiKe;#&eEQB zmw7YWmBh*pAxWzlu}vhs5Bl4A{}xWR(e_%5W(P!-34@fZpRFDYmuZ)0gt9kWDtY1k z*f_E8o-cV?>N@4ujO%c1C zbJOn>c&ho}-($8PWxat4fQamAcuSY;?EadcOWF#+!*acL(2AAw(l9|1<`*g&RHZ7} zzw2r)Xs{vWg7k=g)K&CXwz8pvP4O)0N|!yfR&A0g1z%$+@A^rwn=relX*Pb;?w|$){zh~>$~A=*B;9>zfBjLX)cVXTxe;0u zLXgEi$NSXBYl&Uz*MOUcQlc4ts(A?72aH6VbTQ%wV0N zdAPQULn_S`?*|bDd)azzH=UDY*<4WW;pQnS4${316X50&l0#P&9!@Xo>ZG_VzW^X4 z7OW1J3XX)P^hFQlEE#|fNDym(dMSp$`H5M7*CR+wB{=lkgQ0JwR!b$7T`$E&{GbN@ zQQat%w0IvGz5*Ve?`^(&6no0^&yY7Zw9sS$O#GHAPuMgb#6f?{pVlJAN31Vk$%L}z zUn^_X1zoanMoa6IdO+yd7nfQsS0@q8af#KQ@DLxqZg?d_>MKV<7lXQ-{u*g%z+K*H z=lr$QvT=v{mb+ZN0S-==HvyflyqOo|kv%ke0keLQD%vBIC9Ee6JR!g5y>HF^?Z*FG zXb@UhCMW#0XcGml9_XDswl+O4tZ?mbBzsoZGJfo^G_@OAKQYHAY!9RXpPuPmH5A_E zzjH6?r?eDQp)^{4LX_aao_2UOYq3VEeffUgpcC_$Fuwr;hc+$^TV}u# zy&kMCPt`$yEqgnFyQV*Rm$ygS;NKG3A7=@7GV1qAC^zw^3^gqkZ9kwOb#45)1Jycc z@B(As!aDM(E*z|V{rkA`pC8`{p)q*$Qo!SJp`MMu*}9X?($sqx5X-}vAiNynX^mxB zwZ;Z(|3!6gE<D=wpRvsvj+`JD+07Em#ZQjtzBif)C3wB$leAw{}0%-R4gApO#vU>yQ?ylvrUqn3vtLU3VJ1B_ET`|XH&bh6 z!}_{mF0Z@^`~KbnIotc0?W`q0aF~r7x}p$8a+6`1F=FW?GJ5EtTz4tFd}R3T+ix$S zM)gPnt@lGXyhb>nn& z9deL80w8qrmC9#TcAnxX&o5^QNI@j_^p?(CI9iHdFKNO1ZrtZ6&#BCnx-*2e*SoKc zVMB%t$|?ykg~_XJxlZNEU9H?SUuQokWCetiGfuXq;Kw@O=a(if@VxkEfB63#L)h8b zl5;uJ8aB9~9yyeYk3UW`sHULwt zoVIwSiiehrZ20p!d}Q|HlRh98+7^FXgeCjwd*qenC(g-El1oe9gD~#c0J>B=?Cs(9 zCi%IC(MiujLyj*vepGKfu9!@{lHw3-jthKtRrei8>jwkAoo|J;J7L|EYhwK?`12mc5-8@K|)nO@Rhdn3pe$LZQvGoM%8**FOKK0PH4zW)84iud101 zs*TdYiLc`0o_sU4TgFNm(btVlj?)>mq>X9Aaq02wm?4{|F|E9h_GVrX%q;1yC}Xvn zS3;R}L1R5YRAfSXo&YW*hz+5Ml2L8GY@L1*B%q94FEu^%)>mg)EZ{8kF9FerSS4_5 zYf?PHa%L%3LY?YGtJx~wQUR{`B-pT}i71mJlhGb_XfgAt)QR17Y=0ifR-yM%JvNCA za5)9ns^)G5dGl;+iUX;HSJE2kS3{76`ebSvy2}c>6qy_8(-C!5tLrv7uWAb0pUiL-t@UXDxodnE#A1%QIndQGO;mduw=Vm zHOo|(!KV-c(|+n8f{g90=zT5AXZ zWuz@jVA}^sgljbe7D37Ejk}06l&!CjH67E7ZVOW{=seUY%08w5JSx8b$}e9$y(Gik zRW0aoP#_douxg5u{5sRD2^qDez8s-tOsC;p4<$pgUBmZ+VBlCIu|Cqc`r3vOTTBTg zx03)c2?i(u$pQck$Ji1PAVaXefE|kBSU2+CL%-N%db&q5 z#wcA7Tkf#hYg1z!0~dx}H}Yy6i`e--tJM(uB_~Cae4+VmW@NPkbbvK}ZB}&P0~-jz z#=up++Bbewi(1t+!fw_E3P0sr&arOVy3@ZGrq6E!^N>zQmL%`ROnR0eS%4)GJrXwp zySM1o1MA!L#cR|Vwbw^93Eu9yEWF1LTX*A5_3Zvo0bWv1^Z9w2wgnA=QD&Hoq?k~x z*}8Al>O z92A%mx?jW+xrvhf+3s4mGXI@9LoLDOfXOfQ4t4G_o#9JXfnK_%$yzb;bAD4_)7py6 zr&&Vrs0r^yLnshVB-+$h*zP@Rrq1s=g*JWZ6G-K|DoUTCp4f5Ca|LK4Cu>mQTDJlD zjI7XEQ|lTFu9?0g@2Q25b#B=hC?1`8^)(^@0mU1&bTnYDRS1u)L8`+H28c6zWgFH} z6+WCnP1mySWqPx8fN8BOZ%foLr7k5tRZyEdORP)5-ulLK@U2veh<$IrsZ>17w>I0( ztR5vV{kRM~%^2P*dUc8sQj`wOguBa;RulXdtdjKOh6Z_8w%Q(Gsr5+4Gt^^7slq$;`OAWpn%r}P#j14ywEKBs6Uv$J zBhyM~Z+my59P%j5l($n`)v9d=cNt4-bNea24>Ce7D(Xv@-OiUSK4!L3@=A1O4=m>Z zOXla7Cp2L#Ib&mz$Ei(ledz{hFX6Fo0_r1+%xnZ#RE$a{RP%+{NtNo~Q;@!$o?8-b z_pD}@2yU(*XNeEifYls-k%PL2JyW{t`C1g0`x}b8^m@XOjq9~)NqrYxInodXtVYaGO*U{2ZpB4VMtBiU!y;D1ubR;2u zzHX8I7yIYxP>)^znnUeBRA28po{TSUxvnStq5A0g@k03-Yv%`6kB25+=NFD76T&6N z!i(pBQTI{(r#E`Tf=Z?B;HA?P+NKDKrxGJXoqiQs$N#ID>i_s>{~vDg&3IF6k(%l# zv!As%DtE}-1AM=h2Zms9jE^ZASa%4ffR~r|AUzgcdAj8pVbG!csCW6}vj$?5XJlRF zqr%^u1&s?8S4lXL3G$aGG+!?>ETxrvFt)ynSXWC9Tv_)T8S9#EOe&d&5~m z4-9E~xTm7YXiD;h5g0==G8MhH#epAVm0Tjz&3B zJ>0iaanUM$Kl*7>A4LBFLwg-uAT&Xn>OA>oCi3*=mxnp*q!GIhX3x&5(#^nWS|NfT z3Ciz7HjDCEUzB|m6n~JlIpn@M-i}EGs*-EPlTbkQKqug7*xDt1Y1=2C;OIo%2LUzV z=bk;OeL%ladA(jAsYd8}>M8d8xGPPKAF54NL-jCcl*`cXrXRS%f~&}+(6H-ZO4|b* zlt70zr$rLvU#E6e|!kSkV&Pz5TNG zyT@65$KGq~v*nZX@q_UsQ|@V*^O?6?SB7Hu7D)6>PWe}DTLre90jnZTjp#`)0~L<7 zgjCL{*nnN?ZC~sLr4`(48oz~;yIcePs7ciXkm8=;@YDRSTu)y6de`x1Yvt2e!{iLooP@o+1ssYJD46GWE2)8(?u^W&IBJ)amv(_15jN3JM$LJvA2^TMa|WR@V2@QC=_q;MWYeG$!~3 zcx7dJ#s$tId9l|xjh+(-4y3z+BX4r&KXXXP>y4=+*2pW2j8GovL$UfH)#))*lqIe1 z>r_9}?1WORsI67@qK_UrKNV^lpE=`_*Z#1TF?`jAx|_HC1E&=eh>JUS--yGW5EYD+ z?ghYsmQWN*XhL5%>VAQRG0J4EAl)D;!H^0;;(sOHceNw`(&*D8g}=5%ncw!&n_f3= z|F)O@wvXb@$(~xW@yJlq&v!oVaTfwvN#tI`}EHKC&%%6R+CRaD6`&jD`b@uFB#CD)efu!ufcJx z+6mK|bjjC9u|SUAJ$d`LXVTv!{1>2D?i&|fbe)vgOCpmk#q0|`Ddhjry&6vZe< zHnNmqJYTs%vguc#j&~ZxXY&}cfbL+^QflcZpu~HUpLsC79)t`siP}ZEgGD^fO=WOk+FK936l=;X)mgP744Gb)EiD?`u-vQYny{1;Jnc5f3nd2Y6tM)s^Ayk^v%SuMf(N2 zx7shj&VE@2D)2~M2ym0+_lmMsrGEjMdyu}sUw{$t6yOiFuYtb+UeOLGO1}Wv(MS%Q zjn7j5A+2v$eu?IpeaJ$n!Eyj0QOvTGYU2z8@eam6wxtjH88^l^*|5F4R_I@VL3@T% zP9l?dn*w!`qZ<57-SO*j2T_r0w~_D6M5P{lDOy&8JjCb>C`%m7o56d#F_NNCPfj)O z<>XADFOwnJG`{cnXnI&}-ii=bg;RhUmJ35lEejw5WT}$##UBuMZ|rmSntIZ){Bb-o zaS^V{fizUI6--eKLtDUR`@-ta!fw0}^Fpf;8M9;OeLv2y4p|8!EDROB@$5U-J)@Bm z8)~k;x(U&J&5=o7pTJ1HQ}tv*wPbYCgm*`x>{M{!yc2(zWwTqR=E7Bs7 zm)P?89vo%|h57eCkMDv{Z@1Ic^Io6NF{1}6?bpm(8}Y)zbY(({ijNNhuDIub;*fQ8Ws>4h z>oP=a0eZ2`n${aup$mrJNFw{TQBAb|@Tg{8hrE(%>zakp+yl+jz)OD8pW9;HS;;0b zRwNhJHC-h~sGoJDA2YgUIOgW{q7z`mPVA8$R4m~v)@@y2lXrj7>HHhH zH=Eo&Sj9odopdfMWFPR2`|_lEe!5b@YQ;KEaJWOch^@e2&L?4dRrl;nRqK(WmlwU+ z)UcnKR);yl1asDRNJ@C8oEzI8MC3qd3VBx~;(;tt{VlN#cwKkAFDW)ZH@Dbt+(+W@ z#8YvjdiKme%H#V%NiO}s(8cGQoq!5ybGkQv#T)l(_}eNOetQN~e&!mU>NR2DY?9d; z*e$*H+cSXs>HWhKzRA$9PZ$kb*CGcK^$IQN`s@(~!7kU{z zQDqxx@L(@Dk=Yz{b#E4MI1$c0xOuMt8a6}%TV&&^g-!byL=_Kx;gbF=ztk6bLAdvT zl<}Hu<9trIkJp1)VwjtMPNvTQtY2S)P~zzcOH3IfAi?8jA3@m(QWIOFjMw*xl>c?c zadb0*t+Hx?MUd<5@8V&U!@ru%3*Ynlacp3^4 z`J>g9$twG129t+!Auepyl>SP4ZeuFy1k-YZWTSq;zKmhZ$nY9nB^Kk9)V&GU70s8r zCW*Jm%IDcyk{C_MtxSBaG{kcK2awB5{gf!Vt8koKYU%3*x35ij@H4bRW{p;U3w~E0 zbdWU(&(P~NNb+PC;ISw=oe~~2IW?v=(9b!O_%X{#`_oG9R|EB4x94FephKA4!n_sO zFnPM8ExRU_vk8g4-S|2-3Qh?NrG5DeaC20lDR6V@M#jH4KL54vg2XbFG!N*=de@#} zz7^PUvza<}!C>p=oYRgQOXgAT2iz`#Lox52yMO&y_%b2xUkRuGLxDGi(1YZm zC8Da5Px#v7xQMd_mAoie!?LG_p<&ZL(E7O^49|&0e|U+xWWRgntpYr#&(s8IZdh2A z^2@NuZ*tdYZjlNxkJD{?inV;cUjV;PcS0!PfR`hvXHN0z=RJc?( zK%m*Eepit6XVB*xG^h=X0&t^_JWjFmd`SmB@5vYQYWHeaDRDDeh`mrVlfgnxHea_tkq zHTfreF&XI=Z>K^=5wzkR#8~ek+>v1#%*t$>;JSsbaimqBTLX$;sJZRZcBv-23*8lt zT_SoKKqi7zOl!)Q?$9LsQU`ellbb8#Ji6dztwx;q8qt^;tg3p>Ebj9ZP-t zQOH>oCSkLhx=Q1bjZ9HJX)dz`;`$3)yZKQZ<+6)O#Dzr%*7d$bpwg5Rf%?70et8Rh zWtf^AF#f{`O$9rK2^WXBXbS={Gta7g286$sN^rlW0@PFOtUWJ;@f4-?ZqfPp6+sa< z>{jyAx6}1S+q#TmJ%}|Dh+v8^gB~B=jjSl5IYHGFp;G^TQVe4#4{O=f!Dt4DOA>m& zX$ZA5U6+eIireuW`~)a8*d4J)j#IMo=ZYLl%t})sGu@n{jV*MDjH^>FA&x~rGnfxl zYaz|gn}~{vq6=&|$Mj>ON=*D!zCXC?9#`ZOrht?3x>Sy8>fTfhjgXw~6l^@p8BBQ) zN8V)69P1`%0077dUUS>fsnWzOlBIZ21IfAkden8v5U>}H2Gvm3m9h@ET5k&|XnLGG z3dzjO0-se-_m^6il*96p$i)dpDvWOFOi0P{jdM)$tfL%xS(b=JPHbKog~9#maf*Xo z$OGy>m-OR76(*@N0g6x5iTsBd{tJwD|6!f~lHva+WMTiN zv$vKK5?Cg1_h8uUz_1z9)nmd>B8@)NIZs&{2|UYRx#WV%c?FKIz&!WP@LAVZ%zJx# zLj7u9wvEW1z9sl^@4@o2Y@2?isZG}9=l%Vx1@5UQwJ)w0Z#CWMI6M2c3wNNHVGr1o z@U!vi7kaQwD*Xnq^Y(R{lz{kZ>YbAah898wYPy5Lpi4Qzd4TvQ+9$Em1m{*?{If%M zZ~eb7=*tF!k${2Wc6VgSnbNRWwyb0QN3aGw=ZJ_%v@!V>^2WJi&%7gV?UGJ#ZbBl< zDUj&mVeB&F#lH^n?^^Z0)3N_PAG-D?nQ^XL=g2pA92(u~#oy*Yc?A z1ztnLW~2_8)m@|%TEhW%j0Xh_rQ~4NW9BE&tnvXz2TV~8rw7W&`$e*>tDapqSA>x< z2RN52=;v%5+gNqI-bCtZQy_1?6Z2a=J&`oRMX^)fTwM+@_--E; zHMot6TTp&XoTlYkVd@T6p8J`)WCFdUj!mN|3KIPkqPfbiqXP^&nx!68J+H#0U(EXW zhI#fYd{L1#!hw1;1w|yM_Ic!N>EswCQV$wmy|D6~`uzJO{5PK;DQE;P?*s>Yq`Cg8 znBGN%&OE!!GR?hJcv73M-&D<&9zle#A)H7PFN8>L~*MCtT0ylLlcsPM8 zw>13H9h$X~>%{!eAHaKO6)d0KxYe^+f^#colJ(h$&En!gJs}z7ZqIul!@+j`FwE!N z#Xa$0H|iI_u05Moc>jWmw-}tFNYGR>r(|To0hR8svYeM@FsGbcI{t^@;|AgHMR2q< zK)>c?>6wZj;qSN1sjp1_)uPiQc-HzaW*~s<&%g5EsA?dlyl2BFB^OLDFDY^6laha! zPxzt(p6L8(J^|nYMgPjgxY|9c+uz22UHmgFS*nMxq8s}UW71o(cK8>wqZEc9E!`@( zUCX#eP;CoC-B${#J8v}^Kn>3;y+?@bayZ26&e7-}3(3A2a)!Y=^( z`EOOPib=*(H#M_tU?j@lv$2igW#G}v?tgUB(-d#Y>SNaRL8{FRn@w}Dh7Ef>pg8%@ zHr7AN+%$6hUo2mM+tR;;>^G$(PLk8q-@RL(;NTXlZ)bE!9uwK%1x!MQmq^ea&wlktZ5VALEy)`vk?t zVl-bSm(ey!=zh29rB`V|IMo(uej9CBn_BEt`Gf}B&ne6yyUm~dHy8h(#fjGc-RNBO zzZ=R6ko>aEaO{u9II zle@S6(+PKXA54C*^5&MXsrAyb8M3y$Uhs%6<`#M*ly_YT9 z-#!J2(OpjSId9encCY<;JpQhR`P)MOy@XrjqZ8#Zt(*2G>~(wQY^kG`67UpT@$Dvf z4im)5a^vaU7X&8lT1P{3YdK?B0@0V72Q*}<-71fquMwj&u=BIa8{?T3AZGKu@ zsBB_A4hj>`5iGi+kSLm;1~%hoelwa!6pvG$*&lf^ygBMUNHgaNTe;F@F2ONDg!ISi z4mio$ybg;RwR0E9G+1y;VLd4{XhoGj6`lV(vhKX+qQu7?)};8ijpObmxcv zHo9-d|AYB{g)4QkE3NrfkVP+@ERiM8eMp+sw_)4qBv*73AwDO;C!uM<`@?sEK zy-r+`k{cZ#N8jYm^)QOnJo#FOv{V17$xlD8EGg*IvXNQ+Lh@Vuy~F-8haO;{{u>-u zC^n{WnbQ31fg6MQy~>Z6&7|+~8i^+=c^1Af4X7^u3Do!G@trRRuQp1?*Ojkf?{NOi zq4^cM>HH_qdrd@EcgizyzDGGeHzM*UPN@g@@F*PTS1OvCAnj~YA$?%U!7Vp zx|$~{0ZKo^h*r=N8;?F4sTkpYewWCR;LC+6UY`+ol_$uB`=m>yut5Zi$C?eHV)G!P za+$^R`mJ+>tn?|ZCho)2?DRi09Y~BkhBd?}(2@!hcEt|wseRh)av)~iXEiI$-%-z# z4GdV~RTyzK<;hz?AYA^#-W(Z4$1<9GJOmpHkmBlx@5;nV`+4p!eeanLCsGC}#sn9h zH>_i5coCEHjbPO*+=ix{Wib9udGM7fHD1*wp>R6aq_Oph!f7$ zPBYO}idfzLX4?bE$+zKZq2_SIcsqWLzOhcX*+_~PO#_oDv!{Ah#zG@4o;%G77`vXK z4$GXV!o`!j*uE5|!}MuhcBD?1N=X9_59Ce_bz4SlTiY)8aFuJng`kI-1GqFoeu;Dj4r6vP&fzWaANwJ*FBRaFGIstP z#9|X7S|rez{=^#2bkxFcYBdnw>CB=E<+*bly1SZ6~m|i$<&g|+4I@63^4aGi$))*v}Vl#W3 zbosuX8d|Dx(ys6TzswIl;1|f~!a*mT@~eCUXr~!c9lXNYlZ`zjH>$O5EB#z!WXAzA z@-J3;gP#^$OjV{Z4LanKoWYoB`o_f(Yv?5Zvp3i0f1C*aX>BmBn24j%xX&DIRxsRx zRHuf0T(SWf|EX3rzbH8F7r;XzNXlwdy9)Mg#R>-4|gwMy3!Y$B2GE^xVOP=b)NXgh`o`jd0DuD4plUnp3;ye32)U`6!+1`kkl+-sf&^vnmLAID}{)Bqj-V8;SQm7mQjir4h?3}eVxX4kkdy+IBq=EJuXvDE~ zZqEXuFBnj6MwtqwnZpO?(~_+L`W|}C9H}I+jNz;w&huu2c6K0M8P@gMX0-~azVasmCV8Fz31 zT6H;ilakM|T)Z1gSZsDi+1Dlx4rEZj2U^{`=_oqtQk!QP*jiPPf@dd>Wu7Yk7IP7E zIbSDx2--cRcuN-4{&>a;;WsEtL*i?;M{SM=Y%dah zv=o^!F&+D1y9Erbn>b51KZmr7h2q=a8uQ zu>@CGB{lj;Eb&?VQ$L8Lb)!O@N9+ zJTB8mId+e7v1UHGR!IZv(&W%g=qgM3K_ggkncQ``6%Y1Y{R?j`RwsMu14WwMg5$O1 z;vXf-+{ZL>VzBa!uajEVcJw^Hv5JjB+o(K@kEW#&FfG@O7va>qN*NUa4awReXKO$Wp_Lj*MMSDQ%Y6_tJ~nC{DIRS@%@>az{f4Td$# zhu0|O)-`N+Ie~oxZnaJ2t7syY$wd8c2L&K^NXUt3e*w~P(LtYZ7wfI;9jcl|+1mH4 z)jc2-ad(uoj(coI zBCoXHgTBj7GSZAEEG!KF?4M@&|G3N2ElBDtdO6u~O~J2IO;<}-u`(f411eM7Z|7nd zcj`kd{1Q+oDCWC3KJ%cUx-_HLLSuRCa4Wx~t`>AIF$=C-@|A_`2}(LX?mJn5I9kO+ z!x9WQCFLdt+BQ{O9yn^2chROsnIpF(9UYS6%Pf@r?cL)jlR;H3+(^vb5s}ds^*T!^ zzf`7X0mYY|3u~|jux`G+@evnWUjZs0#`+*A6?DU4(Y+=@w&J zO025CUe`4n*(RvlPjYfav1GX{CZ0WqUt|~sX(+~Z-^3E4vhKBQJP|=k?JC~PSN9;) zkLvjXa)<=Bl=i0gR!1U;97`kIP7ci!W2urW=UL(gpNFlqjpRlvIn0=JDa>_R5scG@}8Eg=ZqcD=wz6_3nNFZrFDuRf)Ojsnw=nswyaEIpHUHq zjb<}5?|`1(<}cG{{7!lXBx$@#GpDFS?s2u`gS$>j!A>;0LhloHHf)`nFe)cs&!Qm^ z*u;AxBZ92*JxVl!Ab&O9p!9u&Wm4jP!6!s&Tx}#vet~B=hcLbJ-DWwMhP=rmC4IJ* zuX9vHr&LZfL>X%)Z|{&d_8o9@7QAqcO48<;L5tCnQ1mS2UEW#?Kb)3zp2}zRAC+Kr zV}-KpvoRxll+3nT?4~vI9iwCch4s z$w%)O8cHX0L$uaRZ<95x5Ob8}7=c0(U`iF0R{7zBeK?#MlrMQN3QzJfD%Q7KocImA z`v=ckvb#B4`>n;vzrMD$gsy{x_BuQOQ9lPA~oG>C`Int=;_H>eD^caZu zy8c>bN;$IVoZMF3-cEk%0|){r?!3;9w$q4~zvDk# z>U2~r=Sh=hpP`8#TUF%6=7^WNxlt$7Vbg3@(K~_fP7w4#^%ilFjiymF*s<1$LULf+<_s+N=O91PTpk)^ zvsOW8x0D{L$YgykHc1|7m|2_@Ku?G1v!LYPYw$eEjf1Z!%q#r3ESzbKw>6IOidJpS ztw13Y6ojP56@h635z8eClNo$Ht1RD@CVHEbH14 z2nL()8M;)P=~^z@A>$rL-!1TF)QsbXr&AQm_0DV|adEjge@zCj+#8w|{D`B; zldA7_q)psJrTIDc6BF8b^86TXM1(qIc!ScJq2gXE3gH+oSJj`kdEj`46r)~A)TFGs zeIWwq`9#+kZYe|_CbT>?^`c6U8+~YMW#lWbGKtp#)ybcRAP&_F4JE_+&3V5c-FUIz zG8%OERRPjid!_EhIE8XCH^-`lQ|$l;MQqW+2e|w?xvBvT7!2QY4xL3mGIT#s0hTScY@pz29On^E&tRs9A2hXJcy@$5m0ir<{Z34=U|5e z32uWYK3SR?r52~Tn?n+z_5lI&l_OsR=OW_#pieq@0L30Druix16K!SnnHy{T@vOXj z(PAA=oO;PBJaRbxC0{MB@RS!p{7)c2;QphVUi)*|zW`iQy2JaDi)9G^6Yh1L<5uv5 zWy94f(-Xul5!)ub7$xopFNKE5HtoZSrK%4+Kp6Z351=h&{v4rZlw{r z)Bo0rn+UnFw43p2N3m-AQ4!fR)UT3y2Uwu-aCNEGt`DL!BJY_}f?g*0vP3oRLs*dN zafqGjCah7W)I{m>Rs}=!@_TZ7>OIqHJHac84o=GL^D}SH%e~c3t7~!Bv+kM$lZ~A5 zbrs3@Kectf`LVj`0w=2XngE(V8s_AsnH8!XxCB4t_ScrzrHJZu$Bi^h$mk_(HB?mU z;0dst>gQg{y6fn@#BukO_~&@)MD4;RuPO)dNRP zrX>^G6vfD$sCo*o-=q-&cTyxx+{dOx=zrXR9v4tFPDYI*8Ye22dB5+KtJ|8Yq6c1) zKc!Oh?y?%l_bhT4cAFgyq;T1K&R@-4pe=Z4IvItHrvy4_H9X=m;1csnu6eupK&dou zas?TTaKY5RvRhIz&_0#DkXdORQz#iq@nyrQa()|eK>oB9#WU2#@lLoMsiZ`|z`@(9 zWG`t;z2i9Lck5JnC$tF(DTAtFWql{iE+)>dZ#Hu_OvsG$8k{n8th6nK8HSV5G?C%n z+B7>xs?Sw;*=jgYtay+`uwPPds5kq-fN{ldeUwOQvcV}W)hrf$Kix4!*0~Hi4tw-c z3>UFG9zIkrbtN|J`(dK3IarjVoUO68@7r3jYxN;1{nRu>tmi0+65*Y>a?zQ(+^CWt z7*j!;uqxOi2*&UfO{DL|J0g^nV0eAo-0*CSJ5FooIhpi#1FhZUnv`fOr5P*RU)M>} z+Ev6%W}z6e!;im41!DcSIhq2mpWjMdXlS0?-|_GG{$^(+Fma1({VZe8w&k}D{I-JL z5}6cOzmYc|Pg2=76HTOBS{nSCaAEJD4|60gN?WpHMf-V8oh*!1MS?j){PV<2^RM-f z)_!K#_B@Gf6tmI_SIRGp#Z${89ioOORG7P1{KX}sl3AxZ{c+QnvY5y5!mpTpO2!>(NyE}v(Y>~o zO@@_dy=^Hu9J&-dTP#SrPl>O*k;TyCFzR#0n6sc``i<_xv9{?8<= zJnXS$Q30bP$2UY{*dC2X1@cz+bHWTeLtb5l_7h|qGmVfM$L4O|#!yO;EU*MH zR70tym1CEx!pwYvl~Str93l?%Xy=d4KpL$~Ade}p+e5ch+nnqsPW&mx3RK)hegS@K z#P7X*s)DkyONm$=N8evgmYd4gN)WJ@jBJ#MYGm;CxmTArF3Dns@Ngu!?ZN(0^{YSr zV{|~!N)FSkeKWp-z1(KA%Fmd1wrT}quSdgCLuLNjRk1S$QlBL9>?pL|vRm(ZL8j8e zMrkzFKs&iYwIadfU!gFMODd{=0TClO)jobtH>#{dZ|Y2J9K|t4^I`HWjXl2x#>Jux zoz1K|nCZ>XBl|#j(og#CSd%{_t!vujw2g4lK^}^~>RCn;+{giztJd&6J!N=3lvTBV zmOPfusZtDci8X>B$!OAr=B`o=vXObAo1oT8qZy6kb}5H=lG560od&Fv5AMemZfJ?@ z2rZ>M-gp0wXr?1*IvD-(DJ&s-4$3sOjb~@7^8qY3TK*Jl+$C3JbMZFOp5GzBlUX}u zFr!=BqebM8hQ72y@k0R20v*vXr;7_lm=ylNVvbf3t9ui9u0TKwq8F z1J0h8?blc(7)D8H7c;1z(YtW=(6_&#u%bfT_M~!Ih6zZUlnJGty1q9s>==6imJG>H zn=C=@NYcZu_BR+~|7Ib~yfUaER4dRQ{k9MspK9{rlGOX}fI`H^OGBA$X&7fvY5a^Feb7NmZB6K_=BpcET#!kNX$1CFC1YcPcH#D910fsd6t7-Nd~ z^k=VGEvy3K#Q#Y-^Pl~2^AD`rmH)Sj(OZFy5FGuq7!I0`!JX^q?ZS=*_}}x!j;$Nk znHotW(tplD-%^(QQvR*Rm^T%g09+a7S6jg1+ zeRYw4bY})y->+^b(T-q-HD3x!tiT85GaF|k&8G&e-!x<2PngnOXE1Mm!b`r*zqDIS zsPE@>@+P)CG_tKw(sF-cdP%AYfOsn_#e4DTIH@<#I_%=d|Fgv+YuBuGo7yd5%?cHy zWFmRMnxk*dQXLpka%aA31{WguMjj7vR0B``drCB`FU6;~$VD8sQuusXs~hvr!A0N2 zeev=7a*M#}7D7KjCE)*tw3|<{K@)A61#~J&)*M9BZlpC4i51_RTB#fbGB-417=hLdIfe6Qg9AC^DL<#Hs6$km9xEe~EC& z-etaNwV`uBAUkH2qE`MIKBm|82PX(5jqI`H7BOCU=g8fTB`(aL5giRtp-_H3TOscg z)Gu&c`kt;7Sy^nGJ0+uank`U}DXUr9jd@X0ms~;+f7aTTFq!=T2*E@b0bOa@~o$Vc7ClC4_(F z0+566IQ>?Mt3hJUoXxA`fvo}(Y<7TFagVCzxKd82(NW0Ad~NN1d?C-ylbZ?iQ1;K$LDj3lrt9wS^|9CA+m9do{9cHC zvg?Q59l%t~n$W!Y1(?S{E4*mm0r=o*zjgOur-VAMvTxU_o&Z_QRb!>-wxjk(Xk@yPaL8TJsI+ zfz~ft&u+fFL3kF4O`W{|WD85tPDtdh>GNIgI{o?SBHr8EA}0|Z)L;^E>{{Z z|8*e2NX>&0g2UMWKo|dRWdF~Ep}Ltq^+e+koALv1n7+#76ZWuMr~3oAT+q^yG%0Oc zV+Nnq5rGC}JD?#N^US)wk^D0hFQ`B6@Bm#84WL$hGXqJFGvk!XpowA+X>_wD^N7y# z@PtQEW!)^Nmq9WtbIdAn#bZ|lA>9}sWzLW`--CsWmBPA#TI@<2Sgpk#syNt-#{n$y zAFpz$s8Oi*@^I*yc2aMT;4VgursCgc6Am?D17pKF^4R-Wy?i zTnaU-A1(-ZaBG^KUD+O~=P)*eBau?+bPQn-sNq5!?2sHI(&X5AO%tRZ> z`W=Z-?UmUHfp1UR}`KW^MI#ZJ^L^Sk0pzKdI)W>pM-lg5eB8c!wC%e3ZMLQwUFf>|~)Y!USX=^2}M>C_x`V=(Gk zpU1GJ8tGHE#n7Puh^XRr4$&c#Wg=;VWff6jbcX=K3sy=i$vm3!3qZJs59((2V^>Sq z`I-WfV9(GX-xsfz_-SXG#|6KNuBF=TLSn?j&0^CMC8M+&+_))`FuM=aCu0a!6oP9` z%B_!;a_RdyYAbcBKf?2-@L1HCaP9bPJ>d|~SI}Y}>X^*onavWa(B%*eiH1Vabx$lf z2$0`dP@RWL?}(E+4K{Uh^{tQM3PBNykIIj!wuJC+ZeH4|eJ&R!v*uH!MnOzZxi(Z$ ziwQh6Qcw-$0zX5wN^UCD@GCtP{9AA*k{WGYg8@xg^@62{V{T)n_i4t@_$%f^8ddib za>Wx%!rQ&c?XZ1_&v;W0312UyPC%7oNsV9EYB_9nIDiXx4)xvWu!GyCnj6H2PS(BF zrVQ>1*`!OSzokj;In)Fm(7#;=o3WiZWf7TCvqZQF6ga7eWUe+%I%)MSEDye zn%99-lLGrX%lnsR&R=GjA+A#M^|&`uXo7!_e6~0Zn_8tPLX&E?g*EsJ#&0nuOrTR; zRQ4&X50*42nMFi|a8TIK{LaAcetEOoB4O4XFRSolCf@W9!GX%^ywA&^PPGT;$ER5iCKVf2yEt=mJ`&Ic`Oi8L8{6!`LfzFI$!@MzYiW)11~AFQ>Es zEYLN#Abc~+KBRsEfGoew;$I>r(4({7LBSHobkL^y}pepRper%>z-FU9$G0d2~qEsC10o z#CQ3|^2WC+->P-$=I>0UzQ~?(otKcV-JU|us700+!26%#yx=ve>7J{)y|dN17Z0vT z_aOf|v^J`B8|Bh;=2$%g#~9|_URqI#k!w#eaoJZV%3YFgdOy*Hwoa)N$_vw@=hBCz znI+GH(+WyvHHlPm99N|Z(!2}sSDG+9^@I4i2^RL^FN#y@44C${t5i`9*s$=BufFb; zdBh1LqV-HNMI7n{{x}YR{YQ&##&^c9ic;@PEW2xY>cRy_cea+aKpLgXJt+eP%o5*; zwQ5_ZaClsTUhKo>pvSirtV=Fdqxj>@DlGJNWD@cTfb=x`B`OWCdAAEh>G}$)2j(1+ z8sYaEVW_)yJHRZCscGvuS@L3LWj^bv+So|Wyvxh;*I7N0K+p} z;83yYj+6@+zqni~CF7^^Iauj{j^K}c8b6;S@raA3zi%zI{)&^o=Yj}W*XwBYp=HxfSo4e30u$qTdePjo2^pUnAc|vu zeLqU*kQc?udy9Cbjt(@DQp>?pQ7OANpl@7%%g{H^BI5uW;TxK3plKR4*s*8}=b&Mz zGPMO2rP%xkW%QqSi-L)8Uq-g{UEevot7R-SR;aCEleMU~Ojcu$DT>E$LK1iG84BY! z^y79g1f=C;5J9a}XVSe+zo`2#RD3)%zR=rmpfJT)2q-JVK&z$2d2gC5=T0A7VZAVX z<27=3whtYB!q--gc2qLwjFnEBxwsy^^bv;M9wsPVy`m&R6rDz!I*74}gr&O!BKB6$zk+&F2& zi7)>91ZBFPJUi{j@$fHIrBP!VcB_MqEA1dqxU{{$0^=%2*H#`)VF}rCyv6VB8)Cjco0D2|lt0+maH>;vS^xgMMd5jVS9?a@BVp}t< zX&Y|(gC0WkWU&*2QSUAJ@Jw!c zdlH0rkTlJcF+0n0AS}*i?3{7cAX2-FqsO>E?oytDt!Lg?BSozU!*;4YshVQ%lW=Nr zCQ-(eD}KCGX1M~%WZ6T?Ahy9&0(o5SGe*AOhu?BZp@tY|-_S_^K`Uka4z*}pVcp*z zDrMxgi*f8p*~AggG8dc0J|0Um8$xmy6z~yPg1wUMWJ@$-ni4qfCY0G5p!0~FatxwQ z`T5X9#ELK&6VndU<9j0}?tcLqJ@34KSYR43Q59fk&T#q_p!f3cUI+nz8zvt8yX6}> z7bp5xWM&pSv|a1LbsqvHVtf76N}Rgtzq0m=(R?wQ%A`= zi`2waFHTcm8xIlB+~c9puTq0MmSCI_)?zn&Gw`Uj)I)DQa-Oy6Gi zxKCG>ylQ`C&XZhaE>vLRe~O1+i?gn2B>1D4cn|q^NN?rw$c`A@6X^F)Aap5pQ`$JX zq$owP*6CgHwe>}hIiPHmd>hU^bCXCDN`~}PVp|5*V+tQA9WNJ@=>~P9o1ajqI}te8 zR9kd8HrsgYh=rIkEvaSQcPS7#?-R_rx6svhvT)o zI9_Yrc==g(nCjZvr^GC?R_JFj{XeC3D`2g^Z;PYFm1xblV%W3_rEVMANH@Wgs2vsR zzYD2w;2gFICVK+)=ZmKbw(;AGV{9?J6&W7RPYUG2mDCGS)NKcvjhS6ATtM^zk%RD6 z+6?+c=n0ZbLBOCU7SGALREqqp~-Z~jG?&xd@RlnPk7Bq7Y9`pYF6Wl z*Jjf+zM4!gQizUkzoi*DrOiiGJx>@J{{-304D&gq4EUzME4)laaRhZPcGkv za{rJIrpAY`D{%E&UJ_7cZ>NB2n!RAop>n{=VzKzbl$P}4=IcBzL0`dRwh5O<*!AX5 z4Y^7-pb?0)7IQk-I~9@c7S#ZjYd^o6<94Bp4y0c;1eH8S=JyHg>=%$3=hS&grheV! zAe7q3daoV;rcQ|c3?bGH_3*}GV=*K@7bV0?hIO?9rZP`&+PGprSaNtu8l_EYIt^YJ z(Pl4DaL;nht8>w>?-3{4h0Kg(bB*<((AERTER*wwRAn?&;F&n5XFtf7(aU{`9$`{|+n$cWdG4QGz?Cb0bxk%Z~H)m@Qz z#o^kz$+^ijROLmUq-+$(9lq|dxn;+gO7HrV* z3(!DX)X&XQG#T{@$ce+$nA=c&sb_g+P}gbwAG{ooVVI`6j$@0=TxIq^%HYmED|kwnVaA5jBM?het$>%RcY!9RZi zegU3JW*K_;-c$l1Hab9q!Fjf_O--B884JW8^B*Rde(Znc#Fls_hTexhh1;E%&z8e& z^6U-W4O(zca5YY-B#o)M?_xYCgYuYMM}gIOkb$A#WCGfX;qp)xwJ?)}lp+&c_3=&UfDAl53z zfp-cMb}&Ggw~IU;Rz%w12;yp;YIqI*R3<*iC_6xR}GNpjmVK|Dx`_ zqnc>nbzv+oiV8|qx|DG)@MizWEGvg=Zc@Y8E z37un$+w>TJjZpY$b9T$7-pSC9Vmb2IIHn^CpaN(UF&NJ>iNGW_+j{xW0*+9!cpBh+ z);!5EL;XI<>N3$DxjT23#^m?l75Lo-c>4wH!F9!rKs3utvaX+6dJ)z0*i}GUYF=#& z7@jszAI?m^sxm355NukUY=LAK4Zh?KA)iew7P4at;5{u0#aA( zxg)D)wJ*Mp7{gYKYSXTHY1n0|Mll@e()){@8|^(6iLBeokC4GGh4Fsn^ALvk4t z<_|5YcosOa%SqvJKPaU3!R#DJP9#kz_He-S1vFM=hkthSpXFIF*i>V45ncMc(HR~B@}4~~Es!gZA6!JAAA9yjNear2;DtGA1SGx-kc`Ibbg-K} z0Ua!=dYSY05DHcOv$2)poU!s~jJnHdJ}5KJgsi&w^_J32_r!o5cZ%3qp%W>notB&et6ZOA7 zy`N4JgSF!mP7iD+j&x~m<*0?Y;YmbI3 z6UkS&NCGu)+EYh&U4Zu$jfNV1V}mkubWE`pg1tFPeMW=k@=Kk>#6)b}PKx~fgI?`7 zQRt{4;(ub#&R+_QRrAx5#dW;}ruKX7H_pnC{-lZa1kaope~bOBasAh?JMCKJ_JlkC z+tu~|_Fw;xE0xb(^TYG~oRe^crRJLGE8onL6CxuaXj8ipq>R8>lu)^mc%WjvtZ*Wr zP++1PNe~)|0Mw;2hHKn-7EC@J`yuu#=Id|S_)E#*J*;_@DXO~gefnoKQ9m%&65sDB zb(YI0pB#QC{T#Eff2#FG4TLC=vG+0d)zJ2ik5&h~58+6wPQAr^`^uaJRi_ufwxe4a z$d+x|TkINS&{^Wtz{jRwO3#iVU+dK}Y~9zJwdcA%2x3GZ6Z#zs=L}okOvkd7J;;Bb z7hz6ra7=~E;Jr`TYhWvEZzG5xfzUs`w(21Cq!gO&U!^%;)IQGRb%pD#VA=}{DLY6C z@CA=zWOL@ahg1NJq@R`9N2ddj&~4|32IkJBrjepkl>#XKQL$K&QsPtYutVxL_sHJ3|5iI5eiL&n9+tJ2dZ}5`7xTZ%-*E> zBBgM{K*K-kTdMe+hP1YU!#)poNf2C!@?x^zQC|0~4OQS#OY_q+8e#KtR~;t-Tgm3E z{CaF4PwcYUBz$e40*tT@Xv&{k(g-C_`xrb)Cy+qA0gPflK7}}5qWwU48xjC3!qm7_ zAV{*3;(6Loaz(=U{D!_LBvp8HeGWiXt$J+v`3BDm7YoBWwi`e0-QOXC65JI(j9n1M zD6?QJ-4O?#%5{g4^C3L52E#2r60hiGKGvC7%BiS!H<$EoPn4p3Vyibd+;>;ZNuA;R z-;7_5%p|@>%6h`JTxA4SNL}DNQj_#N%rO&>1BsrzCemG|%A^ol+bUd>G+WNV*^$Lp z;nUyuG~8`paK8JuOM>SAN@r}U=)ipYycHQ~Q}F0lLJZrH&+Vl#vSV5N0H_K_k)P8z z18cMohsRT&kZ!!R8Weg6+eq?qSuGsOA8mq;>g-GAC>Ldwy+In)3!Zv@?#Fzv zoe2Di_6tfY!;G@3rJp5>YeBcqt%;<3!(d{=-D{+c5N43_)T?8~bf&k%aamN#!SjLe z_m+#QqBBNg_tPAYl8l2t@Dr2~rx-ibv6uqA&1(_Q*2M+@BVnL%kDvdxfb~H!SKf91 z{X#Yo+M(6BJWd6Ee$5nPn^|L1b9;czDWmtQb2T2|BD2GDLv9obTo@dMJN(?73ifdp zF6c$T7HH)^nxTq%Qoage%H&%`uAx9T+sn4t#+%Ic`dSmConIOG zq91P1xg5~bK?Gu%0=Km8U&e@y8QN6ll(b(S>Ygj>DqG*{;+w#1DYw3Oe|e@hwLeqA zbdq|_GZOlp*zw=fg4a`;QcM&Rg6qD;yVryw=Fcai=+4~E`obByMoOsl{Lkf=tc zA$Jk8vH#3<(llpb0E8!28tuUHs~3J1!!Zv>Iz5JfrQGrK%IPW2+=9Jma}e|z84h=D zWc0sF;VD}kmz^wBh;``pbwi@HO}l~WjpFYLQw9_e=`84RBYkgj)M-mXe0nDF`amVI zwjc}ScJpSp4W) zI(TCC1^MeEP&5PSLh&}6y{mxWAss|;tnC+|^6iEkOG%(XwhC`vrUHiI_-#7& z@&02yk?JuKSLpbH%QQZT5?LH8IwuV7Y2EqbcJ1qF#N3nV?zNR6&#pphUYXGJ0PZwp zvGw57ry1pqY|!gohGJYA(~<1)yo2KD!?p43C8IG=`D;IDNx5A^qNGg(9%?4H)|apx zog37t#P`wO)eBQ^cyQ2< zo`Ndoe(gz!8E*7hq3{q#no#-~k6F4?^V>Q7YnuOfnaymE64f$)-RtLa5F&SXx^a;y zMBa5ubP;2nyeMN3#*k|8go&Ze)Y;+fjjfeKNxrGLjP*uo6JM;LnHcJ2*M7xx>e?evvH&T%x)W7og%Yzj%+w=$a9j@^CL<6R4X+n-o2Li1 z@=J0j81-D0H|Lt!G7P6P#3m;vMmmYV>@G49FqBLY$5r6 zZHJ}zDnvzbM#-pn{L2CABa+t5d_zjbQ4+kcWynhYY4%Wfx+B9T)Khwgn?q%|d4t(# zUEWAX+G-dtj(0?#wRvPa4czi{>eLoqqAFh5Ayhlk6-O zg`Sa;xI0pxnJrJAX*ER(2=4eqm2V8`#?0|3M4OuGrP(D9^m(kp`e2Oq?~>ryf^8%; zvD<6_9fH!dLh1H9Xtsk!8ozl}158$!XS(*XoaUP>oq!&M65pqcYXj*~U~IkYM`R$* zfJkdS14>N~JI&Gh#4R)C-12ni`SEJ;b+cAnIZ<0HBZ+6jX}M~VX|+m zzPoX9k~t>A;_6qqTd_Lg8~6ECayprBTo!Ol8Q zuKHL!!%~U-cBR|ys=xE5lma`?*1(yS9N zF94R~SJ{zGYn(PX9Y7aI3#cDV`ZmYDR)&?5XYsj?$&X4-ewcwY#fdturVpT4L-iTS z*VmcY>Ls{(bs~i+Nus;l>x@(=i)yGb8PoRP@)zg6@PS3LT5`#XcSiAul_}6`&G(?-&OIY-YIA314X?Td3PaULkX^o51Vmz= zU9_Rf(}0Z#Kdc!U)jqH4M?75Cv*NXr&xV@UK+$P@f^a<$TLFL!gU#uU4d84gIN?=V{(Vg7b!I~`7byU==utWwnu=KgXt-CxSjaUF8saGGc zFDM~8<8cV#ebtGi|0J>&Auy^giWyjQ&DSv#(GotYONL%{YMYrx{cebUc?7|K5 z;9>TeJ%eD$GMN#Ttt_5#VV|lP41CopR@>upj=xfNUvWv15Sy#qF(L0#&Z;oEU<$@~ zV0>Fhv;s1!GT?yl&@T=gIP-5DH<4I^-Psa8Q{Sx%6_XY4=nowr%|>_@rJL7Ak5QN@EhBkqMH6*dXzUCa4`raJ7J7zCF_oI2h_j0dDULd6tZ7zH zxoVXp1nL3uGX$*Mdt-s;C8U@$vW$pO(Ja_j4w5?cXk^uo*@v^<66`F3w>nGD`R31| zKsM_$p(kpQ^^?vLMqH&28ty1RjW@fK9b)MI!%2P{=9rdZo9p9>i`C|%_te29iZ0f3 zppjCF@#!dh0Nxklx7g0mxwAL!otMGww2v%PslLYMrf+q)(Q5S_k^*TEA zSro@dhChJPO^@aZ@5an8bZ6Zyx#NzJbaQW{UZ*mWr2fmuf>1qhukK-8?mN0)R_#QD z-?k1V+x)@NWz9`?&yBm4;qrrBr@Z%u0k@kNam!8%Yr7n)ax>t3^24c>=IB^Q^zJLo z>;aJ}E&+3L)++g7U9(GIT1~H*J0>$URQPD#tG8sP>nx$HS)EdLPBXc**B@%mZN#iQ!M~q)8D4tHef*WK6 zEETsh*Y3T()RQc!0?lum8BmRAoJlOkM!xFzff}?VB((JOK*|T%C=*QrO=>`6l^b2& ztw!?sXE*sPjOx|jG8mfZG$qToi8zg$I^UZ8IxU_;XtX&>5-fDqLby9|;8Uh;+y{Xe z+2RI?p40xuU$j)x=3ke;DTJiiZ**}wmDGI4Ra(bRjMe{1b9wTo%oQKiiMpR zX`@$%@al9>1acJM1ykNiD;cR*(Q*0a@q=5PEaQ$& zjRreAPj}h6(@?${vE1O(&rC6X)e^}jpC5Ink+90q@P?chiIuj@J!gy3lkuJ^1TszY z>-PO@h7@m>sYt!~##*2`!=I~FMj3+<3n-~@<^S&b@!rr2G?lU)D7HHTk z`o;d5_lncjuO z7DvF#)527K2$R*SEfRnGY*cy*hNs))>MlWvUfHtadgDrOV~Y6lF=779%Bc3{-jZqy*@JAzJ#Kh&adkJ_IBZY-4x!n6o2jC2IoKdygm4L z*#4z#|8D&R?;#sC&+|o((PNsGjqk&kiH7IhL{ykMCq(-4cGA;6Z=~o8B@bC1EE`Gf z9L||r5>YiY#(OxHPsBK9p=Y$Z z_}ujL_a8*_>dmR2JmjXn_NxX+vzv#kR8_7b?CG|smh;HQ46GD7ST9W03GIO0=x-bO zCEr^(Iow#By@VTC(#*zH0&8a0lj4PudiVe`lD zUAz1RB^_llZgg9HN$(ph<%)825i@bio69o{Vzm-6J*1T;LUMWCgq8Bi)BWwZqLr@q zc2T2o9+Yi4X$Kv2Bq^K`g+=dD73{+!&aF)tofr(#N2$(nr^+GWgB>N&p^yEq=8~Ca zeBzMpU+A8sL=ciUAM`jDoNcjg9(L`J{27tW{ijAFF<`5`&N1^9J4Et+=}?ztnv)m znLX2L)%>o!oWJ&p!OYcIl2j9Sn)tNKEOs+)@g?f1Ub2J0WJ`oydGK-C_yqjMHv^+V z@4fyLWlr(bCdQxDk|y79B=Uy_lSB-3Mst?`z;t~}_q|R|Vt_@k$2~LFLS5hmL|IA3 z0TjH$w9GPDp}f@fUD~(7DRI)Tu9>jqMs)xIfgsN(_0~_pP6)aoyH}Yl^4pC5GvU{U zgbg1RdF|9a5NhR?VrT2Klm|mNVg`=I!YOLBqrv_9`c@g~_1#`yTro^+`pz6o)@X!R zD|Kc8MA=xDfE8ex=3p5JyYN6)k&)k*fsOeWfTRn~$rA`aDVrtc6|nt>JodI;Yio~7 zeJZ8M!#REDcKom}K#(lIU$4_S_n_(n>P)Wxvf>GU+3jZk&Wx@53S>`aq{dEzIj*re ze@YN!B~LY#ydR`Ax|6P^^NTC4C7>19Sr#B})8l><`K3Z`O8&$->o-dz;O$7RBqI|& zh<_2GI|4-x2_#qgj*U2poGP!?oA9RD15L;RxH?7&+Y__r_?ZNS}kC9XNqQr$KVVO@uf) zTlp(TJk=t^Ci;yOAr`4t#F2I*IW}u97EdA#8kTr49-({CZ;uu)*?El8#?A#wBNl6! zW`i@Hh$7ThUebdr;xPJ*g0CCMcyR(Y2By6m^%pcG* z=p=1clBON`#`t_~rXnj0NoZ+fUNA1A%uA}cQ@dv;Kd`0FRw-%u$4_G9o`GYnK~vEU zJj{J}T5(F$_hNkjYNd&ZWd6>7vJLYkFzWtq6VuM}c;|v9M(GO4)yayOO_ZbrPE}-r zE{aljdysxl;B#XCpwYaHte3ISGRCJ#j%mm*;iq{`N<7FVVQVrDzSOJO>y6*WeBKhu zV*u+T!3m?Ya?O0jQ(;8Y1m#{$al6^r$AaT z-5X8AWv*Rd<(nqw9iMrLZDku966$VM<_LRDgpcA*Um zi<6xQZe3p9qTt@i>Ooh1oV<^_pzwGBG|{WqYR+<};FQSISxt5SdEow^%2x9`0vN6El)Vl#{IU; ziW;dIRIFe78#LMnoozduPGdtZ%&pOd-K>d)Bko;J`^3D{TGLjzw9>X zpS}LYSR~!^Wq9V$+qk-;bARbY^&^RXa$1&ifs&o6&Z?cMbe$ikgW9Ncb28Tp_xzP9 zBJnC)t>xz{s^*qjOM=0Dn#KB-L-u?xDA->+x3jLMax)?q>aqvfB9{&46 zldK>O%|%d2sM#6<3lF!)8`&-$Gs>r1Kum0aeK0-g*~AVVkI?jhoSH!(RhSdT&S3Jl z(4Dh=`p*Hl{+9^Tu-*D2=r1Gizd25MBkl9Ai`prK--q9FItAC=O1H(y+vhv(h<8!r z?+amHU6KAr{2vna{G4jYy{X5g#;-cc{vNhB>i>-WrGEzZU(8tk_eQ9%bTg}P^Qs>9 z&@vLIdUgcuaF$wgHm_P7ION}+M@k5hZ8jh`&OiN^GyTScKWV}Zq&~^MS-M75e*OQI z!@J@~JSy9;IAt6@L$Y1xp{j0*-7Bf3`j$rMQgrB$CC!}KkFgQpnnohJLy#j@aQTHX z6*~&;09!#Z_u^3_-w(08&gNSt4e~(LeeV#go)vdf-#UC#tGP5(iWV1OjL| zEvVC+ulQvC!?}nRew@?ky&iC279x+- zj~sk3q4R`SW!*%{jb}826okuiV*LvImY4kjVZKn)2c^xBNvmLOP9| zr-O(OcqUXuL!-lOxc+3@dBnZnuoV005$rxdL&z8|sxQfR2zCAq;!G55_(`3$8I5xN zCI~E(3D?4{-M73AR&invcaY^9fWv6MIyB3YT9%v8jamVyUcy-Qi5jgxHjHi5NPFa2EQp&T%_c{Q@5Q@xKBQ8L^SB&y}41tGe+MZ44jI$69s$K?r&^QkhrA*yLeyo0D&Md-MiZ_I@Dqtaf7LLOUr z<;w+1Jv@rGK*d@PzT4&wzPkSiR@F#s8ySgoPpcyJVDqOCX-6m8ZM^B69u!W`*J9w7 zL{W(zDyd2X`K1&N+E;(GrwGf6Iq<*GErd4umQTWUwvy;;3Q0q+BO+WB87`4!!+kDxz~NIpV5-maZXX zlA~3Gaqx&i+Ggb|Q}0U`l0~+b7I;^HS@ECu%>PRZxd1(WTctAMADJR!13~tzx^9TG zcpd)oDa9f_hPu86RM)lsntw!x4m9~wxZf;llN9c{!yY@oZYN$zmCW(tuQyN;aY+?p-a|^c*@d`o)fM{R1H+6kb;`ykF#Xnu4M<-1=;D|LhCq!yg%ES%Jlw-)U1m z?RJMNo5VlK@)Qd#h==f;>0y!6D8E6bpBqn{&dZkP9%Ld zIHXU-#Uu`(Shu4OO+X`eeFnu6(FnPzIZvCABx}tWzhO0V2#bk*R>arG>mYkFKY$T0 z(RNkRbxOPOyzE#`SdYc|Zm!XCtCb?|_VSNmJ&6Weqx&;^5=HF% z`xa}BJxbZzoD8!Z9z|X52=ZsU;U!7Xe1g`Nh(XcO%TSE~-ZB;HM>qdcVN8ED;FxFB zSaT3K6+dNUupFqwiMO_rLBF?x6$dSCB97_&}vFu!q$c!dx{!(_FfH~Nx(SQqD1s{n<=i6x4mx-RXIbyZTOT^ zBmvu?n>SNZqfq zKHvl2D`h<498JO1h1QAct06zObVG569v}1vsl~Hss>yj;+f1JKpEQPw<)!$`nDiAR zc#RakcMKP(1)m1?786+;(4fZ!OkgEWn{>yiJ!tFPz<|4J-38`nR5ysAP?Y=jch$JQ z>Gy|bqC=)ysD$W7Ssb}P%oRPV=OF?O;FlQdp{K>VF@Q>xZ10-aCf~?+kY07FN_hdz zXf{~Z1cGd_#On|0jOJz=3BUOo65mt-R`x{j9TL}h^HO^|9O1xC!H=ZsnIPla_?78H zkdMrke@l3%Mmi6#JLi^+9*XO_(xBY==#N+WmBYjiC7L%(`*l9Bf%NGFQ(ue1-YDnG zsLlGh?kw29b}X_si`QKh0`@XoahkR?8{>+)0?;~TnE62L;)iD|7_8{Y9+sglZ>PT( znLWpjJThZLJvlKqE57yPgM*c+lVut!zVzOajk}2Tv=a&XG)0@oz*M$AeTE+B+#u+? zo;oHkr(G;}qVhPF?MSYoJ33|PTSjjxo5qK4>2}sShgNt^I#Ivn*W#~@h;(vdPl_x; zMtR?jVHLx~#BTTSbBGl)E3dj6b#r$W7$oxj6n|YO-@xzyl`;$fOk96K-z~4<1r*Rm zPWRTR;G{#wH>Af@w}%vMulSMY{Y4!W*GmkzBJiXLJW+~C>87L#e&-Tr=$q!KRRIl* zLonE*NUEvy`F#sEkGkbzGL~5Jm0g1a57G7TWMV-$^(4YLOrOuMHe+ONR&`gqmK$lW znxN(QevT!8Jqo8BlwnHOTTf2WWR?So9S{^-l%kcBFsO`p!c8z;Vnx^AyPLVn={Z)x z)qC$!$y&s$MLvt~G#}siU@QZHvS_-qv24T?li*crb2l-4S@!tpwaNpIx5Pxz3o;w3 zEaS@{F()rwl(c0~Js&usFXks2?J_4SG3>3BG5vdVip#3I%1x(~yG=*b%T`?Y3rCTg=DAiqPBA{p3{y)6pamCVspog*xNQ@B^62J z`Vx?aSgS+NdXdrcV%42)xb@SUme2b~dj_!)OsO!yHNLM(-oJknx}5!f4QGaV1wB(j zoMiMC-w8CI%Ehbx_3^wd;B$A1P}sHs-D+kP<&yH3B5|`6VdM)dRtgyJfnFwW%Al z4x}BI#fNrja55kHa0)ZFJIP)n?AZ7A!RPdL2a`K0ta*rP{;zw&`gcHSjx(~2p{=4iT3|EMXV z3S;WInMIBTh6T`$U$|7kDNiB6CNpPi1v}~&BiTIqM&3ok7fk6JY*RS^z1BX0F&KR; z4PxP?*Y1;z=BunLPSzDHW0n-#7vyf;y%_N!P%2j4O}~{YmX|x+1Qu9U5w%fi>H98{ zC_7H)(cJA(jXj?H<%>C7%#7Wx=M<_-bSpef~q$?Kl9d7qaZ%=uI z`NF^4oGl+g$_breWI#tLQ3pJE{ZhTfd#t0AJYtt5({pXCjWR!^!B4k)bl)e|jrYs@ zrx?De1KoT~hP1>%ipww4hz;e0o?3Mtmn4KQ2tTs_YZci2tJi2$mjOTSEIKqt&C9aO z*S{LamU%A8{zH;-&lC2Wi7Jv1`8}Ft{KNw^?0%5l9Ob0CnXwCCtS(v<^&fjUBjniF zG=z~Ly2y&qMrPt>qI*?iFwcINty)mV9cHF%wZDb&B{+_!Jwa6pYQ6aJa@R85WuL<; zL~EO!PJXP#RX`GH!LoI6=X~s1lvyU+&lfTCgVjVMrT>FRTR;C`Kk$*IspU8KzTl1J z*AnJG1zO22F661#aN7Wu@-V`Nnd2ia(ItvoX>$+$m+IB6(w7g#y~La~!o(w0QAMqb z+4d?z0a@U-5kwlg1zmIg2MrN9$ns>U^JOWXIg>v}|EJRITcvA7yxO|&IySPzY)9sW zoshNa%+otshy`^()AG_`5UuwiYqa?W;_=~iw^I2ZPbTiZ5p0m0kIbZF0$q|56UuWT zl1^Ph@a1H(DR?&a{#D%hTsY}q^G+<5)Z8`Z7Y4|&Ht&v3T|E%tZHbrx!5~Io8dL>y zTo&fI5})?c0d^bsmwsG=?Dw~<>H9)*M@1*TS*fT32HK^oilI)*>87X2fVv7c^uv66 zhFU)GLEu&%spS6ERmGEE_T((<-a(aeio3tL!Dv!VDbC%>8ts=a^H#oLV!tn6+VUSpeyd`FTtGwmJJY9l&1qRhcwRGfP+APP&Wgj+CqRC#4)8oS~a!1KB{*_^7*hml6r zga@}DuLd;I3WdY_IDP2Ln~TZ0n)+|Ac&rFgv=^G#dA~E%K@C|;ih94b!2WG8TC)&_tzd?mow_K`uLJobYGl9?{5QT+U+cmP>!~!FMzC|W-SDw04 z>P1W9GCM7s{Q=PL8)Hn<0v5>6Q6F-txmZI-H=LU?@3XB9#5COfnar=+v#lR<>Q!~Z zv}8oehqAr=NAcc{w6t1JkD0rxtx7h$IaPzoLu$?!R*v#lly`FWtxkL^1=|_`be{FE zJ&3V}dhA{na@@k{fIC=iOOzCO7?ttJ)lpDQf2S9t7YbI)NJw8C0BFP(VAR=he* z5=dUfguT!VrJ;NFFE3+%V-zu^-@|X@k8yHazBcHaaVu88G#dtm#*sjUb zU_$FlBs-Ob{^GwoA=TDWn7ldV{LSot!GZl(jM+Ik6Gr+U6Vm-;G&wP9-h0Nw)BP!w zh&Dxnayt+pNNT`V*t1JjmmRge>bpi2D3_aGCXMKn#HTVFRLX0JS}lctuRKp4fGg1v z8sC?=S-wGE%Z8PFei|fZiCN)DOx*O9Sef$s< z<&)67dpOr5kaJ>t>E5~z5LhbU{(-0>+;NiFb<1OXgk&C})RIk?#-1wVE0f}LP3mHY z+=AhHodc^mz$wJWyK@m$T%B?6IfPE9Bb`G~gy?U=z$e>qBVWJ|DbWiCJ(mWL8Qyux z@w9h3IlkMW6#97BZzg6<3nY4qvyrhKYzzqiz}SDEqg>G_2oCj<#uQo*Hp0M5{x^!| zBqB9Spf`4(F=1cK-vltlcB9=aOXhODdp|(X@u4Yh=2h!PV%gM_HeLE4rtu)1HI*yT zbVq#|n%Utu`3@B|^E3-ikSEIB9XwP!f_PT_@&26A{#5hM;W;3iUG|C@>-K5}cvFfy zRkKX!R+{@q=)-kQB&ruHJmXi-aW(hs=Cr&%c5N-{0e;m`F(!&v`f=8jev_} zLg&vWB>PC;C?qX4wn54{v`sQTnZd?IHpiMchQxes$ry0=3^FiMfgqL3T?L|UhUEt? znF?+kw0ZX_s>;Ot7~E{di{)O(6e*sIqBCQN^2^nl@0dwy>VDBc1AE1adH5p)LRbw7 z<11?FQ1rY%u&3&go<+yy9xm7d((SkZO4HIKC$+~8BWPB}aP>{pli<~iDc zlGdnGou0EXRQN6&DtEpe+E=X{l&;zR`HK@R|74wg#{=`>3Xg0|m~o`|w0yRvNsa25 zd(|Ih=PW#B5*?#jcOm^I@ccJ7D2jn+Tz<`ywsfNPy*D_6q?+Bceif2)J`)skRiUnc z9k|4e{{jE}w}klTJKJoXRd@(-%(&hUgJ0KaOM-pL_1@_GpDo#& zg|p5|Io|aMB}0I%_Gh=o;u3!W2%?2i*dTz#Qx{e&ODsggXJWyzbxbim2}#y8mqg;% zzgz19dv-VWubOH1I-Lpd6L;Q2Zs9y$GpK&T!1p+M37%<+1g|Ev-axmI(-Aov)kjxt}GG(ogSQ0>XtSxQziYtt2p*3(Pu27zBpkcZ8oHZMVfObpRpu0iG@|Q}|%(@1b&>(Gn zDmp+YzSgLZN`Id%=#Ved@b@aS0s_RHYBnsbhmS;w1wSx2V; zAmzli(%nXL>6`|^rAr*Brk%U|Nvv>LayhOPoF6F76Xew*g0`@q&XQ6+$+0qBOu%Bl zL9hC#pZycc{I9?LAG);sS9N30{U0`CtG(xY-`|8e23EPT?dSyj>Ro$8NI!jCGD7OTX7Q|vR~xlXr$gS&ZQ zZqvWlK5y%?RxAv3g&x0gJ1uy2%_z4$Lqhg!xbRo+>#A|pO2j3CWf#giM86x=qJp>{ zE4H1w*tijrHXFJoxwOOa`!C%iDOB~e(K5S?iG`2zG_Ju0qvhe}M`mq3+-QHanZY>u zdn9h5?jto?@ooh$3zbN7-h zNKaUyJd|{G7O2Hs@udq&H#vspQ$6s)8IMw?Y0h6*n;##%1%wsJvq=p*8~D0eOOhUJ zEH9QV9_q)X@e&5Fwa!pYi16$8^z!})TEB31&$itly}d<~?H2Wj7pcN*3!_)R8;bq> zcs}6o%-MhVLI3rNi$N5-`QwrWe1lzMtNh-dG_4B1Ux&}iaclqD&CEH@@;idW455d2Y9?O6MN+r&2%S10OV7@u(*T4xqI3z|wSD}VC~ z{!_#2*QHq?7mTH=D(+TB8=v--+`gcJ2u8>+KE(wm|VqBSqX8A5^uGYzr5Gf-yWqPlGSucYe-S={$H9qD@|da ze=rsu?5}*Os1A%Zm@VMJi1;N1a)ki8_1EGW`k(kmrFl?oG4m%6my@iw)N}S9p&#`S zHehT@d|#ci2Jn(8XB--5xBOPFWoSi|NRY@)vZ48jqMGztU(kuxztJvHZs8yZdV}`i z*)67>ty=%;{bEzlp7__S)fd>49Ci2y!93a)K@M~5zMMBdjh=6F^ype(g{SSp2k7e= z^3?K;lmQ0?o9E|5Om^K}`31L@tPO@8x0%;w9qcwED};=%yBWMytB>~hrVOZ0ZbQO| zaviC%y%j(h$+JS_Yc)IJXi?{>F6$*iO9P5Mg9IvttP+*|-zpXfoAO-gd`XOfY{Fd8=gX;Zo}qNjw$0qf2Qiv3 z%~=#!c1N441JJvnVC=RIgw5#mxGg+)qs5;z$?g`uBMiv?ybDxfEP;O!Pe|lEVbiTa z6VeyMBJ1s=5VuCB?iPkwVt5|<3rA^B>UrujbZBOMJ*3pdq-H`mFw=nA9gvvAhl&xE zOFo&|OLqsS8=4&jjC2vG(la*kBKC^lYkJ$VUW$cQ&J89VU|6Y1&T9V7l_SrmIn}l!~8V@<-K;^E}KM@QkdC zw6ZOS#+Or0#P{C9sab+;g*PgH>*#+20s0qB;4oeR*%Jq4mIQ<+r?ifNNirKg-19i_ zI>NVno7-waC~V|Z{o)y6@1Q{_e#n?REk4IZF3HU|Td}o`E7<*zR7vU*sJ{}d$q{q7 ziInnR2i0s!g4{cu9^at!@ zEf$$ndnSSV#u9AjtYj`}mm)dwVn74yU~ma?jG;#SkLGD-MHhesqm_Y4$P1QQIi4N? zNX7+DqxwZhPEs5Yf;tb-%n)7Mk6m=-Pzd@MIuEg#JOY)7xeN-+xhv~5 zKYy1uX#IW)vED0g%O-!eJfY}qRU=RLy<6e;GQ~wka{Ewv$~wSUGDL2D<4CGsa*Kn4 z*xVaPT8vv9s*wFfh~=fNc=^qkZaW6U-cdb|c1xA$C~IQ{;lakS;cRyx1K z`^`Jb_6Z^UnT4>F*FgA}ETp|m0_F&IDDi9P-8F-vlGflNhMIxu1R0^8FS@u+KX)A8 z;av%Yn#7Ii2iL{VmN|C-f?ov5`RaPBuxrbkE7wBA6i4}|)DO(vq3?;&=1U~EoX_-w zDN{r-C>u378bM5Fx!ntbCLD4+;`k%mVm(Q%(8E=mv$_h89bWQMNmN>;0_gIoa&#HC zOa-4m9k@?U8KN6=Pp`ck;YzA}>R!Y`(Mzd`l9ays$8eUOPeBvUxF(m0Ed!KO{Sz)D zZq)NI`q%Qn2)y?G1@(oJxjW@A&6I^cl2*KQg#?^3H_~(biW$eT>PtP+0?yBid&q4< zy;qFq+%Z_Ua(C2Quj=&3H8UFFXt7=a-;*8eRIx4{^^I*j;F)N?v(_1SXFp z`bWYes0|;$Py`$;0NFukuip{;N692(UeSK?E1I7maKIv3V%T@`Es)EkAJ^G7LY!WM+OWgV?vej-zx^ZVZ?KfuP*W&1ttQ;I|K5x8|AmN{ z1)lf#{j0F3l|N}Xlf>`5k-m5E^8PRS{@)5*kLYT$p3@%>^HH^aiOPhUz8sVmT1Fat zLx)(_(j+24ebE-UbE#_e>7(_AsknOP&fiLhrZs}4yPrv_sy&!@Z42FXr+EsaYZ7lS zB$7jepO-Xw!V&~{dcJT2+Mj>$x0Q0HtJf92%PSMwdyPo1Hm&C!ThC$Y0;GVXA;QP> zx*NZ<&Qr?V*-NePC{(Q3Cn;`B(C5+Petl1`E9}zkSAdy8_2q+IzeK@790t#rh`DiV z+oS&Fvq&RerIx9f>N>4Vy`jP<1Rp{5JrP2{VZp^>@FL=C!V9z}J2x5OGRQLFiOn`Y4XP^g%hnHg^fUx++B`^CuTgS`} zYI#GirLlN`v~%kxB4eGDjU}uf<~auT8bL%Y-u*CmuszT)IWYxLCCwf#d}@r{D-KSt zkXjA0R&!|YU^GEnIZ{~bA@-tTf7qTBWbLC*uUbbcZ@O6)WkL;0E~3E=mfyZB=an~i zW7hq!SK`KBf!=@9J)##LcUl z3*Ate#fhI}3q?~pfHT7MC90-Tmt$H4GxgB$9BSVCcY1Rr!ih~ZYpjEnS3EiGgK7Nv zH^s@kAIKHQfceXk@Q~c)ivHG45As_Sg-gmncIT^jSy@g%Nqujt&w}6(;x5z`hDs#e z*#Dw93zFj8_(D&ouVu(H)L7B&g!6l8k>F}Nd%GC95UK4219|W7o~kgVif2HxW3N_O zMAYP^Mt(9lfF8ti zjno>1^G4d}rw*0832qykx-OhyDfD968bQ`>$Qj6n;EVg?^kRma3-ZtgZ>mBB@{^&b zGETLu*9^a*gGIR@caHNeelv*?ET7r^>`eMf`h4waw^7|GM4&6S_!dwNbC>R;zl?>P zyWxYY(vy@Iexic-bpMrk>6?*Xh8#sdq{ohm@xX+*&4kVsM0l|8xX3I1KkU7AR9wrt z=u4c05P~LnplMt};{*vo8x0<4BtYZckj9+^f_r0WTpG9F4hcaTch}%2-!B<;PYRK=z=g zT_6fJmw{O7qp4;J;c-&M4)|&k+%Cuxpo_GWKo8dtvQR@(28H>U&nxU1lx9ZC_-y25=~TT6gI-kKoF_w$5so_EJ7@) zG$!L`&%3AXb*tpOR7(t^V(6@n=NFXuW`)HdpHS3~w@P|;+CPQT1G^N=tQ@VE{dRre zQ2q1+8V%I7rp}#0yeBPLdL4bwmiVc|HMSFKA7TR4Aa)|HulUHBV=Pn?87z3&jCIJL z-t?#(_7rKXb*HcYaW&Bq#qoIPKcSO2P~Rbt*q0sS4=DK+U4qu zHr)yD>tqWIrfvHML3gFpZ_r^Vi*H6+JClq{8XlyL}8o zq2rw2s#v6{MwT6)E0x3>#}cw%e&aoO&ghI_*Zdj%OgwZ{h@xHByvOuiBL8xid?dPI zQLzg0F?$-Mv+JJD)QrIYyu@1~o|sFbzM?l!t$90wjwU@@PFZag)(v9wTz#)E*9Tr$L2J>6PfhoU!?wP ze{cMi{PP!-554n0*7B z4Wo|=qfW(8UBWbSvx3D)w(O~a4EoGc+O+ST~Sn#K6YmU_4 z(&!r5Np`JPTEE6442W7-RltT73TKExn-)2$jsk6EgpYg5B}eMmFUT@;8#1$N8^7s= z4`V`X+FY9gAUYhQ6iKfp0U&v#S&Dg?%dV$cs>9xhjbwqYeQvU}X$|G)qm+je6$pgW zN@Da}WXl`%!z=DwUMv!@{40!9=~=4-dCaOBi~rC=4yv9$yfm zfh`lEky5@0>a|)_lrzDc^|1JBr*p~x3sDC+RegGKtI^;TjVz6Q$=5PC9^5`ncnWX8 zSN0M3RV|YzCKThfbxvsPm1&vfpH(Z7xcG)Yg)bOv){`tm1fz7dmc*EU$SjrDvQ%Lb zB7|Y#O3#HJ29)S4S7i@|D2m-p{LV76S#Md6UK?VlzI!V&S>3dJhl7%CuIDE?x>rBb zbhZX**MxTb;7kF}bThi80ulxH=4wYN*C^((wP#3xnS&)|d#M` zbaXg2=k#(`IIEhv9bFakJTS=dfm$yM*iL{X{QB_n)^v7p^hO2JNhxm4rJdID)0JR_~dOJv&mOfraI|vnV^HM4uos@YcO&TtojU<&x3J`SLWt%)ew59tY zO@8RmG9ZU6!SHU=r`yN3a#GNCp|0IXQr~8gNtO6yah#tWK%d1-_fT{vH~|56TEB1{ z^e+zO%}Z}v-1RY=ka110Hv7CX>O8so?o%n6K81CnxHy1X1Y#^CXto%50#1l6J=;FtLXNgQ2&MF{N{U|_{CU7!4I|it1emleA?b@$aPEVMO_E}AXZgdnPr#| zi_N2h^XcB7RKUKKR9e;EJmjaVjU~I`INZW+*KaF^e~2ZXGWtKk*6OZY2ZhE%S8R9b%VC7_;2UKnHG!{Og7UzcAzQe-`vq;+SG z!|442pJUu>E~|Ng3uq(N4AMp-0Rd4UOz`BCF&Ux9UFm1@ZHL<(Po{+p$MILixbsgLxU zc4)a!zupVuUWl;2E2W;Zd<;~2MqF67=nA@;u=L5&Kn0S?-a2eanhGs>X)}`KCDN{O z=y6{`2N@?P5p@y-CfwQJJh06%}%Dz`Kk52(xbCV^IReWedz&ZFxt3+q=D2xwA%|5|U}vPG8){A|EIDz}#bY6eMZyD&KuFV}X6ryA}KDSV|a zoxi95H43(6bnd7}DU?`vTW`NiV^Ag?w5Kv)#; z5gjK{T$4vHi`cTj#*e@o=cD>go4z?$j8Tc3J6f^D-jk%7F!POA(bs(peuZd`k%kHm zJP9I~Q~>y_-Sps5LVW>+NJribM3%Rk>DytF@ia}cxrvd^^Q>kXbyhKa;xJ)2I*dWa zQcdyVA~ZlCzm9ekn}J1U_elXUZ@y>(NWMU@P(9yOa$j&$6{C>vTA63VWw(d~Gr=+a zOVqZ}3|7i{+@cm=nM>KdDYDzwG$vksAjtrGDRl8jew&pKQKOk#`{n>CvBmfeLO%D=@45z*d^?@o#^rwa(aQaiJc+Fs^n zd4H4rz%bvC(@r$WpN8#NVWm&DJhC+#2e-VYBI47qfs6lv1Iu-H-h;L^7tdmm zil3Mee<6Mn*Zc=+_&+C7BJ6m&WBp_`oBJ1zN4k-p>u^c4^_yls?ud&OZ0P4dxp9f` z%IUbn;FUZ3^@CFEg@4iO{lj(t%-!rE&X=N$+?-f*{<2$$`Pw-3wIa$rdI``p!(ua+ zSvUCl5Y(24&0n_BYh$yZ4%+_Q*EmN;Fwm1TP`Si;7m;X5vEV9UaC zG%56$=lW4DBAXql+|(76;SuJ6+so=GH!}i7@^Dvo9Gq^@^#-xgRa6T_XDKq7*Z6UK z6j_W@onH7D#XHI7gNx}wO4p{MZE$Wp{I1VsA{e8nl|3%r3+nO!e2t*ZtBWOk{#}Ur z_NQG+1b}h(Fw4P?Cg33v8nvdol|e-B$a)} z4^L2i)*Vdd7nQ{j`(ux1SYVj%f%Pu_?5iHM;mqt;%q%9wiz0`|gb-cPU6lDXScs`y z;@LF6Qn~U9QuMs$p=B(6BrqFq*Q$}vO1pIm3qAsb+?yq`-=cM06Mu)vyEb@ zrqorOwXX>YVw$GdI3;XfF*ymUZz`?K#F(vcz3KIg6}L2D#T!Vr)4q%Jr8n9^#vvz} z%3y(206_GY8~uxdnp#im+eR^Q8}l>b&P*+2vF|*W(_&%}V3-dBS*v?$PD97b|1qa< zc@^bx;sjEtIpvVUCtdvC3On0o)2g8%(d@_%t{`P@=_lrBtq*Swg*5PHyop||2I zR$7Q)7^t>}g%QX``iR#{r1I`Uq9_zRtDoHo{a2c9Pmz8(W!k*W@(ZWF@ZBu!)t0X2 zS)Ts&3cz#Zk&&d&dXuSl)Ehn`A@#1%ejYIujAIsmH#7arNM#K=i-n$thkIq%iw*9( zemB`zdoZ4+uYkNbj5~n1O|eOJaEjmbr&`gF_W({*$N%zhAi=@h1A^mIS{cbt|HFIDsFG#{EBtMggdkM-0e%@`bu=xqhM;i zvCuG1=|u7v9=ZS`&kbFwSAm%B!P4`Gq4H_}*fj(!YMBzDN0Vg~5i~EX9M_U{=<9BP=EUADi>fOCFs19c#Pi z*{$g>P|$grd`fb1(V ztQ%%1WyjRJiYJB$Usz`>up{E@=t(p$IjmSFnjUcT-3@$*o!Qv)4STD507H01Zs z#$d1HXAJMg#f`+pK;;lJTVecq8m7IiQ79YnrzILsW!w=tz2D2yI`jM9PIm18F?J#o z-TvLmMHQu!6#9$GhFt3^%Nz_J0pOWu(qdAd|Dc^?VF@D$fBWGf9-q2pw?a&M5tD19J6WP%K zPM(vWKL* zQxVzfT1)N9#KnY^a2Cirg%AKZ>jBnehF!=aZ_3{I(l(wE{>W$$AcXrWQ=1>G5Y0d2DZx6)0psR{0*Xk%` zh_m{IldJG5^nlMq2HAifQYcyvVO8c$TNCZnzmk=@6^Jz)c|Ir&anR$05KNS7S6J+F zw9na_sPMw%!3Z(P&YYor)3WS&SfL=^noG{k=>1SHU3$eR4!?>{4(K>B5CyMh8VJ9_ zl8mOqyF^M7N_)+=K_`ath9L@DbUQtwJIQY2Ip@^5k2D+AzR={os49E26|5FcVE1I| zrvy`~=_F!DXrEKhWv`=oaE>3}?9I0wu?ubPUc!Pw*Z~UlF0jc%9|XFH-X6ySvI28_ zTEDgA0KeJKfhmr%>J-Mp1|AW&H#)e~RD=YNj?;h%wH~Ffxo<7OCV@8F2GNM0J3F(b zHJ%p??I$vhfo=LBD)TbHk*p({YV!5C^>WNLK&0yRm^8k8l=}XbbpLLev$unH-kko@ z54_j!y2-Yv1EQo50-zMXdkvU*&tm-y+|X)(${pLLxri>v#J)>R@kaN^`TRoSd8&l7 zc~(8>xa3FhaWd{B2z~#vF=M=LORV94g)b1iST3)T)gxuP?e2}tVq4r>`bmIT*v&I` z3}ewDZD4-H>3~s9kj(eAO>%bO3RVm2HLX*Uqvugxn}f<2%fYra!}j3QeNX77GQ>_mV4!l6OP~{MRG((bzrQD-#%3W`IF`cCo7ZD@VYMCkVTN#wCCx( z9sP6zEI##nkpWMbXr#1N?;&iE{2MP7AgAqnQg;cnrZf-sz^UV z+5~Hk6HzP<4Q~AikD{trOCoZ7T)wO@!U_gl5ssU1i7*#B;Fto)hg5*TWLPVDWYl({ z75Ie3syH@zdp8;z8`SM?PvBHuBz#5XRpGSooO3U)v@U+n7WT>SCJ3yIafHylKmmbg zianUXkcJ*iNJG1iS>eX4gP?jLDDXh%vqJcDTmD7TuXqcUd5kGaX|Xd_s)U4>CNyyb zgn`;w%4*tGL5*;C%l4`1G^FE(x>FKzsX%yvsDG1occZhNA-;1 z4=*m(RO8s>!tL$7di|+=mT6v_rDT1_V#>a>dnE0-YG3^?3p*zq9vERt$A-(oMl&P_ z)qdd^XE2a;bBrMA=i`Y$6Fo>!VI@USp-w|a(8zR#M!R)JS=UdNNoA2ZaON^3ty5l1 z%Crv5%ES5HezK+#NYj37$@H~4f6eBi+ubTeX;s&HJV)zC!Hu>ts}n*?Lr`yCG==fQ zwYS37?+;Qf{A$9wgH;?-q2=?79JMg>&!^s64y&xz+5irpDR^czCu@uqz4{}zU~1K! zlL{97)~szAC{%Wa^~8)H^v=Q5F~{dhl=_D8I$xr2br!Y7BMIGTu->(tD}z6q@gRgp zV>n-@NKI>5ZV!$0;gl&C=w*nBLLmh%U%)ngz?6UClnPt_M0QC@D_cw(+I^Dmtt|1r z(u~TI-T(j0M3n9Y5&#>`mYX@fq?Y+#%QwZ zdgn`nx5;1rt&9HOD8B<8frX_c*Cp7$Io#Y}9^QGG79a`t7{;3U*`9{i!8mn*j21@A z4RM`)%UPytFxgt2(j^d;!qbSCpM!fo4L#0IF@|BoS75Cy#$I&fK|^!(;1|&d*d|!*Z(kC{w}HC-+4X#gZN(y z{*G5^{BW47XzTy4y^;*(R-{>Nb2v>!~CxK z?*#s`n~c2uFBjYt9@W45h0k$aE527}bbb^)c z=N>Fo#U)sxi5^c8L4Yh^XTsly+r8Z10~wd@7fy5bll27c1OMB*&jPNmKN)6N8n!?E zPpfMQg260|v1H4prI7^*Ea{@s>)FC50b}}>JK6Cv`We>dV2u$20UB-CB84td$~UBe zv%MkUhbt_(r>eq7=<9jVAcsN^)7v*>nVJR$V--t7#*%8|Z51pmy2vIGGO!v_#!_|L z>?N)M?#3;VHoEnX_T}x5iv-#Z1Y<9@QZ_U(9HV*f1m@h{z;m>3h`0CZ1# zh#W7T%3@FZo75~Ucq9C*UEh_xTT&WO$xl!U6kLosRIHaui)gmz+}U|O)YF~!*3_{^ zFIIu=g$0sPnxVy2k);a%_8Lvk-2=*0;DM`Ol3vscf^E&cWJ316Ub?HsqY z+MJNArI;sTs*7jISg{i>h6^D-glB4DGZINj2`C<|jqtS`Wlt>uTh=*`wn#9DvP2a| z^{d3Pf`pK}>R?wNVBRBM8xGb*DF~tc9vKFQgg-ic$2*Fa?{iPNqK)I2X120D=$#+ON(w&A9$hy+KxF|ix;CkMi9qWeD&x}6&$-e3|Fe*m+s@AFhIr3evlsn=G zw6Sg8gyESsD(~QFP~fO&M0apbCchy6q}@PD4N zJeaofm3w!{+D$&h(6Voy;DqlEt0k|Xr@+qy!ZLMhvBdRrPJR3x8wHKFQOPbRhH%e> z5npgBDTPYS#T5(pW?Q2Mx>e6wusGWdALAc@%!u@%=TUd3O@ffqtTZEnfbwXDCFc4f zTJ^1C(5PTVf*UAd`HFU{IMZY**&a3b_BrVC$F>=Ri^i^kG=wl8UA35mXsfWH43Q@R zgZZH;%ZC$}v*C!r-GX^@Atg;lrNGLZvnGULl1>jYNc>TYoA@105j%OMETFsuLZ^rk zsTN`~-?WGmT2uJ6L+(^VU#o9)t;AY8XGbYDdU%jOM}#gk*Z~W)i(C|RGV@hX=3v5% z$lDL-uQE0od>qXt+ELGjs%5u`?`tnJ42{Ii=}_pr8lCfxkupoJ24S6`Y?)RjR?J(j zX<@@aX;NaCl9aowsZ=@E)NV-5Up`SSwz#{;anh;7a{Ie&Y24K^)ceFu#(+lixn|xX zl_6htjZHaZctd4i9E>kbu^-DYmRGW|_iQ^0-j9LX={jN{#mZT!k3@pq8Ihamd3}SA zIJh;xA+@Ap3Aq~TyN%F&HrXB7ov5(KF>?}8L(^@DsSDalFbnBUUi{#QvQZ1;B7plx;_v!=yrr7`;MMX}vM~>9= zDj4Y{xZX$HtC~i4V{`xRJ)Ikx7R_V+%?u)oE35X6%3AQ+JTBK4Auv-5wc@a?b(q(` zg1yfXqM!dBvXwyOHaFJJ>;ERunfpQG!*>4HKh&murzqR447n=NVRylzSO@VUztNRl zYRY@fWo{56`Y#diw;ELzUh?2!pK;2Nnm_wmHWHLtUvIA_?Ka`T8k`145$ougeQk1IzlYpK42x!A4p$p#}i z$C6v(yWV>r<7ZR}T^o?$SM}|Sr%#OyB%|hrCovIiV}%H3DNRkZ_&mcTK z{_fso4}FKKy@_p?mIwrdb;y+6)zS`jchX5gO12*C9B*EGE+F<`ciuBF^a|Ld?f5jP zyUf(4J6o7>=^{y2VUV%eB49Nbp<`3I=}B-}w|C6+#Xr>%{r0iL=Kbac$3ZJW1-q1< zWH&`uIVbrz>8IVMz%t%c^hoARV138t2hwAdr(OkpKW%g6MSe*eqdFwJf);IOhd`$xk6VdnDLw84hB=ixD%cdlBc#x;=>CEW0_m@ zP8rX-cKU=2w-JV}FTPZaB*g{2bC0gOD!zk=4&(!p*hO2jpeB}G_auJdY)~U7G}jt|UiUG()?MxCvYE zC;3F(&U@=Xves8Egh;F<=-!-I&^(JdTMkw`wO@EP)N`z=(xN?oD&R8KrC_foXlPeW z_R~UpD&k|>lnGx+dU`}#qZZy>a}jYGG4`#X z!%S1r^^^hgWvf(!gV#w08QB6P4(1VT^^8i~*><<_P;rQBYq-8CoRqd5R2XJ3UJ+LW z?z<)+=bwM{08{rufTOlsIcFeV<&_kht0QMWb%1h~~3Jh5zW$2C?*VILKgRU4~Z+e;0MhgH#0 z_Hi_c5wxDwz!)xfkDL-OrR)_uqu3Tj#XRCNWkRQtUDveaBrU);SuCvQQOWpH2-2)MnN_%W~&U~DW%Pr_!n9ah=EL~ON*2Biw z?|ass_{-DJ!$UkV7XecxH_iE9xC;@JD)!D?tUk3tVrDerO)9=IqLYllM8w}^JlEDZ z?i%=lK4s2mbyc7gXms@wZ&c|c2MKqF^j9W#y~6r^>Z|9C0peg=EOfYRp-i4guZ{M; z3xC;FI9tRR**%^fboIRbym!{{my)VzJK~O=&*wEewc4hhobJ1|6jMAyY4VX)=THI&lFLYH=MgN~eRP+1Qc z17jfP4_i-#p>FGH?7%!rV>SiXVM#09p!)!xNfv-wgz?eeX53)T>mPEiCymERrlRF`0jzT$F#vg$a&Giy6%ZbRUWvU1FOr6LmBejgUz!vQ;Ajru+pE`cnH>}6L?s{ zPLf$%&xEvRaO`(%Y|T9&1yZVOC{x}UQLcKh?EYqeB&Um}BdYz;L7DQFof5-YTvdtR z?&&VkB&x)fO)RIeV(+^n-~q~USEE%pPGf|jEav$XN?qn7Llwim4pDR|*)%y}=wRq* zsP-DB5UxxWSX8xzhO;BV_VLs)rfr7riZG!)_0`kZU<%i%pjCKIaoygyCU%R**vDV> z9_NnVm*3yw|Jw22R!8-(HTIvD-Em9s`2TiW4B?16;Jgs0LAuR)nD0-c@Ai`E? zE8&3$nUl{3*8aS!kt-C^16o^=rw+H~I`6q*jH8|UG#Tzzwa_$6JL=oc1TiS8;i{HB zetX9syZtkEtlXUTxp45-BKSk<{cj%jLD1NEfeqfy;ME(EKcxP~E&o#`f0FtexBQPv z{@dTm71MpbS#Q+U)k`s~XePPAn=$02aWs~>H``xo>7&+DJriOFm^Q(ISJZ;t3#c6( zGnf7I->r9pj(|hP?*6G848qt%vabS_eFO$}XgB~JIkD|8cNOB6qm!yT%kH5Rly#I0 zotCHE6D;pDR850_ypD+?bpC|=r^DmlJNfTNFVGOLeJ%KS6`M3h_bqrT#Y=LR7+x`@ z;K(7bg^f2PM4?U}pOSXuO|2&c6ihLt>(HmT6L%HDcZ;`%G8hZhHOZ?#g`XmRSj;(A zve5Vuy}0OPd}*$*Rggor%#jcpcX*cFGnA(>(HlB3(|ki@8k-VXUa|Wyf|j!vORN`2 zy1)|a?|cZO5E0yw3j79p-a<63#E+Dxe5GJ4FISHZkPi$D$o&nUw-7jMSo;_Fd;DeC{T65Ik^ zx8k9me)gey-3OPW>l0OaA=Z3ckIN(~pJs|o<_1pA%63|EgxK#rfnl{t-Q`#a|C-p} zApF}{2)}FpZwNm&HW3Tq|Kne}^@rGpzv)ftc_}aF?0=*D3H~H@#~%kvlK-O`kw3)# zrn>r5Er1QD#*If6B-uyPg1)w_H^J)967(WKAeEXh%pQ<4t$KH6NV@smUYqsiVtD55 zRNrGd34^{~{eeTk&qHMy8-$IeDS&s$7`=R8bDz zwVmb!*~*#B9cfang5x4oPmjqZkXAM~Pp88OOt0Y*%|7sB2$)3)vpFzA6GaS2&(48C zW$*vjAKSe@9xj%M@*f=`tCcT`sGoFJjI+0I0?Z%vS7oN&WhM33LC5?k9s5X_J2??J zlCj5a_2q^-uWw-^MXN*n{nkDD)$K{OrOL`-N@Ys3GRf_Bcr}qcqZ0_o&~3NNJ@%); z`5zy4|F!o2-3T9Y+u68`k!6)JCq;^jp6+*RR83(YSf}qq$Pzc##!1$Ka8X1i>u^$8 z{j&nykwMJI1NOvsjm)}U8+aU;pSgi2Aklh^LreJz3F(x|m`x(&?ckf-u!2aN&BH)d zas^ml)J^W5FcwStiCIn+hVhw9Al6lc{5|^@92}vBUpT7!N^Bj}-Hya8C24u4VAyhx za}bNw5#9ZZtlYni8k61K#|Ou=!-t`xL^9?a(bp#zPfha^5#mHh&kr`T2lxgo61OzY zM@K>~dn+c((T*dPPg_}-BQRUSZj_Q{hze5BNu+9fAakW^{s~&X>5o<8f6LNCkhfKxbEry{ zN1mi5cX>MhjYDnP^5;kKd&fqVWs4wWElQQKf7nZ{90P$v_LrffM zMWPMuLb2KqsM+r)`0pPC#P??Eoe(J6%-{EAcuo0sutqT3yG5i5Ueh)t+#f`Mq>%1n!#xx`9AzUL)HrvHur&){#s2Z zjyGSLH2YX1SnoW)I;#^Z_j`7>)mr%mSf1X0u#CB)QmPQZkRr8 z5=~eddzzx7N->l&>L-JG^h${Tht(0f`xnlT{xVcXe+f05{CH@VDVpEwUQneWe_PdL zEcYZ=H*!mE-q-BM79) z{c9|Yd3nqO-d=#H5l|~2Tc4_JnRA6G@36t}rdR`1V1%ra^wiazpKovSl@lwpeV;LK zMxq9}jKtAkOxR>Qy4*z8Z;4EKnK?+eC-@X_n-&h2ypZ!IZf1`@>3| z<)sy2Udod+9r*2xEV!W#r`V>S+KdJ3PT5unAzq213nga9U z>0V9g!Gv`XIK@v9qMXS|W;Fwscq1ta3o`?I%v|3|R4Gg&!(``*dMtq&Vto+{3p{+c$QC{?fm0%SXv4zKb$$A zX=r3L)5mS&-IIScEX4YDXvW^9I@CV_A^YXb#tK@G%oN@B{(|Lct>=1AFkRnDHIa8X z=PT9c3wv%ybe=_rhiLme-NnNv=ueVqiN~WBfmD;NMfu@3`-WPvv>?JgQ=NT70%!SV$Sd!Bz1+ zZh1O8fC3Ec4U?UL6x7(*s4GdRnyUCOvn;*9g+%MT={q}aBsmIsP%YS zSk+#iTl@Gl`@&}0iW7v`tkIvpX!~jE^ct=ocOLDo*=$PN1tAw#eD$Bte(WzdtuZ9P zun^GM14x#sV%j3Jh^>~ta+N8_^AS1G(?4fZRGX|WKun{aBgloMwM|GjY-L;$7)QSc zGt^`_=5Eg37wrT#TbORlyqW*gw79ik<$a_fzD@iK=Z1ho44FBFUA^-dc>3#1*?zah z&%DZ_ZZYdzYy?v9boJhR3FX$%kf~dd3|EAL-BiglHR#tqorrs}`7;WDLLf~Mdt9?5H?x}lo%){?Rd>w0H8y@&?P zxkuP0c*iRFayu%>8Gj^^1U(vbB6FCp7NAL5rq!jE#3Qo-5FL`r8l4;#HpECt%FBsygku*oT>vRUni*CpNF%$lSQ`et1Mo?}jR(LS^a3w@pn7uTt5>i`Ty(iGrqd6Iy zG?KQ|w7YRs{dI4AR}a@$sJm2v$Bprr|C(c)-~+om)Fg!n=5$p~9aNO9uX~2r!HqF5 zWyV}I0=N9dZ+4yd*jUEOxM?a=>Q;4WmYfRdWUYn9oOCZP?ZiKV?7HXnF9C?{N~w&T zdgoZf>3YqC7wP(Xsu<(YdEli}rrLC-rs-2R_a3u9#>ca``EP~17V00PD--L$R`Yzo ztl{#FvRSw-gg-NXQ(nRIRLrk&UVqBLdPs9~8;Rt9L8r+-oHmn*S82=U=FtHLZHL7; znN7`T-%H*BkJ73O5f=9W2n`lh1{-rp>Zlqr8!supzVPBMQZn-!x~++e7Nu zMA5k)rKC#-U300I3?V42l48?q{iVkTiG z;b{Gm!!3B;QT$G0tK-d#p;uX}hf_76$gL7qkvMl#PF9l!GZ=+C=xyYbCMJwg26D(V z@n$8#krtFz*T&xEp{_wjnTL7=bNFhJsx9=e$pc~vUd^hRZd*eVLE<*%zFhv#CF#%e z?V;CtE}!}RX?>mod!!PCGfGEGBf(OiDUYi+Rv=I2+&W&L(s{mM95O3V6ntfE8}jD+ zy=eWF?Uxl8uB19A!gen^jd5sIrgS_FcXaE=4h6`$U>yWSeoFho8)Y=6He+|qkw{Q- z=e=g6HB-~stFj&70XVPHH9Q_w9=_9xCBxX3PKp0%iMk7L2?vc9hGcKhlXR+11#cy% zEN4%t2TLF>kQ1}^4I%dP4gJPbdg3h4E24XqJ++WpqdIJ|(x%o8eVst)q^VN8mQzLM zxWnv`Tch*50X66OV@SWIyt%6F9?0UbL?=g*R3?P0(&bhD6fC|hbWC3U1TY_jz6OzT{jrkb{Aopy ze58t{g2d1Sv$@TbSyT*)B$UjV9!}aJt4!N=7k^kcyEUbJ)S8;fd|Wo#3EC-};K;{X zFQ&T5nUP!SuClz9)XL~f>W3AZ5fQ1iiNKjy=F=IgF^ie5pCry&6FcbG)`SZu(x?@& zA!t-Ods!R`DKi`lWRdz&OhjZc)5Oieoh9r^F;Se*{z>S*r&^?6&Ef=ng*+ii@_}lF zA1Y6_lLau-(;@MvS5;iq{8x5q)}wU(bdtgu%>)&D@T9dvYux2)FAQajIk#HqiebRW)~>wSue%`xk> z#^WdQ6SR?c3B$eq>BF#XmMI$2aDx}8(DfINmT-};c=pq>I!h-RL>>?(9WEZ<7XI$Z zy$`wJ+R$u0U{&V32!~U`C$1@NHO-s(<5)rvv+iCt$J?0N3h;fm4V_!+nHrOoz0&uO z`3IkM!n0T<^9)^chdX-J0`YKfmVDV#ZZ{yzIrn3dU4E52()%(b;B`z$I9KtlEnP@C z#4{kV`vZ!nb{9e}R2r3(&|V9?&PF?|oIJPG@OWPK3&&rCr&h|InDXnzBkFm_?o{y* zZ0N;J^&5J;_X?-rPNNO+il16ivcn8o^Cph#)=qYoA7}RH$r{3`6Tppe*u1GgR8+xW zM{~o*551@o5|n-yw=eqQX*WB<1h(p;@{!7*f9GOf_K|+h49p#VDuWaAMM{^t_=I9D z+j(-9hA2>dvk10HKPGC~Ni-Miq1t(ksRtn-glEA^P1oP2pD}mG1Lp$yT*TKf9BAqs2NUH%HnbnrW0zjryxe zWCQy`OzORz%cGnK%Qg?MEzh5+X_CR4IeKgk{%bmRHnEyFGqQOprAjIJL-@`#i;*#x zAA#A5L)og6g#%3@$gEWQ1?XFy1=6na!aDY@X@RJ$iPT9Mzz3tXXdP%!>Go5-N*N9R zf<#$pu`;D4r`57P3OR&6RPN~8P|?>SFIwf1Oj-Agqu6x@gD!}XiB;?-Ivruuwl(r< zkNrLu;}_Ig%n#1ay~H!P2^iw=k|}UldXmeq7+k2B&`n9}qwEZ`+}Ea&eKcLJd|8iw z`hB*0z}>l{-?8G`xn`r>C z;5u#d*ta#tAbgYu;8i=FtN8P)&m_1=vdaQ}$nOGhWVjx)S~c`t!huK_xb0jUsh^F9 zdz1Wz$cZ}r!g-!|xz!;aEQ_^Y;NWN{Q(YVghhAs;d96JEo#MoPuXU5>Je>3*=Bv?r z9MW$Z=lLTKU+6Jfq$E3>@ra{z!Yh$IZj7&b1XM|tPvjT6i)bw}R5NSYmObi2YTEUR z%a6`><8zJ$A~gCEiD*yhEtZ)-#W~3w>L~J2fIT#8osSmx8i($&t~V=8Md71 zK9X7Iw#6^l6K}m!YR-;H3gWBH4Xq*tlpRG01_*3lJ)oax1a##T6}tVxQLJcwz43WX z-&?$IYadUz8umgUmkA?qe{SFcAb{U=8yc-(R#Af`|83)xePQ!b8uf>p<_U2ou0K&3 zONiSgIs`zS#2>Zig;2Z-?3plCLDoAHmqwv$wk^d&OvL-p$EtZQ;n~LLvH=cY z#$hW&Mxxm8s{NZgB-5q6HoVz0yeu#5fMge^WNkG!J8=qhQ^y!HHcs4cDHjOaxj(Cs z!R>SQ_(rbhq^U_;0kKN}=VeMon}Vm2Br=VcBioSBi|Z;&s+Oa^X+?*{t>;~pmet7} zhz#Tili+8=*#}W{9+5*3koXG|00ZijArynhezQXLIF@IDTFoz)LQ)XyJW<%r5#G0LiQy%HVu{J2Watk_1X0Cbb6)>H&;H3L7#ZV+WlU;)L?EC zBU7SenPcp}+4eqj`nIZ&(61Lfp=&MhH=wPeCYvnZZT^MHcL)Y=*i5g8B8<3*7!^&Fvfroi$LRDnuJKv2gMi3`MYga+QQeE-rZS*!JP z-SBxBXUlbu2y}65lA8XSR0;t3ycAv3s?E#73<=SOX0b>qGt-6hfOC%Zebr{y=SbtUweOAXS{$O3Zv5-jYx7HW38K+aDv^ z1Bmrp*ihB%lnG#yj1J%=f6>NQ*y2xAb_>lI$a$BOGv4P_h2Jg{bV-qu_}1np;1SPc zDkpSE6Eo(+P~vq}f9%#jW}}@ol^MGrc;eI5UGO=W%uZqJ%^4-(6S4=xbu-Mn)%8i< zH?_>Xk^^s8e9^wr-CRvnITFAhQ`E*wj_De?S1{3$`$*OYjI33nY|zM%Ov1;NuLgnR zIYPV+xj)1oC(9~i>RRVbRFRxWTISof`+^mFkYKFPl_($*$f0d~KC;(_6c| zRhLSyc)cbYV4*-XXWZj>&Cm-0hn;-K9`SWo*jmM{Z(la9r6pZp zo7d2|)9`Y+YkSoSp%y;i>PW^c>#fa}r6|MF!2m%y<(TC*=>;l`TRM{`2Sh8;gk zZ*_+Uv|-rcqyL3eqdB?7OVIJS83T9vepJ7)w1T>*p%K5Q8=X7*cFQ&Ihv@SHXMo+2 z`6i#n_)%p?#aFFCazPp+`_gWkFtv~qXMTQNNL=6gL~{u)j!b1U%#J5&bR!PU=-lj~ z>UI9egFwQT)zsqaxsMdlnU(lT>TiHK9T{JXm${mz$^SSorFO6I__Sk99>0=fdpq_e z$A0oS;v7fQW2A75x&8%Do;I8xpY*h!?yna!mUe51pB6lt+lU?qaFw%UPQg`aVc#x{ zWUeS>JCX6UWOR@4N1OJziE)i3)i#Cza_SuPaO5@bmsp0V##%#<@+TDw zTH&b|tAq*%-@x#Y>yT6z3cXCK3QU;Yk*+oB>+SeircG$FFwAJEscs7w@1TrkXu&h@ zUr_Fk<`LT-dnCtw0dV10-YA_F?cVG{kP*eRAl?c^RC+SUm};?(=GD%Kc3@|^6EhOk z7_|*LJf(_O{OtRKmEav|)z0WA!kXQb;?xqsYQcuTuk{WU%#fuWZsrkZD$~gU(25zDxlK15z zKTVVtXt!{uKPfd097?Q_s+S&Ad=H8V(nBIv@^Tl33#Ap>RQ(2*@0ezio#%6L6p;yC!p7fw^=2+gn^NFtOZbsu5H^!BlQ)cX zSuyE>@e6|aV*>ZXjsSrFl+^uWf{nqi%#OTgvfF_n^qdWy#X2r$Yj%mZ_Y31l=fjp(y$yI#`8o0mq%;$?ZISDk zgqLQTItb-%1Mv5wtrtpc*Y`mW+A4a513(>$GMfjj5#%ZvXPh|~rB^-CEMnY>Ws6*k z)(O3PQiM{!T9z1D*d)C;7Xiyt<}oq)KGZON-{8wks`)Bvz$HM~QOL^T+A#vNgS z(E2@JfkRFJBRiEfR&FxK+m(o)5VA-d(Agy1D>v;;mZ8At%#b)i=bF#%Vo%p8?i=?x}bPHv?n&H zj3{T4?R$&C=)3=ODjL=4oo(*za;2QI54zpT*FxCS8Od!Y8HzV=&o=bGDjSDh(8)SP z>mU*AC28#n!Nt#ao0!Q>V6xRa?aKoO#od;c93Eo7G%iUDascsBE9_;>P7t^LBcCIu zRQ7-n25_mDoz;`Qj8B`+FSI2$@QdkdOkF>}`@N9)NLs*FDPQ(rM(PXLMLLNi^mdCq z)|NDO44oQ12z&M1@CUlBtBb8%cdZww&f7Y|USo9b0B&-Z^4j^mR{iuEc0+Y{KdU~xdC6;4x9-Z) z_gz}puGOvIE*0QSBDaDxH(}!XDw{|4G7KoQ#DdvD_^|7wc$YW<&7vFoq979 z5T>Ol(A3sMWC}fxd*#l0xg%Gj=V%g{2=^5T4q?!>bF{BD+vts35GmZ)WwYc}yUg83 zIOu#uStpI<-3%}3z83I;=w3w=3@J~zg8W2LpkWoT$K)phWA@4SPd&47Xl1`c+meGk zIjlG41Xl*^rvw}zUNu~!W6u-smQtr|DB#WIsvU`TdUe9Od2bb``4*MkV(}-?9e;lt z4M?WKJimOM(Dphk@?)N74P^}90?9a&17rP^OhdKy+~8N6utF@--8-AAgO@)Ia9wv;7N|VVo~^gHOqz1(7$w> zUuWo+XRBm^!t|?jI<>9)79mdwIU_kC4kG7HMZ4Aeo~hf@2t3^?YTl%!hiptosl1Tk z;9O@HCv{o=2JAxR4|15#(MO>Eeen^S(Agv6p>}}f<;9TymmU_4`vw)B7!<#UV*~k4ioc3EscR~X*_B*{cXeLf#yJsc*PW$gjrM6p9vPj~bK|~tR$D+& z;vd>j(1*2K00u;LU!RgFwWDhKuDv^I-u&4SSnml2FMlbTtwji5r7G!@LIe#&Y(KU_r=ox%e!YaE3zK zf~u1y?rt)5Lsqp>sg;#lL4vb&#jxK1Xl(yaIs{?*HT#z)b*j+K1sG_mo*?>fYnuRn zPf?&ud|u&B=i>W9@TkUIb!k%)2W1^o#15~V`hnL84k0nb6eXs2`%?xo?4u=8j!q*I zkjmYTXh>)eDd;Sh43DpXu@n`lhYS#kca3+q`^1=syPS^$A~aaDKR;85@$E7yODY(a u2#jfSma~qQ;i+5gs9Dwv5XBJ%X9Ko#{x(tJ_#(#|IM%@bs0K8D)BXuMs+kJ_ literal 0 HcmV?d00001 diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc index 6bb1870486d..def68f7c26a 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc @@ -128,7 +128,7 @@ ChatResponse response = chatClient.call( )); ---- -TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java[AnthropicChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. +TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java[AnthropicChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. == Function Calling diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/azure-openai-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/azure-openai-chat.adoc index 000d7f4c4fa..759aca40418 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/azure-openai-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/azure-openai-chat.adoc @@ -132,7 +132,7 @@ ChatResponse response = chatClient.call( )); ---- -TIP: In addition to the model specific link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiChatOptions.java[AzureOpenAiChatOptions.java] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. +TIP: In addition to the model specific link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiChatOptions.java[AzureOpenAiChatOptions.java] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. == Function Calling diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-anthropic.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-anthropic.adoc index 806b5d9ebf3..d6fc03d60c0 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-anthropic.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-anthropic.adoc @@ -116,7 +116,7 @@ ChatResponse response = chatClient.call( )); ---- -TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/anthropic/AnthropicChatOptions.java[AnthropicChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. +TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/anthropic/AnthropicChatOptions.java[AnthropicChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. == Sample Controller diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-anthropic3.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-anthropic3.adoc index af1ac613ae2..3b4cd66af81 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-anthropic3.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-anthropic3.adoc @@ -113,7 +113,7 @@ ChatResponse response = chatClient.call( )); ---- -TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/anthropic3/Anthropic3ChatOptions.java[AnthropicChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. +TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/anthropic3/Anthropic3ChatOptions.java[AnthropicChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. == Multimodal diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-cohere.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-cohere.adoc index 9f0c53592b5..8133f3bc26c 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-cohere.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-cohere.adoc @@ -109,7 +109,7 @@ ChatResponse response = chatClient.call( )); ---- -TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/cohere/BedrockCohereChatOptions.java[BedrockCohereChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. +TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/cohere/BedrockCohereChatOptions.java[BedrockCohereChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. == Sample Controller diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-llama.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-llama.adoc index 1d04b2b8ffb..435b71e47f6 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-llama.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-llama.adoc @@ -107,7 +107,7 @@ ChatResponse response = chatClient.call( )); ---- -TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama/BedrockLlamaChatOptions.java[BedrockLlamaChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. +TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/llama/BedrockLlamaChatOptions.java[BedrockLlamaChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. == Sample Controller diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-titan.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-titan.adoc index 7ba332e67a9..38978fcfc2e 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-titan.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/bedrock/bedrock-titan.adoc @@ -105,7 +105,7 @@ ChatResponse response = chatClient.call( )); ---- -TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/BedrockTitanChatOptions.java[BedrockTitanChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. +TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-bedrock/src/main/java/org/springframework/ai/bedrock/titan/BedrockTitanChatOptions.java[BedrockTitanChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. == Sample Controller diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/mistralai-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/mistralai-chat.adoc index a53638d7bf6..26fb6afeed9 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/mistralai-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/mistralai-chat.adoc @@ -130,7 +130,7 @@ ChatResponse response = chatClient.call( )); ---- -TIP: In addition to the model specific link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatOptions.java[MistralAiChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. +TIP: In addition to the model specific link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatOptions.java[MistralAiChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. == Function Calling diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/ollama-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/ollama-chat.adoc index de61f88518b..0fb32b9975f 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/ollama-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/ollama-chat.adoc @@ -126,7 +126,7 @@ ChatResponse response = chatClient.call( )); ---- -TIP: In addition to the model specific link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaOptions.java[OllamaOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. +TIP: In addition to the model specific link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaOptions.java[OllamaOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. == Multimodal diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc index 99fd646346d..440a4a6b6bf 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc @@ -134,7 +134,7 @@ ChatResponse response = chatClient.call( )); ---- -TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java[OpenAiChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. +TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java[OpenAiChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. == Function Calling diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/vertexai-gemini-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/vertexai-gemini-chat.adoc index 3b4b8944e8a..ef66bcdf84a 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/vertexai-gemini-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/vertexai-gemini-chat.adoc @@ -65,7 +65,7 @@ The prefix `spring.ai.vertex.ai.gemini.chat` is the property prefix that lets yo |==== | Property | Description | Default -| spring.ai.vertex.ai.gemini.chat.options.model | This is the https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/gemini[Vertex AI Gemini Chat model] to use | gemini-pro-vision +| spring.ai.vertex.ai.gemini.chat.options.model | Supported https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/gemini[Vertex AI Gemini Chat model] to use include the (1.0 ) `gemini-pro`, `gemini-pro-vision` and the new (1.5) `gemini-1.5-pro-preview-0514`, `gemini-1.5-flash-preview-0514` models. | gemini-pro-vision | spring.ai.vertex.ai.gemini.chat.options.temperature | Controls the randomness of the output. Values can range over [0.0,1.0], inclusive. A value closer to 1.0 will produce responses that are more varied, while a value closer to 0.0 will typically result in less surprising responses from the generative. This value specifies default to be used by the backend while making the call to the generative. | 0.8 | spring.ai.vertex.ai.gemini.chat.options.topK | The maximum number of tokens to consider when sampling. The generative uses combined Top-k and nucleus sampling. Top-k sampling considers the set of topK most probable tokens. | - | spring.ai.vertex.ai.gemini.chat.options.topP | The maximum cumulative probability of tokens to consider when sampling. The generative uses combined Top-k and nucleus sampling. Nucleus sampling considers the smallest set of tokens whose probability sum is at least topP. | - @@ -100,7 +100,8 @@ ChatResponse response = chatClient.call( )); ---- -TIP: In addition to the model specific `VertexAiChatPaLm2Options` you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. +TIP: In addition to the model specific `VertexAiChatPaLm2Options` you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java[ChatOptions] instance, created with the +https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. == Function Calling @@ -211,6 +212,7 @@ VertexAI vertexApi = new VertexAI(projectId, location); var chatClient = new VertexAiGeminiChatClient(vertexApi, VertexAiGeminiChatOptions.builder() + .withModel(ChatModel.GEMINI_PRO_1_5_PRO) .withTemperature(0.4) .build()); @@ -221,3 +223,9 @@ ChatResponse response = chatClient.call( The `VertexAiGeminiChatOptions` provides the configuration information for the chat requests. The `VertexAiGeminiChatOptions.Builder` is fluent options builder. +== Low-level Java Client [[low-level-api]] + +Following class diagram illustrates the Vertex AI Gemini native Java API: + +image::vertex-ai-gemini-native-api.jpg[w=800,align="center"] + diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/vertexai-palm2-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/vertexai-palm2-chat.adoc index f67a467f059..045df41e2b2 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/vertexai-palm2-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/vertexai-palm2-chat.adoc @@ -97,7 +97,7 @@ ChatResponse response = chatClient.call( )); ---- -TIP: In addition to the model specific `VertexAiPaLm2ChatOptions` you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. +TIP: In addition to the model specific `VertexAiPaLm2ChatOptions` you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. == Sample Controller diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/watsonx-ai-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/watsonx-ai-chat.adoc index 1cc4445f45a..79ce3d8d9ea 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/watsonx-ai-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/watsonx-ai-chat.adoc @@ -92,7 +92,7 @@ ChatResponse response = chatClient.call( )); ---- -TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/WatsonxAiChatOptions.java[WatsonxAiChatOptions.java] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. +TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-watsonx-ai/src/main/java/org/springframework/ai/watsonx/WatsonxAiChatOptions.java[WatsonxAiChatOptions.java] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/prompt/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()]. NOTE: For more information go to https://dataplatform.cloud.ibm.com/docs/content/wsj/analyze-data/fm-model-parameters.html?context=wx[watsonx-parameters-info] From 14d620e2caebbe8d0d2234d12690c787170f6760 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Fri, 17 May 2024 06:47:43 +0200 Subject: [PATCH 22/39] minor doc fix --- .../modules/ROOT/pages/api/chat/vertexai-gemini-chat.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/vertexai-gemini-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/vertexai-gemini-chat.adoc index ef66bcdf84a..ecf11add0b5 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/vertexai-gemini-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/vertexai-gemini-chat.adoc @@ -65,7 +65,7 @@ The prefix `spring.ai.vertex.ai.gemini.chat` is the property prefix that lets yo |==== | Property | Description | Default -| spring.ai.vertex.ai.gemini.chat.options.model | Supported https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/gemini[Vertex AI Gemini Chat model] to use include the (1.0 ) `gemini-pro`, `gemini-pro-vision` and the new (1.5) `gemini-1.5-pro-preview-0514`, `gemini-1.5-flash-preview-0514` models. | gemini-pro-vision +| spring.ai.vertex.ai.gemini.chat.options.model | Supported https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/gemini[Vertex AI Gemini Chat model] to use include the (1.0 ) `gemini-pro`, `gemini-pro-vision` and the new (1.5) `gemini-1.5-pro-preview-0514`, `gemini-1.5-flash-preview-0514` models. | gemini-pro | spring.ai.vertex.ai.gemini.chat.options.temperature | Controls the randomness of the output. Values can range over [0.0,1.0], inclusive. A value closer to 1.0 will produce responses that are more varied, while a value closer to 0.0 will typically result in less surprising responses from the generative. This value specifies default to be used by the backend while making the call to the generative. | 0.8 | spring.ai.vertex.ai.gemini.chat.options.topK | The maximum number of tokens to consider when sampling. The generative uses combined Top-k and nucleus sampling. Top-k sampling considers the set of topK most probable tokens. | - | spring.ai.vertex.ai.gemini.chat.options.topP | The maximum cumulative probability of tokens to consider when sampling. The generative uses combined Top-k and nucleus sampling. Nucleus sampling considers the smallest set of tokens whose probability sum is at least topP. | - From 654e22be9d174b43e3bc51a6d1457ea2adcec17a Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Sat, 11 May 2024 15:36:38 +0300 Subject: [PATCH 23/39] Add TypeReference StructuredOutputConverter A ParameterizedTypeReference alternative of BeanOutputConverter that can cover list of beans as well. Based on suggestion: https://twitter.com/kisco_/status/1788862522998546440 --- ...trizedTypeReferencefOutputConverterIT.java | 109 ++++++++++ ...meterizedTypeReferenceOutputConverter.java | 179 +++++++++++++++++ .../TypeReferenceOutputConverterTest.java | 190 ++++++++++++++++++ 3 files changed, 478 insertions(+) create mode 100644 models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientParametrizedTypeReferencefOutputConverterIT.java create mode 100644 spring-ai-core/src/main/java/org/springframework/ai/converter/ParameterizedTypeReferenceOutputConverter.java create mode 100644 spring-ai-core/src/test/java/org/springframework/ai/converter/TypeReferenceOutputConverterTest.java diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientParametrizedTypeReferencefOutputConverterIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientParametrizedTypeReferencefOutputConverterIT.java new file mode 100644 index 00000000000..146715f34d4 --- /dev/null +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientParametrizedTypeReferencefOutputConverterIT.java @@ -0,0 +1,109 @@ +/* + * Copyright 2023 - 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.chat; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.chat.ChatResponse; +import org.springframework.ai.chat.Generation; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.ai.converter.ParameterizedTypeReferenceOutputConverter; +import org.springframework.ai.openai.OpenAiTestConfiguration; +import org.springframework.ai.openai.testutils.AbstractIT; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.ParameterizedTypeReference; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = OpenAiTestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +class OpenAiChatClientParametrizedTypeReferencefOutputConverterIT extends AbstractIT { + + private static final Logger logger = LoggerFactory + .getLogger(OpenAiChatClientParametrizedTypeReferencefOutputConverterIT.class); + + record ActorsFilmsRecord(String actor, List movies) { + } + + @Test + void typeRefOutputConverterRecords() { + + ParameterizedTypeReferenceOutputConverter> outputConverter = new ParameterizedTypeReferenceOutputConverter<>( + new ParameterizedTypeReference>() { + }); + + String format = outputConverter.getFormat(); + String template = """ + Generate the filmography of 5 movies for Tom Hanks and Bill Murray. + {format} + """; + PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format)); + Prompt prompt = new Prompt(promptTemplate.createMessage()); + Generation generation = chatClient.call(prompt).getResult(); + + List actorsFilms = outputConverter.convert(generation.getOutput().getContent()); + logger.info("" + actorsFilms); + assertThat(actorsFilms).hasSize(2); + assertThat(actorsFilms.get(0).actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.get(0).movies()).hasSize(5); + assertThat(actorsFilms.get(1).actor()).isEqualTo("Bill Murray"); + assertThat(actorsFilms.get(1).movies()).hasSize(5); + } + + @Test + void typeRefStreamOutputConverterRecords() { + + ParameterizedTypeReferenceOutputConverter> outputConverter = new ParameterizedTypeReferenceOutputConverter<>( + new ParameterizedTypeReference>() { + }); + + String format = outputConverter.getFormat(); + String template = """ + Generate the filmography of 5 movies for Tom Hanks and Bill Murray. + {format} + """; + PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format)); + Prompt prompt = new Prompt(promptTemplate.createMessage()); + + String generationTextFromStream = streamingChatClient.stream(prompt) + .collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getContent) + .collect(Collectors.joining()); + + List actorsFilms = outputConverter.convert(generationTextFromStream); + logger.info("" + actorsFilms); + assertThat(actorsFilms).hasSize(2); + assertThat(actorsFilms.get(0).actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.get(0).movies()).hasSize(5); + assertThat(actorsFilms.get(1).actor()).isEqualTo("Bill Murray"); + assertThat(actorsFilms.get(1).movies()).hasSize(5); + } + +} \ No newline at end of file diff --git a/spring-ai-core/src/main/java/org/springframework/ai/converter/ParameterizedTypeReferenceOutputConverter.java b/spring-ai-core/src/main/java/org/springframework/ai/converter/ParameterizedTypeReferenceOutputConverter.java new file mode 100644 index 00000000000..a0b79261956 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/converter/ParameterizedTypeReferenceOutputConverter.java @@ -0,0 +1,179 @@ +/* + * Copyright 2023 - 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.converter; + +import java.util.Objects; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.core.util.DefaultIndenter; +import com.fasterxml.jackson.core.util.DefaultPrettyPrinter; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.github.victools.jsonschema.generator.SchemaGenerator; +import com.github.victools.jsonschema.generator.SchemaGeneratorConfig; +import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; +import com.github.victools.jsonschema.module.jackson.JacksonModule; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.lang.NonNull; +import java.lang.reflect.Type; + +import static com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON; +import static com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12; + +/** + * An implementation of {@link StructuredOutputConverter} that transforms the LLM output + * to a specific object type using JSON schema. This parser works by generating a JSON + * schema based on a given Java class type reference, which is then used to validate and + * transform the LLM output into the desired type. + * + * @param The target type to which the output will be converted. + * @author Mark Pollack + * @author Christian Tzolov + * @author Sebastian Ullrich + * @author Kirk Lund + * @author Josh Long + */ +public class ParameterizedTypeReferenceOutputConverter implements StructuredOutputConverter { + + /** Holds the generated JSON schema for the target type. */ + private String jsonSchema; + + /** + * The target class type reference to which the output will be converted. + */ + @SuppressWarnings({ "FieldMayBeFinal", "rawtypes" }) + private TypeReference typeRef; + + /** The object mapper used for deserialization and other JSON operations. */ + @SuppressWarnings("FieldMayBeFinal") + private ObjectMapper objectMapper; + + /** + * Constructor to initialize with the target class type reference. + * @param typeRef The target type's class. + */ + public ParameterizedTypeReferenceOutputConverter(ParameterizedTypeReference typeRef) { + this(new CustomizedTypeReference<>(typeRef), null); + } + + /** + * Constructor to initialize with the target class type reference, a custom object + * mapper, and a line endings normalizer to ensure consistent line endings on any + * platform. + * @param typeRef The target class type reference. + * @param objectMapper Custom object mapper for JSON operations. endings. + */ + public ParameterizedTypeReferenceOutputConverter(ParameterizedTypeReference typeRef, ObjectMapper objectMapper) { + this(new CustomizedTypeReference<>(typeRef), objectMapper); + } + + private static class CustomizedTypeReference extends TypeReference { + + private final Type type; + + CustomizedTypeReference(ParameterizedTypeReference typeRef) { + this.type = typeRef.getType(); + } + + @Override + public Type getType() { + return this.type; + } + + } + + /** + * Constructor to initialize with the target class type reference, a custom object + * mapper, and a line endings normalizer to ensure consistent line endings on any + * platform. + * @param typeRef The target class type reference. + * @param objectMapper Custom object mapper for JSON operations. endings. + */ + private ParameterizedTypeReferenceOutputConverter(TypeReference typeRef, ObjectMapper objectMapper) { + Objects.requireNonNull(typeRef, "Type reference cannot be null;"); + this.typeRef = typeRef; + this.objectMapper = objectMapper != null ? objectMapper : getObjectMapper(); + generateSchema(); + } + + /** + * Generates the JSON schema for the target type. + */ + private void generateSchema() { + JacksonModule jacksonModule = new JacksonModule(); + SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(DRAFT_2020_12, PLAIN_JSON) + .with(jacksonModule); + SchemaGeneratorConfig config = configBuilder.build(); + SchemaGenerator generator = new SchemaGenerator(config); + JsonNode jsonNode = generator.generateSchema(this.typeRef.getType()); + ObjectWriter objectWriter = new ObjectMapper().writer(new DefaultPrettyPrinter() + .withObjectIndenter(new DefaultIndenter().withLinefeed(System.lineSeparator()))); + try { + this.jsonSchema = objectWriter.writeValueAsString(jsonNode); + } + catch (JsonProcessingException e) { + throw new RuntimeException("Could not pretty print json schema for " + this.typeRef, e); + } + } + + @Override + /** + * Parses the given text to transform it to the desired target type. + * @param text The LLM output in string format. + * @return The parsed output in the desired target type. + */ + public T convert(@NonNull String text) { + try { + return (T) this.objectMapper.readValue(text, this.typeRef); + } + catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + /** + * Configures and returns an object mapper for JSON operations. + * @return Configured object mapper. + */ + protected ObjectMapper getObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + return mapper; + } + + /** + * Provides the expected format of the response, instructing that it should adhere to + * the generated JSON schema. + * @return The instruction format string. + */ + @Override + public String getFormat() { + String template = """ + Your response should be in JSON format. + Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation. + Do not include markdown code blocks in your response. + Remove the ```json markdown from the output. + Here is the JSON Schema instance your output must adhere to: + ```%s``` + """; + return String.format(template, this.jsonSchema); + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/converter/TypeReferenceOutputConverterTest.java b/spring-ai-core/src/test/java/org/springframework/ai/converter/TypeReferenceOutputConverterTest.java new file mode 100644 index 00000000000..abddd15673b --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/converter/TypeReferenceOutputConverterTest.java @@ -0,0 +1,190 @@ +/* + * Copyright 2023 - 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.converter; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.core.ParameterizedTypeReference; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Sebastian Ullrich + * @author Kirk Lund + * @author Christian Tzolov + */ +@ExtendWith(MockitoExtension.class) +class TypeReferenceOutputConverterTest { + + @Mock + private ObjectMapper objectMapperMock; + + @Test + public void shouldHavePreConfiguredDefaultObjectMapper() { + var converter = new ParameterizedTypeReferenceOutputConverter<>(new ParameterizedTypeReference() { + }); + var objectMapper = converter.getObjectMapper(); + assertThat(objectMapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse(); + } + + @Nested + class ParserTest { + + @Test + public void shouldParseFieldNamesFromString() { + var converter = new ParameterizedTypeReferenceOutputConverter<>( + new ParameterizedTypeReference() { + }); + var testClass = converter.convert("{ \"someString\": \"some value\" }"); + assertThat(testClass.getSomeString()).isEqualTo("some value"); + } + + @Test + public void shouldParseFieldNamesFromArrayString() { + var converter = new ParameterizedTypeReferenceOutputConverter<>( + new ParameterizedTypeReference>() { + }); + List testClass = converter.convert("[{ \"someString\": \"some value\" }]"); + assertThat(testClass).hasSize(1); + assertThat(testClass.get(0).getSomeString()).isEqualTo("some value"); + } + + @Test + public void shouldParseJsonPropertiesFromString() { + var converter = new ParameterizedTypeReferenceOutputConverter<>( + new ParameterizedTypeReference() { + }); + var testClass = converter.convert("{ \"string_property\": \"some value\" }"); + assertThat(testClass.getSomeString()).isEqualTo("some value"); + } + + @Test + public void shouldParseJsonPropertiesFromArrayString() { + var converter = new ParameterizedTypeReferenceOutputConverter<>( + new ParameterizedTypeReference>() { + }); + List testClass = converter + .convert("[{ \"string_property\": \"some value\" }]"); + assertThat(testClass).hasSize(1); + assertThat(testClass.get(0).getSomeString()).isEqualTo("some value"); + } + + } + + @Nested + class FormatTest { + + @Test + public void shouldReturnFormatContainingResponseInstructionsAndJsonSchema() { + var converter = new ParameterizedTypeReferenceOutputConverter<>( + new ParameterizedTypeReference() { + }); + assertThat(converter.getFormat()).isEqualTo( + """ + Your response should be in JSON format. + Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation. + Do not include markdown code blocks in your response. + Remove the ```json markdown from the output. + Here is the JSON Schema instance your output must adhere to: + ```{ + "$schema" : "https://json-schema.org/draft/2020-12/schema", + "type" : "object", + "properties" : { + "someString" : { + "type" : "string" + } + } + }``` + """); + } + + @Test + public void shouldReturnFormatContainingJsonSchemaIncludingPropertyAndPropertyDescription() { + var converter = new ParameterizedTypeReferenceOutputConverter<>( + new ParameterizedTypeReference() { + }); + assertThat(converter.getFormat()).contains(""" + ```{ + "$schema" : "https://json-schema.org/draft/2020-12/schema", + "type" : "object", + "properties" : { + "string_property" : { + "type" : "string", + "description" : "string_property_description" + } + } + }``` + """); + } + + @Test + void normalizesLineEndings() { + var converter = new ParameterizedTypeReferenceOutputConverter<>( + new ParameterizedTypeReference() { + }); + + String formatOutput = converter.getFormat(); + + // validate that output contains \n line endings + assertThat(formatOutput).contains(System.lineSeparator()).doesNotContain("\r\n").doesNotContain("\r"); + } + + } + + public static class TestClass { + + private String someString; + + @SuppressWarnings("unused") + public TestClass() { + } + + public TestClass(String someString) { + this.someString = someString; + } + + public String getSomeString() { + return someString; + } + + } + + public static class TestClassWithJsonAnnotations { + + @JsonProperty("string_property") + @JsonPropertyDescription("string_property_description") + private String someString; + + public TestClassWithJsonAnnotations() { + } + + public String getSomeString() { + return someString; + } + + } + +} \ No newline at end of file From 8c758617f3974799469406a0948396820a5ebead Mon Sep 17 00:00:00 2001 From: Josh Long Date: Mon, 6 May 2024 12:34:18 +0200 Subject: [PATCH 24/39] moving to ApplicationListener to avoid slowing down the application on startup. --- pom.xml | 4 +- .../README.md | 0 .../pom.xml | 0 ...zureAiSearchFilterExpressionConverter.java | 0 .../vectorstore/azure/AzureVectorStore.java | 126 ++++++++---------- ...iSearchFilterExpressionConverterTests.java | 0 .../vectorstore/azure/AzureVectorStoreIT.java | 0 .../CassandraFilterExpressionConverter.java | 17 ++- .../ai/vectorstore/CassandraVectorStore.java | 26 +--- .../CassandraVectorStoreConfig.java | 24 ++-- .../README.md | 0 .../pom.xml | 0 .../springframework/ai/chroma/ChromaApi.java | 0 .../ai/vectorstore/ChromaVectorStore.java | 18 ++- .../ai/vectorstore/JsonUtils.java | 0 .../ai/chroma/ChromaApiIT.java | 0 .../vectorstore/BasicAuthChromaWhereIT.java | 0 .../ai/vectorstore/ChromaVectorStoreIT.java | 0 .../TokenSecuredChromaWhereIT.java | 0 .../src/test/resources/api.yaml | 0 .../src/test/resources/server.htpasswd | 0 .../vectorstore/ElasticsearchVectorStore.java | 9 +- .../ai/vectorstore/MilvusVectorStore.java | 15 ++- .../vectorstore/MongoDBAtlasVectorStore.java | 26 ++-- .../ai/vectorstore/Neo4jVectorStore.java | 57 ++++---- .../ai/vectorstore/PgVectorStore.java | 65 +++++---- .../vectorstore/qdrant/QdrantVectorStore.java | 62 +++++---- .../ai/vectorstore/RedisVectorStore.java | 67 ++++------ 28 files changed, 246 insertions(+), 270 deletions(-) rename vector-stores/{spring-ai-azure => spring-ai-azure-vector-store}/README.md (100%) rename vector-stores/{spring-ai-azure => spring-ai-azure-vector-store}/pom.xml (100%) rename vector-stores/{spring-ai-azure => spring-ai-azure-vector-store}/src/main/java/org/springframework/ai/vectorstore/azure/AzureAiSearchFilterExpressionConverter.java (100%) rename vector-stores/{spring-ai-azure => spring-ai-azure-vector-store}/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java (84%) rename vector-stores/{spring-ai-azure => spring-ai-azure-vector-store}/src/test/java/org/springframework/ai/vectorstore/azure/AzureAiSearchFilterExpressionConverterTests.java (100%) rename vector-stores/{spring-ai-azure => spring-ai-azure-vector-store}/src/test/java/org/springframework/ai/vectorstore/azure/AzureVectorStoreIT.java (100%) rename vector-stores/{spring-ai-chroma => spring-ai-chroma-store}/README.md (100%) rename vector-stores/{spring-ai-chroma => spring-ai-chroma-store}/pom.xml (100%) rename vector-stores/{spring-ai-chroma => spring-ai-chroma-store}/src/main/java/org/springframework/ai/chroma/ChromaApi.java (100%) rename vector-stores/{spring-ai-chroma => spring-ai-chroma-store}/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java (94%) rename vector-stores/{spring-ai-chroma => spring-ai-chroma-store}/src/main/java/org/springframework/ai/vectorstore/JsonUtils.java (100%) rename vector-stores/{spring-ai-chroma => spring-ai-chroma-store}/src/test/java/org/springframework/ai/chroma/ChromaApiIT.java (100%) rename vector-stores/{spring-ai-chroma => spring-ai-chroma-store}/src/test/java/org/springframework/ai/vectorstore/BasicAuthChromaWhereIT.java (100%) rename vector-stores/{spring-ai-chroma => spring-ai-chroma-store}/src/test/java/org/springframework/ai/vectorstore/ChromaVectorStoreIT.java (100%) rename vector-stores/{spring-ai-chroma => spring-ai-chroma-store}/src/test/java/org/springframework/ai/vectorstore/TokenSecuredChromaWhereIT.java (100%) rename vector-stores/{spring-ai-chroma => spring-ai-chroma-store}/src/test/resources/api.yaml (100%) rename vector-stores/{spring-ai-chroma => spring-ai-chroma-store}/src/test/resources/server.htpasswd (100%) diff --git a/pom.xml b/pom.xml index 32af3af00e1..771b4e5ad6a 100644 --- a/pom.xml +++ b/pom.xml @@ -56,8 +56,8 @@ document-readers/pdf-reader document-readers/tika-reader vector-stores/spring-ai-pinecone - vector-stores/spring-ai-chroma - vector-stores/spring-ai-azure + vector-stores/spring-ai-chroma-store + vector-stores/spring-ai-azure-vector-store vector-stores/spring-ai-weaviate vector-stores/spring-ai-redis vector-stores/spring-ai-gemfire diff --git a/vector-stores/spring-ai-azure/README.md b/vector-stores/spring-ai-azure-vector-store/README.md similarity index 100% rename from vector-stores/spring-ai-azure/README.md rename to vector-stores/spring-ai-azure-vector-store/README.md diff --git a/vector-stores/spring-ai-azure/pom.xml b/vector-stores/spring-ai-azure-vector-store/pom.xml similarity index 100% rename from vector-stores/spring-ai-azure/pom.xml rename to vector-stores/spring-ai-azure-vector-store/pom.xml diff --git a/vector-stores/spring-ai-azure/src/main/java/org/springframework/ai/vectorstore/azure/AzureAiSearchFilterExpressionConverter.java b/vector-stores/spring-ai-azure-vector-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureAiSearchFilterExpressionConverter.java similarity index 100% rename from vector-stores/spring-ai-azure/src/main/java/org/springframework/ai/vectorstore/azure/AzureAiSearchFilterExpressionConverter.java rename to vector-stores/spring-ai-azure-vector-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureAiSearchFilterExpressionConverter.java diff --git a/vector-stores/spring-ai-azure/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java b/vector-stores/spring-ai-azure-vector-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java similarity index 84% rename from vector-stores/spring-ai-azure/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java rename to vector-stores/spring-ai-azure-vector-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java index 165b190b06d..071a48fa547 100644 --- a/vector-stores/spring-ai-azure/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java +++ b/vector-stores/spring-ai-azure-vector-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java @@ -15,45 +15,30 @@ */ package org.springframework.ai.vectorstore.azure; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.TypeReference; import com.azure.core.util.Context; import com.azure.search.documents.SearchClient; import com.azure.search.documents.SearchDocument; import com.azure.search.documents.indexes.SearchIndexClient; -import com.azure.search.documents.indexes.models.HnswAlgorithmConfiguration; -import com.azure.search.documents.indexes.models.HnswParameters; -import com.azure.search.documents.indexes.models.SearchField; -import com.azure.search.documents.indexes.models.SearchFieldDataType; -import com.azure.search.documents.indexes.models.SearchIndex; -import com.azure.search.documents.indexes.models.VectorSearch; -import com.azure.search.documents.indexes.models.VectorSearchAlgorithmMetric; -import com.azure.search.documents.indexes.models.VectorSearchProfile; -import com.azure.search.documents.models.IndexDocumentsResult; -import com.azure.search.documents.models.IndexingResult; -import com.azure.search.documents.models.SearchOptions; -import com.azure.search.documents.models.VectorSearchOptions; -import com.azure.search.documents.models.VectorizedQuery; +import com.azure.search.documents.indexes.models.*; +import com.azure.search.documents.models.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.VectorStore; import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; -import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; +import java.util.*; +import java.util.stream.Collectors; + /** * Uses Azure Cognitive Search as a backing vector store. Documents can be preloaded into * a Cognitive Search index and managed via Azure tools or added and managed through this @@ -63,8 +48,9 @@ * @author Greg Meyer * @author Xiangyang Yu * @author Christian Tzolov + * @author Josh Long */ -public class AzureVectorStore implements VectorStore, InitializingBean { +public class AzureVectorStore implements VectorStore, ApplicationListener { private static final Logger logger = LoggerFactory.getLogger(AzureVectorStore.class); @@ -115,6 +101,53 @@ public class AzureVectorStore implements VectorStore, InitializingBean { */ private final List filterMetadataFields; + @Override + public void onApplicationEvent(ApplicationReadyEvent event) { + + int dimensions = this.embeddingClient.dimensions(); + + List fields = new ArrayList<>(); + + fields.add(new SearchField(ID_FIELD_NAME, SearchFieldDataType.STRING).setKey(true) + .setFilterable(true) + .setSortable(true)); + fields.add(new SearchField(EMBEDDING_FIELD_NAME, SearchFieldDataType.collection(SearchFieldDataType.SINGLE)) + .setSearchable(true) + .setVectorSearchDimensions(dimensions) + // This must match a vector search configuration name. + .setVectorSearchProfileName(SPRING_AI_VECTOR_PROFILE)); + fields.add(new SearchField(CONTENT_FIELD_NAME, SearchFieldDataType.STRING).setSearchable(true) + .setFilterable(true)); + fields.add(new SearchField(METADATA_FIELD_NAME, SearchFieldDataType.STRING).setSearchable(true) + .setFilterable(true)); + + for (MetadataField filterableMetadataField : this.filterMetadataFields) { + fields.add(new SearchField(METADATA_FIELD_PREFIX + filterableMetadataField.name(), + filterableMetadataField.fieldType()) + .setSearchable(false) + .setFacetable(true)); + } + + SearchIndex searchIndex = new SearchIndex(this.indexName).setFields(fields) + // VectorSearch configuration is required for a vector field. The name used + // for the vector search algorithm configuration must match the configuration + // used by the search field used for vector search. + .setVectorSearch(new VectorSearch() + .setProfiles(Collections + .singletonList(new VectorSearchProfile(SPRING_AI_VECTOR_PROFILE, SPRING_AI_VECTOR_CONFIG))) + .setAlgorithms(Collections.singletonList(new HnswAlgorithmConfiguration(SPRING_AI_VECTOR_CONFIG) + .setParameters(new HnswParameters().setM(4) + .setEfConstruction(400) + .setEfSearch(1000) + .setMetric(VectorSearchAlgorithmMetric.COSINE))))); + + SearchIndex index = this.searchIndexClient.createOrUpdateIndex(searchIndex); + + logger.info("Created search index: " + index.getName()); + + this.searchClient = this.searchIndexClient.getSearchClient(this.indexName); + } + public record MetadataField(String name, SearchFieldDataType fieldType) { public static MetadataField text(String name) { @@ -325,51 +358,4 @@ private List toFloatList(List doubleList) { private record AzureSearchDocument(String id, String content, List embedding, String metadata) { } - @Override - public void afterPropertiesSet() throws Exception { - - int dimensions = this.embeddingClient.dimensions(); - - List fields = new ArrayList<>(); - - fields.add(new SearchField(ID_FIELD_NAME, SearchFieldDataType.STRING).setKey(true) - .setFilterable(true) - .setSortable(true)); - fields.add(new SearchField(EMBEDDING_FIELD_NAME, SearchFieldDataType.collection(SearchFieldDataType.SINGLE)) - .setSearchable(true) - .setVectorSearchDimensions(dimensions) - // This must match a vector search configuration name. - .setVectorSearchProfileName(SPRING_AI_VECTOR_PROFILE)); - fields.add(new SearchField(CONTENT_FIELD_NAME, SearchFieldDataType.STRING).setSearchable(true) - .setFilterable(true)); - fields.add(new SearchField(METADATA_FIELD_NAME, SearchFieldDataType.STRING).setSearchable(true) - .setFilterable(true)); - - for (MetadataField filterableMetadataField : this.filterMetadataFields) { - fields.add(new SearchField(METADATA_FIELD_PREFIX + filterableMetadataField.name(), - filterableMetadataField.fieldType()) - .setSearchable(false) - .setFacetable(true)); - } - - SearchIndex searchIndex = new SearchIndex(this.indexName).setFields(fields) - // VectorSearch configuration is required for a vector field. The name used - // for the vector search algorithm configuration must match the configuration - // used by the search field used for vector search. - .setVectorSearch(new VectorSearch() - .setProfiles(Collections - .singletonList(new VectorSearchProfile(SPRING_AI_VECTOR_PROFILE, SPRING_AI_VECTOR_CONFIG))) - .setAlgorithms(Collections.singletonList(new HnswAlgorithmConfiguration(SPRING_AI_VECTOR_CONFIG) - .setParameters(new HnswParameters().setM(4) - .setEfConstruction(400) - .setEfSearch(1000) - .setMetric(VectorSearchAlgorithmMetric.COSINE))))); - - SearchIndex index = this.searchIndexClient.createOrUpdateIndex(searchIndex); - - logger.info("Created search index: " + index.getName()); - - this.searchClient = this.searchIndexClient.getSearchClient(this.indexName); - } - } diff --git a/vector-stores/spring-ai-azure/src/test/java/org/springframework/ai/vectorstore/azure/AzureAiSearchFilterExpressionConverterTests.java b/vector-stores/spring-ai-azure-vector-store/src/test/java/org/springframework/ai/vectorstore/azure/AzureAiSearchFilterExpressionConverterTests.java similarity index 100% rename from vector-stores/spring-ai-azure/src/test/java/org/springframework/ai/vectorstore/azure/AzureAiSearchFilterExpressionConverterTests.java rename to vector-stores/spring-ai-azure-vector-store/src/test/java/org/springframework/ai/vectorstore/azure/AzureAiSearchFilterExpressionConverterTests.java diff --git a/vector-stores/spring-ai-azure/src/test/java/org/springframework/ai/vectorstore/azure/AzureVectorStoreIT.java b/vector-stores/spring-ai-azure-vector-store/src/test/java/org/springframework/ai/vectorstore/azure/AzureVectorStoreIT.java similarity index 100% rename from vector-stores/spring-ai-azure/src/test/java/org/springframework/ai/vectorstore/azure/AzureVectorStoreIT.java rename to vector-stores/spring-ai-azure-vector-store/src/test/java/org/springframework/ai/vectorstore/azure/AzureVectorStoreIT.java diff --git a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverter.java b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverter.java index 3efd440341b..15028f7250a 100644 --- a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverter.java +++ b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverter.java @@ -15,30 +15,29 @@ */ package org.springframework.ai.vectorstore; -import java.util.Collection; -import java.util.Map; -import java.util.Optional; -import java.util.function.Function; -import java.util.stream.Collectors; - import com.datastax.oss.driver.api.core.metadata.schema.ColumnMetadata; import com.datastax.oss.driver.api.core.type.DataTypes; import com.datastax.oss.driver.api.core.type.codec.registry.CodecRegistry; import com.datastax.oss.driver.shaded.guava.common.base.Preconditions; - import org.springframework.ai.vectorstore.filter.Filter; import org.springframework.ai.vectorstore.filter.Filter.ExpressionType; import org.springframework.ai.vectorstore.filter.Filter.Key; import org.springframework.ai.vectorstore.filter.Filter.Value; import org.springframework.ai.vectorstore.filter.converter.AbstractFilterExpressionConverter; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + /** - * Converts {@link Expression} into CQL where clauses. + * Converts {@link org.springframework.ai.vectorstore.filter.Filter.Expression} into CQL where clauses. * * @author Mick Semb Wever * @since 1.0.0 */ -final class CassandraFilterExpressionConverter extends AbstractFilterExpressionConverter { +class CassandraFilterExpressionConverter extends AbstractFilterExpressionConverter { private final Map columnsByName; diff --git a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java index a9532d850a7..c06cfdfc566 100644 --- a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java +++ b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java @@ -15,22 +15,7 @@ */ package org.springframework.ai.vectorstore; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; - -import com.datastax.oss.driver.api.core.cql.BoundStatement; -import com.datastax.oss.driver.api.core.cql.BoundStatementBuilder; -import com.datastax.oss.driver.api.core.cql.PreparedStatement; -import com.datastax.oss.driver.api.core.cql.Row; -import com.datastax.oss.driver.api.core.cql.SimpleStatement; +import com.datastax.oss.driver.api.core.cql.*; import com.datastax.oss.driver.api.core.data.CqlVector; import com.datastax.oss.driver.api.core.metadata.schema.TableMetadata; import com.datastax.oss.driver.api.querybuilder.QueryBuilder; @@ -41,14 +26,17 @@ import com.datastax.oss.driver.shaded.guava.common.base.Preconditions; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingClient; -import org.springframework.ai.vectorstore.CassandraVectorStoreConfig; import org.springframework.ai.vectorstore.CassandraVectorStoreConfig.SchemaColumn; import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; import org.springframework.beans.factory.InitializingBean; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + /** * The CassandraVectorStore is for managing and querying vector data in an Apache * Cassandra db. It offers functionalities like adding, deleting, and performing @@ -91,7 +79,7 @@ * @see EmbeddingClient * @since 1.0.0 */ -public final class CassandraVectorStore implements VectorStore, InitializingBean, AutoCloseable { +public class CassandraVectorStore implements VectorStore, InitializingBean, AutoCloseable { /** * Indexes are automatically created with COSINE. This can be changed manually via diff --git a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java index 288894648bf..aebe211c8af 100644 --- a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java +++ b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java @@ -15,18 +15,6 @@ */ package org.springframework.ai.vectorstore; -import java.net.InetSocketAddress; -import java.time.Duration; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.function.Function; -import java.util.stream.Stream; - import com.datastax.oss.driver.api.core.CqlSession; import com.datastax.oss.driver.api.core.CqlSessionBuilder; import com.datastax.oss.driver.api.core.cql.SimpleStatement; @@ -44,12 +32,18 @@ import com.datastax.oss.driver.api.querybuilder.schema.CreateTableStart; import com.datastax.oss.driver.shaded.guava.common.annotations.VisibleForTesting; import com.datastax.oss.driver.shaded.guava.common.base.Preconditions; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import org.springframework.lang.Nullable; +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.function.Function; +import java.util.stream.Stream; + /** * Configuration for the Cassandra vector store. * @@ -66,7 +60,7 @@ * @author Mick Semb Wever * @since 1.0.0 */ -public final class CassandraVectorStoreConfig implements AutoCloseable { +public class CassandraVectorStoreConfig implements AutoCloseable { public static final String DEFAULT_KEYSPACE_NAME = "springframework"; diff --git a/vector-stores/spring-ai-chroma/README.md b/vector-stores/spring-ai-chroma-store/README.md similarity index 100% rename from vector-stores/spring-ai-chroma/README.md rename to vector-stores/spring-ai-chroma-store/README.md diff --git a/vector-stores/spring-ai-chroma/pom.xml b/vector-stores/spring-ai-chroma-store/pom.xml similarity index 100% rename from vector-stores/spring-ai-chroma/pom.xml rename to vector-stores/spring-ai-chroma-store/pom.xml diff --git a/vector-stores/spring-ai-chroma/src/main/java/org/springframework/ai/chroma/ChromaApi.java b/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/chroma/ChromaApi.java similarity index 100% rename from vector-stores/spring-ai-chroma/src/main/java/org/springframework/ai/chroma/ChromaApi.java rename to vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/chroma/ChromaApi.java diff --git a/vector-stores/spring-ai-chroma/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java b/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java similarity index 94% rename from vector-stores/spring-ai-chroma/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java rename to vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java index 9f89e7a00d3..411398a6d62 100644 --- a/vector-stores/spring-ai-chroma/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java +++ b/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java @@ -15,12 +15,6 @@ */ package org.springframework.ai.vectorstore; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - import org.springframework.ai.chroma.ChromaApi; import org.springframework.ai.chroma.ChromaApi.AddEmbeddingsRequest; import org.springframework.ai.chroma.ChromaApi.DeleteEmbeddingsRequest; @@ -29,19 +23,24 @@ import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; import org.springframework.ai.vectorstore.filter.converter.ChromaFilterExpressionConverter; -import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; +import java.util.*; + /** * {@link ChromaVectorStore} is a concrete implementation of the {@link VectorStore} * interface. It is responsible for adding, deleting, and searching documents based on * their similarity to a query, using the {@link ChromaApi} and {@link EmbeddingClient} * for embedding calculations. For more information about how it does this, see the * official Chroma website. + * + * @author Josh Long */ -public class ChromaVectorStore implements VectorStore, InitializingBean { +public class ChromaVectorStore implements VectorStore, ApplicationListener { public static final String DISTANCE_FIELD_NAME = "distance"; @@ -147,12 +146,11 @@ public List similaritySearch(SearchRequest request) { } @Override - public void afterPropertiesSet() throws Exception { + public void onApplicationEvent(ApplicationReadyEvent event) { var collection = this.chromaApi.getCollection(this.collectionName); if (collection == null) { collection = this.chromaApi.createCollection(new ChromaApi.CreateCollectionRequest(this.collectionName)); } this.collectionId = collection.id(); } - } diff --git a/vector-stores/spring-ai-chroma/src/main/java/org/springframework/ai/vectorstore/JsonUtils.java b/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/JsonUtils.java similarity index 100% rename from vector-stores/spring-ai-chroma/src/main/java/org/springframework/ai/vectorstore/JsonUtils.java rename to vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/JsonUtils.java diff --git a/vector-stores/spring-ai-chroma/src/test/java/org/springframework/ai/chroma/ChromaApiIT.java b/vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/chroma/ChromaApiIT.java similarity index 100% rename from vector-stores/spring-ai-chroma/src/test/java/org/springframework/ai/chroma/ChromaApiIT.java rename to vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/chroma/ChromaApiIT.java diff --git a/vector-stores/spring-ai-chroma/src/test/java/org/springframework/ai/vectorstore/BasicAuthChromaWhereIT.java b/vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/vectorstore/BasicAuthChromaWhereIT.java similarity index 100% rename from vector-stores/spring-ai-chroma/src/test/java/org/springframework/ai/vectorstore/BasicAuthChromaWhereIT.java rename to vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/vectorstore/BasicAuthChromaWhereIT.java diff --git a/vector-stores/spring-ai-chroma/src/test/java/org/springframework/ai/vectorstore/ChromaVectorStoreIT.java b/vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/vectorstore/ChromaVectorStoreIT.java similarity index 100% rename from vector-stores/spring-ai-chroma/src/test/java/org/springframework/ai/vectorstore/ChromaVectorStoreIT.java rename to vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/vectorstore/ChromaVectorStoreIT.java diff --git a/vector-stores/spring-ai-chroma/src/test/java/org/springframework/ai/vectorstore/TokenSecuredChromaWhereIT.java b/vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/vectorstore/TokenSecuredChromaWhereIT.java similarity index 100% rename from vector-stores/spring-ai-chroma/src/test/java/org/springframework/ai/vectorstore/TokenSecuredChromaWhereIT.java rename to vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/vectorstore/TokenSecuredChromaWhereIT.java diff --git a/vector-stores/spring-ai-chroma/src/test/resources/api.yaml b/vector-stores/spring-ai-chroma-store/src/test/resources/api.yaml similarity index 100% rename from vector-stores/spring-ai-chroma/src/test/resources/api.yaml rename to vector-stores/spring-ai-chroma-store/src/test/resources/api.yaml diff --git a/vector-stores/spring-ai-chroma/src/test/resources/server.htpasswd b/vector-stores/spring-ai-chroma-store/src/test/resources/server.htpasswd similarity index 100% rename from vector-stores/spring-ai-chroma/src/test/resources/server.htpasswd rename to vector-stores/spring-ai-chroma-store/src/test/resources/server.htpasswd diff --git a/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/ElasticsearchVectorStore.java b/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/ElasticsearchVectorStore.java index 72a29472f69..8dcb1b5bc53 100644 --- a/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/ElasticsearchVectorStore.java +++ b/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/ElasticsearchVectorStore.java @@ -37,7 +37,8 @@ import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.vectorstore.filter.Filter; import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; -import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; import org.springframework.util.Assert; import java.io.IOException; @@ -49,9 +50,10 @@ /** * @author Jemin Huh * @author Wei Jiang + * @author Josh Long * @since 1.0.0 */ -public class ElasticsearchVectorStore implements VectorStore, InitializingBean { +public class ElasticsearchVectorStore implements VectorStore, ApplicationListener { // divided by 2 to get score in the range [0, 1] public static final String COSINE_SIMILARITY_FUNCTION = "(cosineSimilarity(params.query_vector, 'embedding') + 1.0) / 2"; @@ -219,10 +221,9 @@ private CreateIndexResponse createIndexMapping() { } @Override - public void afterPropertiesSet() { + public void onApplicationEvent(ApplicationReadyEvent event) { if (!indexExists()) { createIndexMapping(); } } - } diff --git a/vector-stores/spring-ai-milvus-store/src/main/java/org/springframework/ai/vectorstore/MilvusVectorStore.java b/vector-stores/spring-ai-milvus-store/src/main/java/org/springframework/ai/vectorstore/MilvusVectorStore.java index 0a6004a1c0d..76a54fddb65 100644 --- a/vector-stores/spring-ai-milvus-store/src/main/java/org/springframework/ai/vectorstore/MilvusVectorStore.java +++ b/vector-stores/spring-ai-milvus-store/src/main/java/org/springframework/ai/vectorstore/MilvusVectorStore.java @@ -55,14 +55,16 @@ import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; import org.springframework.ai.vectorstore.filter.converter.MilvusFilterExpressionConverter; -import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; import org.springframework.util.Assert; import org.springframework.util.StringUtils; /** * @author Christian Tzolov + * @author Josh Long */ -public class MilvusVectorStore implements VectorStore, InitializingBean { +public class MilvusVectorStore implements VectorStore, ApplicationListener { private static final Logger logger = LoggerFactory.getLogger(MilvusVectorStore.class); @@ -96,6 +98,11 @@ public class MilvusVectorStore implements VectorStore, InitializingBean { private final MilvusVectorStoreConfig config; + @Override + public void onApplicationEvent(ApplicationReadyEvent event) { + this.createCollection(); + } + /** * Configuration for the Milvus vector store. */ @@ -378,10 +385,6 @@ private List toFloatList(List embeddingDouble) { // --------------------------------------------------------------------------------- // Initialization // --------------------------------------------------------------------------------- - @Override - public void afterPropertiesSet() throws Exception { - this.createCollection(); - } void releaseCollection() { if (isDatabaseCollectionExists()) { diff --git a/vector-stores/spring-ai-mongodb-atlas-store/src/main/java/org/springframework/ai/vectorstore/MongoDBAtlasVectorStore.java b/vector-stores/spring-ai-mongodb-atlas-store/src/main/java/org/springframework/ai/vectorstore/MongoDBAtlasVectorStore.java index 0fbb099ce1d..231c196b729 100644 --- a/vector-stores/spring-ai-mongodb-atlas-store/src/main/java/org/springframework/ai/vectorstore/MongoDBAtlasVectorStore.java +++ b/vector-stores/spring-ai-mongodb-atlas-store/src/main/java/org/springframework/ai/vectorstore/MongoDBAtlasVectorStore.java @@ -25,7 +25,8 @@ import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingClient; -import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.aggregation.Aggregation; import org.springframework.data.mongodb.core.query.Criteria; @@ -36,9 +37,10 @@ /** * @author Chris Smith + * @author Josh Long * @since 1.0.0 */ -public class MongoDBAtlasVectorStore implements VectorStore, InitializingBean { +public class MongoDBAtlasVectorStore implements VectorStore, ApplicationListener { public static final String ID_FIELD_NAME = "_id"; @@ -76,16 +78,6 @@ public MongoDBAtlasVectorStore(MongoTemplate mongoTemplate, EmbeddingClient embe } - @Override - public void afterPropertiesSet() throws Exception { - // Create the collection if it does not exist - if (!mongoTemplate.collectionExists(this.config.collectionName)) { - mongoTemplate.createCollection(this.config.collectionName); - } - // Create search index, command doesn't do anything if already existing - mongoTemplate.executeCommand(createSearchIndex()); - } - /** * Provides the Definition for the search index */ @@ -174,6 +166,16 @@ public List similaritySearch(SearchRequest request) { .toList(); } + @Override + public void onApplicationEvent(ApplicationReadyEvent event) { + // Create the collection if it does not exist + if (!mongoTemplate.collectionExists(this.config.collectionName)) { + mongoTemplate.createCollection(this.config.collectionName); + } + // Create search index, command doesn't do anything if already existing + mongoTemplate.executeCommand(createSearchIndex()); + } + public static class MongoDBVectorStoreConfig { private final String collectionName; diff --git a/vector-stores/spring-ai-neo4j-store/src/main/java/org/springframework/ai/vectorstore/Neo4jVectorStore.java b/vector-stores/spring-ai-neo4j-store/src/main/java/org/springframework/ai/vectorstore/Neo4jVectorStore.java index 1c50ccb9080..bb1799584c0 100644 --- a/vector-stores/spring-ai-neo4j-store/src/main/java/org/springframework/ai/vectorstore/Neo4jVectorStore.java +++ b/vector-stores/spring-ai-neo4j-store/src/main/java/org/springframework/ai/vectorstore/Neo4jVectorStore.java @@ -22,7 +22,8 @@ import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.vectorstore.filter.Neo4jVectorFilterExpressionConverter; -import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; import org.springframework.util.Assert; import java.util.HashMap; @@ -34,8 +35,9 @@ /** * @author Gerrit Meier * @author Michael Simons + * @author Josh Long */ -public class Neo4jVectorStore implements VectorStore, InitializingBean { +public class Neo4jVectorStore implements VectorStore, ApplicationListener { /** * An enum to configure the distance function used in the Neo4j vector index. @@ -50,8 +52,8 @@ public enum Neo4jDistanceType { this.name = name; } - } + } /** * Configuration for the Neo4j vector store. */ @@ -70,8 +72,8 @@ public static final class Neo4jVectorStoreConfig { private final String indexName; // needed for similarity search call - private final String indexNameNotSanitized; + private final String indexNameNotSanitized; private final String idProperty; private final String constraintName; @@ -249,10 +251,34 @@ public Neo4jVectorStoreConfig build() { return new Neo4jVectorStoreConfig(this); } + } } + @Override + public void onApplicationEvent(ApplicationReadyEvent event) { + + try (var session = this.driver.session(this.config.sessionConfig)) { + + session + .run("CREATE CONSTRAINT %s IF NOT EXISTS FOR (n:%s) REQUIRE n.%s IS UNIQUE" + .formatted(this.config.constraintName, this.config.label, this.config.idProperty)) + .consume(); + + var statement = """ + CREATE VECTOR INDEX %s IF NOT EXISTS FOR (n:%s) ON (n.%s) + OPTIONS {indexConfig: { + `vector.dimensions`: %d, + `vector.similarity_function`: '%s' + }} + """.formatted(this.config.indexName, this.config.label, this.config.embeddingProperty, + this.config.embeddingDimension, this.config.distanceType.name); + session.run(statement).consume(); + session.run("CALL db.awaitIndexes()").consume(); + } + } + public static final int DEFAULT_EMBEDDING_DIMENSION = 1536; public static final String DEFAULT_LABEL = "Document"; @@ -348,29 +374,6 @@ public List similaritySearch(SearchRequest request) { } } - @Override - public void afterPropertiesSet() { - - try (var session = this.driver.session(this.config.sessionConfig)) { - - session - .run("CREATE CONSTRAINT %s IF NOT EXISTS FOR (n:%s) REQUIRE n.%s IS UNIQUE" - .formatted(this.config.constraintName, this.config.label, this.config.idProperty)) - .consume(); - - var statement = """ - CREATE VECTOR INDEX %s IF NOT EXISTS FOR (n:%s) ON (n.%s) - OPTIONS {indexConfig: { - `vector.dimensions`: %d, - `vector.similarity_function`: '%s' - }} - """.formatted(this.config.indexName, this.config.label, this.config.embeddingProperty, - this.config.embeddingDimension, this.config.distanceType.name); - session.run(statement).consume(); - session.run("CALL db.awaitIndexes()").consume(); - } - } - private Map documentToRecord(Document document) { var embedding = this.embeddingClient.embed(document); document.setEmbedding(embedding); diff --git a/vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/PgVectorStore.java b/vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/PgVectorStore.java index c2b7954d900..eab9e102458 100644 --- a/vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/PgVectorStore.java +++ b/vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/PgVectorStore.java @@ -35,7 +35,8 @@ import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; import org.springframework.ai.vectorstore.filter.converter.PgVectorFilterExpressionConverter; -import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; import org.springframework.jdbc.core.BatchPreparedStatementSetter; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; @@ -49,8 +50,9 @@ * vector index will be auto-created if not available. * * @author Christian Tzolov + * @author Josh Long */ -public class PgVectorStore implements VectorStore, InitializingBean { +public class PgVectorStore implements VectorStore, ApplicationListener { private static final Logger logger = LoggerFactory.getLogger(PgVectorStore.class); @@ -78,6 +80,38 @@ public class PgVectorStore implements VectorStore, InitializingBean { private PgIndexType createIndexMethod; + @Override + public void onApplicationEvent(ApplicationReadyEvent event) { + try { + // Enable the PGVector, JSONB and UUID support. + this.jdbcTemplate.execute("CREATE EXTENSION IF NOT EXISTS vector"); + this.jdbcTemplate.execute("CREATE EXTENSION IF NOT EXISTS hstore"); + this.jdbcTemplate.execute("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\""); + + // Remove existing VectorStoreTable + if (this.removeExistingVectorStoreTable) { + this.jdbcTemplate.execute("DROP TABLE IF EXISTS " + VECTOR_TABLE_NAME); + } + + this.jdbcTemplate.execute(String.format(""" + CREATE TABLE IF NOT EXISTS %s ( + id uuid DEFAULT uuid_generate_v4() PRIMARY KEY, + content text, + metadata json, + embedding vector(%d) + ) + """, VECTOR_TABLE_NAME, this.embeddingDimensions())); + + if (this.createIndexMethod != PgIndexType.NONE) { + this.jdbcTemplate.execute(String.format(""" + CREATE INDEX IF NOT EXISTS %s ON %s USING %s (embedding %s) + """, VECTOR_INDEX_NAME, VECTOR_TABLE_NAME, this.createIndexMethod, this.getDistanceType().index)); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + /** * By default, pgvector performs exact nearest neighbor search, which provides perfect * recall. You can add an index to use approximate nearest neighbor search, which @@ -331,33 +365,6 @@ private String comparisonOperator() { // --------------------------------------------------------------------------------- // Initialize // --------------------------------------------------------------------------------- - @Override - public void afterPropertiesSet() throws Exception { - // Enable the PGVector, JSONB and UUID support. - this.jdbcTemplate.execute("CREATE EXTENSION IF NOT EXISTS vector"); - this.jdbcTemplate.execute("CREATE EXTENSION IF NOT EXISTS hstore"); - this.jdbcTemplate.execute("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\""); - - // Remove existing VectorStoreTable - if (this.removeExistingVectorStoreTable) { - this.jdbcTemplate.execute("DROP TABLE IF EXISTS " + VECTOR_TABLE_NAME); - } - - this.jdbcTemplate.execute(String.format(""" - CREATE TABLE IF NOT EXISTS %s ( - id uuid DEFAULT uuid_generate_v4() PRIMARY KEY, - content text, - metadata json, - embedding vector(%d) - ) - """, VECTOR_TABLE_NAME, this.embeddingDimensions())); - - if (this.createIndexMethod != PgIndexType.NONE) { - this.jdbcTemplate.execute(String.format(""" - CREATE INDEX IF NOT EXISTS %s ON %s USING %s (embedding %s) - """, VECTOR_INDEX_NAME, VECTOR_TABLE_NAME, this.createIndexMethod, this.getDistanceType().index)); - } - } int embeddingDimensions() { // The manually set dimensions have precedence over the computed one. diff --git a/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java b/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java index fcbe464fbc2..72dcf09b538 100644 --- a/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java +++ b/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java @@ -15,24 +15,12 @@ */ package org.springframework.ai.vectorstore.qdrant; -import static io.qdrant.client.PointIdFactory.id; -import static io.qdrant.client.ValueFactory.value; -import static io.qdrant.client.VectorsFactory.vectors; -import static io.qdrant.client.WithPayloadSelectorFactory.enable; - import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.concurrent.ExecutionException; -import org.springframework.ai.document.Document; -import org.springframework.ai.embedding.EmbeddingClient; -import org.springframework.ai.vectorstore.SearchRequest; -import org.springframework.ai.vectorstore.VectorStore; -import org.springframework.beans.factory.InitializingBean; -import org.springframework.util.Assert; - import io.qdrant.client.QdrantClient; import io.qdrant.client.grpc.Collections.Distance; import io.qdrant.client.grpc.Collections.VectorParams; @@ -44,6 +32,19 @@ import io.qdrant.client.grpc.Points.SearchPoints; import io.qdrant.client.grpc.Points.UpdateStatus; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.util.Assert; + +import static io.qdrant.client.PointIdFactory.id; +import static io.qdrant.client.ValueFactory.value; +import static io.qdrant.client.VectorsFactory.vectors; +import static io.qdrant.client.WithPayloadSelectorFactory.enable; + /** * Qdrant vectorStore implementation. This store supports creating, updating, deleting, * and similarity searching of documents in a Qdrant collection. @@ -51,12 +52,12 @@ * @author Anush Shetty * @author Christian Tzolov * @author Eddú Meléndez + * @author Josh Long * @since 0.8.1 */ -public class QdrantVectorStore implements VectorStore, InitializingBean { +public class QdrantVectorStore implements VectorStore, ApplicationListener { private static final String CONTENT_FIELD_NAME = "doc_content"; - private static final String DISTANCE_FIELD_NAME = "distance"; public static final String DEFAULT_COLLECTION_NAME = "vector_store"; @@ -84,10 +85,10 @@ public static final class QdrantVectorStoreConfig { * * @param builder The configuration builder. */ + private QdrantVectorStoreConfig(Builder builder) { this.collectionName = builder.collectionName; } - /** * Start building a new configuration. * @return The entry point for creating a new configuration. @@ -126,10 +127,10 @@ public QdrantVectorStoreConfig build() { return new QdrantVectorStoreConfig(this); } + } } - /** * Constructs a new QdrantVectorStore. * @param config The configuration for the store. @@ -158,6 +159,24 @@ public QdrantVectorStore(QdrantClient qdrantClient, String collectionName, Embed this.qdrantClient = qdrantClient; } + @Override + public void onApplicationEvent(ApplicationReadyEvent event) { + + // Create the collection if it does not exist. + if (!isCollectionExists()) { + var vectorParams = VectorParams.newBuilder() + .setDistance(Distance.Cosine) + .setSize(this.embeddingClient.dimensions()) + .build(); + try { + this.qdrantClient.createCollectionAsync(this.collectionName, vectorParams).get(); + } // + catch (Exception e) { + throw new RuntimeException(e); + } + } + } + /** * Adds a list of documents to the vector store. * @param documents The list of documents to be added. @@ -284,17 +303,6 @@ private List toFloatList(List doubleList) { return doubleList.stream().map(d -> d.floatValue()).toList(); } - @Override - public void afterPropertiesSet() throws Exception { - // Create the collection if it does not exist. - if (!isCollectionExists()) { - var vectorParams = VectorParams.newBuilder() - .setDistance(Distance.Cosine) - .setSize(this.embeddingClient.dimensions()) - .build(); - this.qdrantClient.createCollectionAsync(this.collectionName, vectorParams).get(); - } - } private boolean isCollectionExists() { try { diff --git a/vector-stores/spring-ai-redis/src/main/java/org/springframework/ai/vectorstore/RedisVectorStore.java b/vector-stores/spring-ai-redis/src/main/java/org/springframework/ai/vectorstore/RedisVectorStore.java index ce267ebeb69..744411622dc 100644 --- a/vector-stores/spring-ai-redis/src/main/java/org/springframework/ai/vectorstore/RedisVectorStore.java +++ b/vector-stores/spring-ai-redis/src/main/java/org/springframework/ai/vectorstore/RedisVectorStore.java @@ -15,42 +15,29 @@ */ package org.springframework.ai.vectorstore; -import java.text.MessageFormat; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.function.Function; -import java.util.function.Predicate; -import java.util.stream.Collectors; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; -import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; - import redis.clients.jedis.JedisPooled; import redis.clients.jedis.Pipeline; import redis.clients.jedis.json.Path2; -import redis.clients.jedis.search.FTCreateParams; -import redis.clients.jedis.search.IndexDataType; -import redis.clients.jedis.search.Query; -import redis.clients.jedis.search.RediSearchUtil; +import redis.clients.jedis.search.*; import redis.clients.jedis.search.Schema.FieldType; -import redis.clients.jedis.search.SearchResult; -import redis.clients.jedis.search.schemafields.NumericField; -import redis.clients.jedis.search.schemafields.SchemaField; -import redis.clients.jedis.search.schemafields.TagField; -import redis.clients.jedis.search.schemafields.TextField; -import redis.clients.jedis.search.schemafields.VectorField; +import redis.clients.jedis.search.schemafields.*; import redis.clients.jedis.search.schemafields.VectorField.VectorAlgorithm; +import java.text.MessageFormat; +import java.util.*; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; + /** * The RedisVectorStore is for managing and querying vector data in a Redis database. It * offers functionalities like adding, deleting, and performing similarity searches on @@ -68,11 +55,27 @@ * * @author Julien Ruaux * @author Christian Tzolov + * @author Josh Long * @see VectorStore * @see RedisVectorStoreConfig * @see EmbeddingClient */ -public class RedisVectorStore implements VectorStore, InitializingBean { +public class RedisVectorStore implements VectorStore, ApplicationListener { + + @Override + public void onApplicationEvent(ApplicationReadyEvent event) { + // If index already exists don't do anything + if (this.jedis.ftList().contains(this.config.indexName)) { + return; + } + + String response = this.jedis.ftCreate(this.config.indexName, + FTCreateParams.createParams().on(IndexDataType.JSON).addPrefix(this.config.prefix), schemaFields()); + if (!RESPONSE_OK.test(response)) { + String message = MessageFormat.format("Could not create index: {0}", response); + throw new RuntimeException(message); + } + } public enum Algorithm { @@ -402,22 +405,6 @@ private String nativeExpressionFilter(SearchRequest request) { return "(" + this.filterExpressionConverter.convertExpression(request.getFilterExpression()) + ")"; } - @Override - public void afterPropertiesSet() { - - // If index already exists don't do anything - if (this.jedis.ftList().contains(this.config.indexName)) { - return; - } - - String response = this.jedis.ftCreate(this.config.indexName, - FTCreateParams.createParams().on(IndexDataType.JSON).addPrefix(this.config.prefix), schemaFields()); - if (!RESPONSE_OK.test(response)) { - String message = MessageFormat.format("Could not create index: {0}", response); - throw new RuntimeException(message); - } - } - private Iterable schemaFields() { Map vectorAttrs = new HashMap<>(); vectorAttrs.put("DIM", this.embeddingClient.dimensions()); From c5b7dd9c46ada0ca4843e7449eee1854da9e5485 Mon Sep 17 00:00:00 2001 From: Josh Long Date: Mon, 6 May 2024 12:36:37 +0200 Subject: [PATCH 25/39] i ran the spring javaformat maven plugin to clean up any source code incompatabilities. --- .../vectorstore/azure/AzureVectorStore.java | 42 +++++++++---------- .../CassandraFilterExpressionConverter.java | 3 +- .../ai/vectorstore/ChromaVectorStore.java | 1 + .../vectorstore/ElasticsearchVectorStore.java | 1 + .../ai/vectorstore/MilvusVectorStore.java | 2 +- .../ai/vectorstore/Neo4jVectorStore.java | 10 ++--- .../ai/vectorstore/PgVectorStore.java | 16 +++---- .../vectorstore/qdrant/QdrantVectorStore.java | 25 +++++------ 8 files changed, 53 insertions(+), 47 deletions(-) diff --git a/vector-stores/spring-ai-azure-vector-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java b/vector-stores/spring-ai-azure-vector-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java index 071a48fa547..9a5205cfb95 100644 --- a/vector-stores/spring-ai-azure-vector-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java +++ b/vector-stores/spring-ai-azure-vector-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java @@ -109,37 +109,37 @@ public void onApplicationEvent(ApplicationReadyEvent event) { List fields = new ArrayList<>(); fields.add(new SearchField(ID_FIELD_NAME, SearchFieldDataType.STRING).setKey(true) - .setFilterable(true) - .setSortable(true)); + .setFilterable(true) + .setSortable(true)); fields.add(new SearchField(EMBEDDING_FIELD_NAME, SearchFieldDataType.collection(SearchFieldDataType.SINGLE)) - .setSearchable(true) - .setVectorSearchDimensions(dimensions) - // This must match a vector search configuration name. - .setVectorSearchProfileName(SPRING_AI_VECTOR_PROFILE)); + .setSearchable(true) + .setVectorSearchDimensions(dimensions) + // This must match a vector search configuration name. + .setVectorSearchProfileName(SPRING_AI_VECTOR_PROFILE)); fields.add(new SearchField(CONTENT_FIELD_NAME, SearchFieldDataType.STRING).setSearchable(true) - .setFilterable(true)); + .setFilterable(true)); fields.add(new SearchField(METADATA_FIELD_NAME, SearchFieldDataType.STRING).setSearchable(true) - .setFilterable(true)); + .setFilterable(true)); for (MetadataField filterableMetadataField : this.filterMetadataFields) { fields.add(new SearchField(METADATA_FIELD_PREFIX + filterableMetadataField.name(), filterableMetadataField.fieldType()) - .setSearchable(false) - .setFacetable(true)); + .setSearchable(false) + .setFacetable(true)); } SearchIndex searchIndex = new SearchIndex(this.indexName).setFields(fields) - // VectorSearch configuration is required for a vector field. The name used - // for the vector search algorithm configuration must match the configuration - // used by the search field used for vector search. - .setVectorSearch(new VectorSearch() - .setProfiles(Collections - .singletonList(new VectorSearchProfile(SPRING_AI_VECTOR_PROFILE, SPRING_AI_VECTOR_CONFIG))) - .setAlgorithms(Collections.singletonList(new HnswAlgorithmConfiguration(SPRING_AI_VECTOR_CONFIG) - .setParameters(new HnswParameters().setM(4) - .setEfConstruction(400) - .setEfSearch(1000) - .setMetric(VectorSearchAlgorithmMetric.COSINE))))); + // VectorSearch configuration is required for a vector field. The name used + // for the vector search algorithm configuration must match the configuration + // used by the search field used for vector search. + .setVectorSearch(new VectorSearch() + .setProfiles(Collections + .singletonList(new VectorSearchProfile(SPRING_AI_VECTOR_PROFILE, SPRING_AI_VECTOR_CONFIG))) + .setAlgorithms(Collections.singletonList(new HnswAlgorithmConfiguration(SPRING_AI_VECTOR_CONFIG) + .setParameters(new HnswParameters().setM(4) + .setEfConstruction(400) + .setEfSearch(1000) + .setMetric(VectorSearchAlgorithmMetric.COSINE))))); SearchIndex index = this.searchIndexClient.createOrUpdateIndex(searchIndex); diff --git a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverter.java b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverter.java index 15028f7250a..f3270ac8110 100644 --- a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverter.java +++ b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverter.java @@ -32,7 +32,8 @@ import java.util.stream.Collectors; /** - * Converts {@link org.springframework.ai.vectorstore.filter.Filter.Expression} into CQL where clauses. + * Converts {@link org.springframework.ai.vectorstore.filter.Filter.Expression} into CQL + * where clauses. * * @author Mick Semb Wever * @since 1.0.0 diff --git a/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java b/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java index 411398a6d62..3c9741cbefe 100644 --- a/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java +++ b/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java @@ -153,4 +153,5 @@ public void onApplicationEvent(ApplicationReadyEvent event) { } this.collectionId = collection.id(); } + } diff --git a/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/ElasticsearchVectorStore.java b/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/ElasticsearchVectorStore.java index 8dcb1b5bc53..cff3a8efdfe 100644 --- a/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/ElasticsearchVectorStore.java +++ b/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/ElasticsearchVectorStore.java @@ -226,4 +226,5 @@ public void onApplicationEvent(ApplicationReadyEvent event) { createIndexMapping(); } } + } diff --git a/vector-stores/spring-ai-milvus-store/src/main/java/org/springframework/ai/vectorstore/MilvusVectorStore.java b/vector-stores/spring-ai-milvus-store/src/main/java/org/springframework/ai/vectorstore/MilvusVectorStore.java index 76a54fddb65..b86438b6cd6 100644 --- a/vector-stores/spring-ai-milvus-store/src/main/java/org/springframework/ai/vectorstore/MilvusVectorStore.java +++ b/vector-stores/spring-ai-milvus-store/src/main/java/org/springframework/ai/vectorstore/MilvusVectorStore.java @@ -64,7 +64,7 @@ * @author Christian Tzolov * @author Josh Long */ -public class MilvusVectorStore implements VectorStore, ApplicationListener { +public class MilvusVectorStore implements VectorStore, ApplicationListener { private static final Logger logger = LoggerFactory.getLogger(MilvusVectorStore.class); diff --git a/vector-stores/spring-ai-neo4j-store/src/main/java/org/springframework/ai/vectorstore/Neo4jVectorStore.java b/vector-stores/spring-ai-neo4j-store/src/main/java/org/springframework/ai/vectorstore/Neo4jVectorStore.java index bb1799584c0..8b5b3024f4d 100644 --- a/vector-stores/spring-ai-neo4j-store/src/main/java/org/springframework/ai/vectorstore/Neo4jVectorStore.java +++ b/vector-stores/spring-ai-neo4j-store/src/main/java/org/springframework/ai/vectorstore/Neo4jVectorStore.java @@ -52,8 +52,8 @@ public enum Neo4jDistanceType { this.name = name; } - } + /** * Configuration for the Neo4j vector store. */ @@ -74,6 +74,7 @@ public static final class Neo4jVectorStoreConfig { // needed for similarity search call private final String indexNameNotSanitized; + private final String idProperty; private final String constraintName; @@ -251,7 +252,6 @@ public Neo4jVectorStoreConfig build() { return new Neo4jVectorStoreConfig(this); } - } } @@ -262,9 +262,9 @@ public void onApplicationEvent(ApplicationReadyEvent event) { try (var session = this.driver.session(this.config.sessionConfig)) { session - .run("CREATE CONSTRAINT %s IF NOT EXISTS FOR (n:%s) REQUIRE n.%s IS UNIQUE" - .formatted(this.config.constraintName, this.config.label, this.config.idProperty)) - .consume(); + .run("CREATE CONSTRAINT %s IF NOT EXISTS FOR (n:%s) REQUIRE n.%s IS UNIQUE" + .formatted(this.config.constraintName, this.config.label, this.config.idProperty)) + .consume(); var statement = """ CREATE VECTOR INDEX %s IF NOT EXISTS FOR (n:%s) ON (n.%s) diff --git a/vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/PgVectorStore.java b/vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/PgVectorStore.java index eab9e102458..2eb9eefeef6 100644 --- a/vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/PgVectorStore.java +++ b/vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/PgVectorStore.java @@ -52,7 +52,7 @@ * @author Christian Tzolov * @author Josh Long */ -public class PgVectorStore implements VectorStore, ApplicationListener { +public class PgVectorStore implements VectorStore, ApplicationListener { private static final Logger logger = LoggerFactory.getLogger(PgVectorStore.class); @@ -82,7 +82,7 @@ public class PgVectorStore implements VectorStore, ApplicationListener { private static final String CONTENT_FIELD_NAME = "doc_content"; + private static final String DISTANCE_FIELD_NAME = "distance"; public static final String DEFAULT_COLLECTION_NAME = "vector_store"; @@ -89,6 +90,7 @@ public static final class QdrantVectorStoreConfig { private QdrantVectorStoreConfig(Builder builder) { this.collectionName = builder.collectionName; } + /** * Start building a new configuration. * @return The entry point for creating a new configuration. @@ -127,10 +129,10 @@ public QdrantVectorStoreConfig build() { return new QdrantVectorStoreConfig(this); } - } } + /** * Constructs a new QdrantVectorStore. * @param config The configuration for the store. @@ -165,16 +167,16 @@ public void onApplicationEvent(ApplicationReadyEvent event) { // Create the collection if it does not exist. if (!isCollectionExists()) { var vectorParams = VectorParams.newBuilder() - .setDistance(Distance.Cosine) - .setSize(this.embeddingClient.dimensions()) - .build(); - try { - this.qdrantClient.createCollectionAsync(this.collectionName, vectorParams).get(); - } // - catch (Exception e) { - throw new RuntimeException(e); - } - } + .setDistance(Distance.Cosine) + .setSize(this.embeddingClient.dimensions()) + .build(); + try { + this.qdrantClient.createCollectionAsync(this.collectionName, vectorParams).get(); + } // + catch (Exception e) { + throw new RuntimeException(e); + } + } } /** @@ -303,7 +305,6 @@ private List toFloatList(List doubleList) { return doubleList.stream().map(d -> d.floatValue()).toList(); } - private boolean isCollectionExists() { try { return this.qdrantClient.listCollectionsAsync().get().stream().anyMatch(c -> c.equals(this.collectionName)); From 04d854cc49237e4af80e95ef51fc9c65f77cf28f Mon Sep 17 00:00:00 2001 From: Josh Long Date: Mon, 6 May 2024 12:51:20 +0200 Subject: [PATCH 26/39] make project names consistent --- models/spring-ai-watsonx-ai/pom.xml | 2 ++ pom.xml | 4 ++-- .../README.md | 0 .../pom.xml | 6 +++--- .../azure/AzureAiSearchFilterExpressionConverter.java | 0 .../ai/vectorstore/azure/AzureVectorStore.java | 0 .../azure/AzureAiSearchFilterExpressionConverterTests.java | 0 .../ai/vectorstore/azure/AzureVectorStoreIT.java | 0 vector-stores/spring-ai-chroma-store/pom.xml | 2 +- .../springframework/ai/vectorstore/ChromaVectorStore.java | 2 +- vector-stores/spring-ai-milvus-store/pom.xml | 4 ++-- vector-stores/spring-ai-mongodb-atlas-store/pom.xml | 4 ++-- vector-stores/spring-ai-neo4j-store/pom.xml | 2 +- vector-stores/spring-ai-pgvector-store/pom.xml | 2 +- vector-stores/spring-ai-pinecone/pom.xml | 3 +-- vector-stores/spring-ai-qdrant/pom.xml | 4 ++-- .../ai/vectorstore/qdrant/QdrantVectorStore.java | 1 - vector-stores/spring-ai-redis/pom.xml | 4 ++-- .../README.md | 0 .../pom.xml | 3 +-- .../ai/vectorstore/WeaviateFilterExpressionConverter.java | 0 .../springframework/ai/vectorstore/WeaviateVectorStore.java | 0 .../vectorstore/WeaviateFilterExpressionConverterTests.java | 0 .../ai/vectorstore/WeaviateVectorStoreIT.java | 0 .../src/test/resources/docker-compose.yml | 0 25 files changed, 21 insertions(+), 22 deletions(-) rename vector-stores/{spring-ai-azure-vector-store => spring-ai-azure-store}/README.md (100%) rename vector-stores/{spring-ai-azure-vector-store => spring-ai-azure-store}/pom.xml (93%) rename vector-stores/{spring-ai-azure-vector-store => spring-ai-azure-store}/src/main/java/org/springframework/ai/vectorstore/azure/AzureAiSearchFilterExpressionConverter.java (100%) rename vector-stores/{spring-ai-azure-vector-store => spring-ai-azure-store}/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java (100%) rename vector-stores/{spring-ai-azure-vector-store => spring-ai-azure-store}/src/test/java/org/springframework/ai/vectorstore/azure/AzureAiSearchFilterExpressionConverterTests.java (100%) rename vector-stores/{spring-ai-azure-vector-store => spring-ai-azure-store}/src/test/java/org/springframework/ai/vectorstore/azure/AzureVectorStoreIT.java (100%) rename vector-stores/{spring-ai-weaviate => spring-ai-weaviate-store}/README.md (100%) rename vector-stores/{spring-ai-weaviate => spring-ai-weaviate-store}/pom.xml (96%) rename vector-stores/{spring-ai-weaviate => spring-ai-weaviate-store}/src/main/java/org/springframework/ai/vectorstore/WeaviateFilterExpressionConverter.java (100%) rename vector-stores/{spring-ai-weaviate => spring-ai-weaviate-store}/src/main/java/org/springframework/ai/vectorstore/WeaviateVectorStore.java (100%) rename vector-stores/{spring-ai-weaviate => spring-ai-weaviate-store}/src/test/java/org/springframework/ai/vectorstore/WeaviateFilterExpressionConverterTests.java (100%) rename vector-stores/{spring-ai-weaviate => spring-ai-weaviate-store}/src/test/java/org/springframework/ai/vectorstore/WeaviateVectorStoreIT.java (100%) rename vector-stores/{spring-ai-weaviate => spring-ai-weaviate-store}/src/test/resources/docker-compose.yml (100%) diff --git a/models/spring-ai-watsonx-ai/pom.xml b/models/spring-ai-watsonx-ai/pom.xml index 8cd07c4e2bd..7f28cc147a6 100644 --- a/models/spring-ai-watsonx-ai/pom.xml +++ b/models/spring-ai-watsonx-ai/pom.xml @@ -11,6 +11,8 @@ spring-ai-watsonx-ai + Spring AI WatsonX AI + 17 17 diff --git a/pom.xml b/pom.xml index 771b4e5ad6a..d9f873b09d1 100644 --- a/pom.xml +++ b/pom.xml @@ -57,8 +57,8 @@ document-readers/tika-reader vector-stores/spring-ai-pinecone vector-stores/spring-ai-chroma-store - vector-stores/spring-ai-azure-vector-store - vector-stores/spring-ai-weaviate + vector-stores/spring-ai-azure-store + vector-stores/spring-ai-weaviate-store vector-stores/spring-ai-redis vector-stores/spring-ai-gemfire spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-palm2 diff --git a/vector-stores/spring-ai-azure-vector-store/README.md b/vector-stores/spring-ai-azure-store/README.md similarity index 100% rename from vector-stores/spring-ai-azure-vector-store/README.md rename to vector-stores/spring-ai-azure-store/README.md diff --git a/vector-stores/spring-ai-azure-vector-store/pom.xml b/vector-stores/spring-ai-azure-store/pom.xml similarity index 93% rename from vector-stores/spring-ai-azure-vector-store/pom.xml rename to vector-stores/spring-ai-azure-store/pom.xml index 08f680f83e2..19dbf15af61 100644 --- a/vector-stores/spring-ai-azure-vector-store/pom.xml +++ b/vector-stores/spring-ai-azure-store/pom.xml @@ -8,10 +8,10 @@ 1.0.0-SNAPSHOT ../../pom.xml - spring-ai-azure-vector-store + spring-ai-azure-store jar - Spring AI - Azure AI Search Vector Store - Spring AI - Azure AI Search Vector Store + Spring AI Vector Store - Azure AI Search + Spring AI Vector Store - Azure AI Search https://github.com/spring-projects/spring-ai diff --git a/vector-stores/spring-ai-azure-vector-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureAiSearchFilterExpressionConverter.java b/vector-stores/spring-ai-azure-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureAiSearchFilterExpressionConverter.java similarity index 100% rename from vector-stores/spring-ai-azure-vector-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureAiSearchFilterExpressionConverter.java rename to vector-stores/spring-ai-azure-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureAiSearchFilterExpressionConverter.java diff --git a/vector-stores/spring-ai-azure-vector-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java b/vector-stores/spring-ai-azure-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java similarity index 100% rename from vector-stores/spring-ai-azure-vector-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java rename to vector-stores/spring-ai-azure-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java diff --git a/vector-stores/spring-ai-azure-vector-store/src/test/java/org/springframework/ai/vectorstore/azure/AzureAiSearchFilterExpressionConverterTests.java b/vector-stores/spring-ai-azure-store/src/test/java/org/springframework/ai/vectorstore/azure/AzureAiSearchFilterExpressionConverterTests.java similarity index 100% rename from vector-stores/spring-ai-azure-vector-store/src/test/java/org/springframework/ai/vectorstore/azure/AzureAiSearchFilterExpressionConverterTests.java rename to vector-stores/spring-ai-azure-store/src/test/java/org/springframework/ai/vectorstore/azure/AzureAiSearchFilterExpressionConverterTests.java diff --git a/vector-stores/spring-ai-azure-vector-store/src/test/java/org/springframework/ai/vectorstore/azure/AzureVectorStoreIT.java b/vector-stores/spring-ai-azure-store/src/test/java/org/springframework/ai/vectorstore/azure/AzureVectorStoreIT.java similarity index 100% rename from vector-stores/spring-ai-azure-vector-store/src/test/java/org/springframework/ai/vectorstore/azure/AzureVectorStoreIT.java rename to vector-stores/spring-ai-azure-store/src/test/java/org/springframework/ai/vectorstore/azure/AzureVectorStoreIT.java diff --git a/vector-stores/spring-ai-chroma-store/pom.xml b/vector-stores/spring-ai-chroma-store/pom.xml index d6232a57616..586c6a88bac 100644 --- a/vector-stores/spring-ai-chroma-store/pom.xml +++ b/vector-stores/spring-ai-chroma-store/pom.xml @@ -10,7 +10,7 @@ spring-ai-chroma-store jar - Spring AI Chroma Vector Store + Spring AI Vector Store - Chroma Spring AI Chroma Vector Store https://github.com/spring-projects/spring-ai diff --git a/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java b/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java index 3c9741cbefe..e3c03863d72 100644 --- a/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java +++ b/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java @@ -38,7 +38,7 @@ * for embedding calculations. For more information about how it does this, see the * official Chroma website. * - * @author Josh Long + * */ public class ChromaVectorStore implements VectorStore, ApplicationListener { diff --git a/vector-stores/spring-ai-milvus-store/pom.xml b/vector-stores/spring-ai-milvus-store/pom.xml index 460f9dbbf49..798ef5be039 100644 --- a/vector-stores/spring-ai-milvus-store/pom.xml +++ b/vector-stores/spring-ai-milvus-store/pom.xml @@ -10,8 +10,8 @@ spring-ai-milvus-store jar - Spring AI Milvus Vector Store - Spring AI Milvus Vector Store + Spring AI Vector Store - Milvus + Spring AI Vector Store - Milvus https://github.com/spring-projects/spring-ai diff --git a/vector-stores/spring-ai-mongodb-atlas-store/pom.xml b/vector-stores/spring-ai-mongodb-atlas-store/pom.xml index 5c71ee2363c..abf7a5a2d00 100644 --- a/vector-stores/spring-ai-mongodb-atlas-store/pom.xml +++ b/vector-stores/spring-ai-mongodb-atlas-store/pom.xml @@ -10,8 +10,8 @@ spring-ai-mongodb-atlas-store jar - Spring AI MongoDB Atlas Vector Store - Spring AI MongoDB Atlas Vector Store + Spring AI Vector Store - MongoDB Atlas + Spring AI Vector Store - MongoDB Atlas https://github.com/spring-projects-experimental/spring-ai diff --git a/vector-stores/spring-ai-neo4j-store/pom.xml b/vector-stores/spring-ai-neo4j-store/pom.xml index 430f6f0eb04..9cbb5a1e23a 100644 --- a/vector-stores/spring-ai-neo4j-store/pom.xml +++ b/vector-stores/spring-ai-neo4j-store/pom.xml @@ -10,7 +10,7 @@ spring-ai-neo4j-store jar - Spring AI Vector Store - neo4j + Spring AI Vector Store - Neo4J Spring AI Neo4j Vector Store https://github.com/spring-projects/spring-ai diff --git a/vector-stores/spring-ai-pgvector-store/pom.xml b/vector-stores/spring-ai-pgvector-store/pom.xml index 9c23e4b052f..056e6e172e8 100644 --- a/vector-stores/spring-ai-pgvector-store/pom.xml +++ b/vector-stores/spring-ai-pgvector-store/pom.xml @@ -10,7 +10,7 @@ spring-ai-pgvector-store jar - Spring AI Vector Store - pgvector + Spring AI Vector Store - PGVector Spring AI PGVector Vector Store https://github.com/spring-projects/spring-ai diff --git a/vector-stores/spring-ai-pinecone/pom.xml b/vector-stores/spring-ai-pinecone/pom.xml index f9a40f83bf8..38fe9c4fdd2 100644 --- a/vector-stores/spring-ai-pinecone/pom.xml +++ b/vector-stores/spring-ai-pinecone/pom.xml @@ -10,8 +10,7 @@ spring-ai-pinecone jar - spring-ai-pinecone - spring-ai-pinecone + Spring AI Vector Store - Pinecone https://github.com/spring-projects/spring-ai diff --git a/vector-stores/spring-ai-qdrant/pom.xml b/vector-stores/spring-ai-qdrant/pom.xml index 95c578d2829..77ec45ffa96 100644 --- a/vector-stores/spring-ai-qdrant/pom.xml +++ b/vector-stores/spring-ai-qdrant/pom.xml @@ -10,8 +10,8 @@ spring-ai-qdrant jar - spring-ai-qdrant - spring-ai-qdrant + Spring AI Vector Store - QDrant + https://github.com/spring-projects/spring-ai diff --git a/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java b/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java index 1b54c2f003e..607a1ecb7a5 100644 --- a/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java +++ b/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java @@ -261,7 +261,6 @@ public List similaritySearch(SearchRequest request) { /** * Extracts metadata from a Protobuf Struct. - * @param metadataStruct The Protobuf Struct containing metadata. * @return The metadata as a map. */ private Document toDocument(ScoredPoint point) { diff --git a/vector-stores/spring-ai-redis/pom.xml b/vector-stores/spring-ai-redis/pom.xml index ebf2cc44ff2..2d66f35c732 100644 --- a/vector-stores/spring-ai-redis/pom.xml +++ b/vector-stores/spring-ai-redis/pom.xml @@ -10,8 +10,8 @@ spring-ai-redis jar - spring-ai-redis - Spring AI Redis Vector Store + Spring AI Vector Store - Redis + Spring AI Vector Store - Redis https://github.com/spring-projects/spring-ai diff --git a/vector-stores/spring-ai-weaviate/README.md b/vector-stores/spring-ai-weaviate-store/README.md similarity index 100% rename from vector-stores/spring-ai-weaviate/README.md rename to vector-stores/spring-ai-weaviate-store/README.md diff --git a/vector-stores/spring-ai-weaviate/pom.xml b/vector-stores/spring-ai-weaviate-store/pom.xml similarity index 96% rename from vector-stores/spring-ai-weaviate/pom.xml rename to vector-stores/spring-ai-weaviate-store/pom.xml index 523d341c72a..7324f9c7d92 100644 --- a/vector-stores/spring-ai-weaviate/pom.xml +++ b/vector-stores/spring-ai-weaviate-store/pom.xml @@ -10,8 +10,7 @@ spring-ai-weaviate-store jar - spring-ai-weaviate-store - spring-ai-weaviate + Spring AI Vector Store - Weaviate https://github.com/spring-projects/spring-ai diff --git a/vector-stores/spring-ai-weaviate/src/main/java/org/springframework/ai/vectorstore/WeaviateFilterExpressionConverter.java b/vector-stores/spring-ai-weaviate-store/src/main/java/org/springframework/ai/vectorstore/WeaviateFilterExpressionConverter.java similarity index 100% rename from vector-stores/spring-ai-weaviate/src/main/java/org/springframework/ai/vectorstore/WeaviateFilterExpressionConverter.java rename to vector-stores/spring-ai-weaviate-store/src/main/java/org/springframework/ai/vectorstore/WeaviateFilterExpressionConverter.java diff --git a/vector-stores/spring-ai-weaviate/src/main/java/org/springframework/ai/vectorstore/WeaviateVectorStore.java b/vector-stores/spring-ai-weaviate-store/src/main/java/org/springframework/ai/vectorstore/WeaviateVectorStore.java similarity index 100% rename from vector-stores/spring-ai-weaviate/src/main/java/org/springframework/ai/vectorstore/WeaviateVectorStore.java rename to vector-stores/spring-ai-weaviate-store/src/main/java/org/springframework/ai/vectorstore/WeaviateVectorStore.java diff --git a/vector-stores/spring-ai-weaviate/src/test/java/org/springframework/ai/vectorstore/WeaviateFilterExpressionConverterTests.java b/vector-stores/spring-ai-weaviate-store/src/test/java/org/springframework/ai/vectorstore/WeaviateFilterExpressionConverterTests.java similarity index 100% rename from vector-stores/spring-ai-weaviate/src/test/java/org/springframework/ai/vectorstore/WeaviateFilterExpressionConverterTests.java rename to vector-stores/spring-ai-weaviate-store/src/test/java/org/springframework/ai/vectorstore/WeaviateFilterExpressionConverterTests.java diff --git a/vector-stores/spring-ai-weaviate/src/test/java/org/springframework/ai/vectorstore/WeaviateVectorStoreIT.java b/vector-stores/spring-ai-weaviate-store/src/test/java/org/springframework/ai/vectorstore/WeaviateVectorStoreIT.java similarity index 100% rename from vector-stores/spring-ai-weaviate/src/test/java/org/springframework/ai/vectorstore/WeaviateVectorStoreIT.java rename to vector-stores/spring-ai-weaviate-store/src/test/java/org/springframework/ai/vectorstore/WeaviateVectorStoreIT.java diff --git a/vector-stores/spring-ai-weaviate/src/test/resources/docker-compose.yml b/vector-stores/spring-ai-weaviate-store/src/test/resources/docker-compose.yml similarity index 100% rename from vector-stores/spring-ai-weaviate/src/test/resources/docker-compose.yml rename to vector-stores/spring-ai-weaviate-store/src/test/resources/docker-compose.yml From 3ee04ed8da61c0f3d0f69eaa3e6012538d2cc432 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Thu, 9 May 2024 11:31:37 +0300 Subject: [PATCH 27/39] fix code style and missing dependency renaming --- spring-ai-bom/pom.xml | 2 +- .../ROOT/pages/api/vectordbs/azure.adoc | 2 +- spring-ai-spring-boot-autoconfigure/pom.xml | 4 +- .../spring-ai-starter-azure-store/pom.xml | 2 +- .../vectorstore/azure/AzureVectorStore.java | 26 +++++++++--- .../ai/vectorstore/CassandraVectorStore.java | 14 ++++++- .../CassandraVectorStoreConfig.java | 6 ++- .../ai/vectorstore/ChromaVectorStore.java | 10 +++-- .../ai/vectorstore/JsonUtils.java | 4 -- .../ai/vectorstore/RedisVectorStore.java | 40 +++++++++++++------ 10 files changed, 77 insertions(+), 33 deletions(-) diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index d1c857f9da9..507a8b40146 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -128,7 +128,7 @@ org.springframework.ai - spring-ai-azure-vector-store + spring-ai-azure-store ${project.version} diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/azure.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/azure.adoc index 1ad2d24a1bb..b9f303c077c 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/azure.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/azure.adoc @@ -77,7 +77,7 @@ Add these dependencies to your project: ---- org.springframework.ai - spring-ai-azure-vector-store + spring-ai-azure-store ---- diff --git a/spring-ai-spring-boot-autoconfigure/pom.xml b/spring-ai-spring-boot-autoconfigure/pom.xml index ee5a26bc452..38e867b1c4e 100644 --- a/spring-ai-spring-boot-autoconfigure/pom.xml +++ b/spring-ai-spring-boot-autoconfigure/pom.xml @@ -141,7 +141,7 @@ org.springframework.ai - spring-ai-azure-vector-store + spring-ai-azure-store ${project.parent.version} true @@ -328,7 +328,7 @@ cassandra test - + com.redis testcontainers-redis diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-azure-store/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-azure-store/pom.xml index d688bba563c..c6b1d727622 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-azure-store/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-azure-store/pom.xml @@ -34,7 +34,7 @@ org.springframework.ai - spring-ai-azure-vector-store + spring-ai-azure-store ${project.parent.version} diff --git a/vector-stores/spring-ai-azure-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java b/vector-stores/spring-ai-azure-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java index 9a5205cfb95..85e790277f9 100644 --- a/vector-stores/spring-ai-azure-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java +++ b/vector-stores/spring-ai-azure-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java @@ -15,16 +15,35 @@ */ package org.springframework.ai.vectorstore.azure; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.TypeReference; import com.azure.core.util.Context; import com.azure.search.documents.SearchClient; import com.azure.search.documents.SearchDocument; import com.azure.search.documents.indexes.SearchIndexClient; -import com.azure.search.documents.indexes.models.*; -import com.azure.search.documents.models.*; +import com.azure.search.documents.indexes.models.HnswAlgorithmConfiguration; +import com.azure.search.documents.indexes.models.HnswParameters; +import com.azure.search.documents.indexes.models.SearchField; +import com.azure.search.documents.indexes.models.SearchFieldDataType; +import com.azure.search.documents.indexes.models.SearchIndex; +import com.azure.search.documents.indexes.models.VectorSearch; +import com.azure.search.documents.indexes.models.VectorSearchAlgorithmMetric; +import com.azure.search.documents.indexes.models.VectorSearchProfile; +import com.azure.search.documents.models.IndexDocumentsResult; +import com.azure.search.documents.models.IndexingResult; +import com.azure.search.documents.models.SearchOptions; +import com.azure.search.documents.models.VectorSearchOptions; +import com.azure.search.documents.models.VectorizedQuery; import org.slf4j.Logger; import org.slf4j.LoggerFactory; + import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.vectorstore.SearchRequest; @@ -36,9 +55,6 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; -import java.util.*; -import java.util.stream.Collectors; - /** * Uses Azure Cognitive Search as a backing vector store. Documents can be preloaded into * a Cognitive Search index and managed via Azure tools or added and managed through this diff --git a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java index c06cfdfc566..c08f7d09d64 100644 --- a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java +++ b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java @@ -15,7 +15,11 @@ */ package org.springframework.ai.vectorstore; -import com.datastax.oss.driver.api.core.cql.*; +import com.datastax.oss.driver.api.core.cql.BoundStatement; +import com.datastax.oss.driver.api.core.cql.BoundStatementBuilder; +import com.datastax.oss.driver.api.core.cql.PreparedStatement; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.core.cql.SimpleStatement; import com.datastax.oss.driver.api.core.data.CqlVector; import com.datastax.oss.driver.api.core.metadata.schema.TableMetadata; import com.datastax.oss.driver.api.querybuilder.QueryBuilder; @@ -32,7 +36,13 @@ import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; import org.springframework.beans.factory.InitializingBean; -import java.util.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; diff --git a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java index aebe211c8af..7218f26973e 100644 --- a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java +++ b/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java @@ -38,11 +38,15 @@ import java.net.InetSocketAddress; import java.time.Duration; -import java.util.*; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.function.Function; import java.util.stream.Stream; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; /** * Configuration for the Cassandra vector store. diff --git a/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java b/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java index e3c03863d72..f51c530e4fd 100644 --- a/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java +++ b/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java @@ -15,6 +15,12 @@ */ package org.springframework.ai.vectorstore; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + import org.springframework.ai.chroma.ChromaApi; import org.springframework.ai.chroma.ChromaApi.AddEmbeddingsRequest; import org.springframework.ai.chroma.ChromaApi.DeleteEmbeddingsRequest; @@ -29,8 +35,6 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; -import java.util.*; - /** * {@link ChromaVectorStore} is a concrete implementation of the {@link VectorStore} * interface. It is responsible for adding, deleting, and searching documents based on @@ -38,7 +42,7 @@ * for embedding calculations. For more information about how it does this, see the * official Chroma website. * - * + * @author Christian Tzolov */ public class ChromaVectorStore implements VectorStore, ApplicationListener { diff --git a/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/JsonUtils.java b/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/JsonUtils.java index 7a05fab2869..41f891b4927 100644 --- a/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/JsonUtils.java +++ b/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/JsonUtils.java @@ -21,10 +21,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -/** - * @author Christian Tzolov - */ - /** * Utility class for JSON processing. Provides methods for converting JSON strings to maps * and lists, and for converting between lists of different numeric types. diff --git a/vector-stores/spring-ai-redis/src/main/java/org/springframework/ai/vectorstore/RedisVectorStore.java b/vector-stores/spring-ai-redis/src/main/java/org/springframework/ai/vectorstore/RedisVectorStore.java index 744411622dc..47982bf9281 100644 --- a/vector-stores/spring-ai-redis/src/main/java/org/springframework/ai/vectorstore/RedisVectorStore.java +++ b/vector-stores/spring-ai-redis/src/main/java/org/springframework/ai/vectorstore/RedisVectorStore.java @@ -15,8 +15,35 @@ */ package org.springframework.ai.vectorstore; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import redis.clients.jedis.JedisPooled; +import redis.clients.jedis.Pipeline; +import redis.clients.jedis.json.Path2; +import redis.clients.jedis.search.FTCreateParams; +import redis.clients.jedis.search.IndexDataType; +import redis.clients.jedis.search.Query; +import redis.clients.jedis.search.RediSearchUtil; +import redis.clients.jedis.search.Schema.FieldType; +import redis.clients.jedis.search.SearchResult; +import redis.clients.jedis.search.schemafields.NumericField; +import redis.clients.jedis.search.schemafields.SchemaField; +import redis.clients.jedis.search.schemafields.TagField; +import redis.clients.jedis.search.schemafields.TextField; +import redis.clients.jedis.search.schemafields.VectorField; +import redis.clients.jedis.search.schemafields.VectorField.VectorAlgorithm; + import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; @@ -24,19 +51,6 @@ import org.springframework.context.ApplicationListener; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; -import redis.clients.jedis.JedisPooled; -import redis.clients.jedis.Pipeline; -import redis.clients.jedis.json.Path2; -import redis.clients.jedis.search.*; -import redis.clients.jedis.search.Schema.FieldType; -import redis.clients.jedis.search.schemafields.*; -import redis.clients.jedis.search.schemafields.VectorField.VectorAlgorithm; - -import java.text.MessageFormat; -import java.util.*; -import java.util.function.Function; -import java.util.function.Predicate; -import java.util.stream.Collectors; /** * The RedisVectorStore is for managing and querying vector data in a Redis database. It From 1985824fa906c7b8deab9b92cb1fdcb5c457f022 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Thu, 9 May 2024 18:22:24 +0300 Subject: [PATCH 28/39] clean code imports --- .../springframework/ai/vectorstore/azure/AzureVectorStore.java | 3 ++- .../org/springframework/ai/vectorstore/ChromaVectorStore.java | 3 ++- .../test/java/org/springframework/ai/chroma/ChromaApiIT.java | 2 +- .../springframework/ai/vectorstore/BasicAuthChromaWhereIT.java | 1 + .../springframework/ai/vectorstore/ChromaVectorStoreIT.java | 3 +-- .../ai/vectorstore/TokenSecuredChromaWhereIT.java | 1 + 6 files changed, 8 insertions(+), 5 deletions(-) diff --git a/vector-stores/spring-ai-azure-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java b/vector-stores/spring-ai-azure-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java index 85e790277f9..36417d66955 100644 --- a/vector-stores/spring-ai-azure-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java +++ b/vector-stores/spring-ai-azure-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java @@ -51,6 +51,7 @@ import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.ApplicationListener; +import org.springframework.lang.NonNull; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; @@ -118,7 +119,7 @@ public class AzureVectorStore implements VectorStore, ApplicationListener filterMetadataFields; @Override - public void onApplicationEvent(ApplicationReadyEvent event) { + public void onApplicationEvent(@NonNull ApplicationReadyEvent event) { int dimensions = this.embeddingClient.dimensions(); diff --git a/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java b/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java index f51c530e4fd..a53d02e9aca 100644 --- a/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java +++ b/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java @@ -31,6 +31,7 @@ import org.springframework.ai.vectorstore.filter.converter.ChromaFilterExpressionConverter; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.ApplicationListener; +import org.springframework.lang.NonNull; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; @@ -150,7 +151,7 @@ public List similaritySearch(SearchRequest request) { } @Override - public void onApplicationEvent(ApplicationReadyEvent event) { + public void onApplicationEvent(@NonNull ApplicationReadyEvent event) { var collection = this.chromaApi.getCollection(this.collectionName); if (collection == null) { collection = this.chromaApi.createCollection(new ChromaApi.CreateCollectionRequest(this.collectionName)); diff --git a/vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/chroma/ChromaApiIT.java b/vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/chroma/ChromaApiIT.java index a53814d569b..0d1576ea0b0 100644 --- a/vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/chroma/ChromaApiIT.java +++ b/vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/chroma/ChromaApiIT.java @@ -45,7 +45,7 @@ public class ChromaApiIT { @Container - static ChromaDBContainer chromaContainer = new ChromaDBContainer("ghcr.io/chroma-core/chroma:0.4.22.dev44"); + static ChromaDBContainer chromaContainer = new ChromaDBContainer("ghcr.io/chroma-core/chroma:0.4.22"); @Autowired ChromaApi chroma; diff --git a/vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/vectorstore/BasicAuthChromaWhereIT.java b/vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/vectorstore/BasicAuthChromaWhereIT.java index a7b9ebf12c0..8be4b8ac9a6 100644 --- a/vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/vectorstore/BasicAuthChromaWhereIT.java +++ b/vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/vectorstore/BasicAuthChromaWhereIT.java @@ -28,6 +28,7 @@ import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.ai.openai.OpenAiEmbeddingClient; +import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; diff --git a/vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/vectorstore/ChromaVectorStoreIT.java b/vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/vectorstore/ChromaVectorStoreIT.java index 9c5e8674ede..7ed5eeea847 100644 --- a/vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/vectorstore/ChromaVectorStoreIT.java +++ b/vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/vectorstore/ChromaVectorStoreIT.java @@ -28,9 +28,8 @@ import org.springframework.ai.chroma.ChromaApi; import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingClient; -import org.springframework.ai.openai.api.OpenAiApi; -import org.springframework.ai.vectorstore.ChromaVectorStore; import org.springframework.ai.openai.OpenAiEmbeddingClient; +import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; diff --git a/vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/vectorstore/TokenSecuredChromaWhereIT.java b/vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/vectorstore/TokenSecuredChromaWhereIT.java index 4953732c1bf..dac6e9bc0a3 100644 --- a/vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/vectorstore/TokenSecuredChromaWhereIT.java +++ b/vector-stores/spring-ai-chroma-store/src/test/java/org/springframework/ai/vectorstore/TokenSecuredChromaWhereIT.java @@ -28,6 +28,7 @@ import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.ai.openai.OpenAiEmbeddingClient; +import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; From 3475f17e98c3c6be01792fb5d09ed805d30d37ee Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Fri, 17 May 2024 17:05:03 +0200 Subject: [PATCH 29/39] Unify the vector store module and pom names spring-ai-qdrant -> spring-ai-qdrant-store spring-ai-cassandra -> spring-ai-cassandra-store spring-ai-pinecone -> spring-ai-pinecone-store spring-ai-redis -> spring-ai-redis-store spring-ai-qdrant -> spring-ai-qdrant-store spring-ai-gemfire -> spring-ai-gemfire-store spring-ai-azure-vector-store-spring-boot-starter -> spring-ai-azure-store-spring-boot-starter spring-ai-redis-spring-boot-starter -> spring-ai-redis-store-spring-boot-starter --- models/spring-ai-openai/pom.xml | 2 +- pom.xml | 100 +++++++++-------- spring-ai-bom/pom.xml | 14 +-- .../pages/api/vectordbs/apache-cassandra.adoc | 4 +- .../ROOT/pages/api/vectordbs/gemfire.adoc | 2 +- .../ROOT/pages/api/vectordbs/pinecone.adoc | 2 +- .../ROOT/pages/api/vectordbs/qdrant.adoc | 4 +- .../ROOT/pages/api/vectordbs/redis.adoc | 2 +- spring-ai-spring-boot-autoconfigure/pom.xml | 8 +- .../spring-ai-starter-azure-store/pom.xml | 2 +- .../pom.xml | 4 +- .../spring-ai-starter-pinecone-store/pom.xml | 2 +- .../spring-ai-starter-qdrant-store/pom.xml | 2 +- .../pom.xml | 4 +- spring-ai-spring-boot-testcontainers/pom.xml | 4 +- .../vectorstore/azure/AzureVectorStore.java | 103 +++++++++--------- .../README.md | 0 .../pom.xml | 2 +- .../CassandraFilterExpressionConverter.java | 0 .../ai/vectorstore/CassandraVectorStore.java | 0 .../CassandraVectorStoreConfig.java | 0 .../src/main/resources/application.conf | 0 ...ssandraFilterExpressionConverterTests.java | 0 .../CassandraRichSchemaVectorStoreIT.java | 0 .../vectorstore/CassandraVectorStoreIT.java | 0 .../vectorstore/WikiVectorStoreExample.java | 0 .../src/test/resources/application.conf | 0 .../test/resources/test_wiki_full_schema.cql | 0 .../resources/test_wiki_partial_0_schema.cql | 0 .../resources/test_wiki_partial_1_schema.cql | 0 .../resources/test_wiki_partial_2_schema.cql | 0 .../resources/test_wiki_partial_3_schema.cql | 0 .../resources/test_wiki_partial_4_schema.cql | 0 .../ai/vectorstore/ChromaVectorStore.java | 12 +- .../vectorstore/ElasticsearchVectorStore.java | 10 +- .../README.md | 0 .../pom.xml | 2 +- .../ai/vectorstore/GemFireVectorStore.java | 0 .../ai/vectorstore/GemFireVectorStoreIT.java | 0 .../ai/vectorstore/MilvusVectorStore.java | 15 +-- .../vectorstore/MongoDBAtlasVectorStore.java | 26 ++--- .../ai/vectorstore/Neo4jVectorStore.java | 55 +++++----- .../ai/vectorstore/PgVectorStore.java | 67 +++++------- .../README.md | 0 .../pom.xml | 2 +- .../ai/vectorstore/PineconeVectorStore.java | 0 .../ai/vectorstore/PineconeVectorStoreIT.java | 0 .../README.md | 0 .../pom.xml | 2 +- .../QdrantFilterExpressionConverter.java | 0 .../qdrant/QdrantObjectFactory.java | 0 .../qdrant/QdrantValueFactory.java | 0 .../vectorstore/qdrant/QdrantVectorStore.java | 62 +++++------ .../qdrant/QdrantVectorStoreIT.java | 0 .../README.md | 0 .../pom.xml | 2 +- .../RedisFilterExpressionConverter.java | 0 .../ai/vectorstore/RedisVectorStore.java | 51 +++++---- .../RedisFilterExpressionConverterTests.java | 0 .../ai/vectorstore/RedisVectorStoreIT.java | 0 .../ai/vectorstore/WeaviateVectorStore.java | 2 +- 61 files changed, 270 insertions(+), 299 deletions(-) rename spring-ai-spring-boot-starters/{spring-ai-starter-cassandra => spring-ai-starter-cassandra-store}/pom.xml (92%) rename spring-ai-spring-boot-starters/{spring-ai-starter-redis => spring-ai-starter-redis-store}/pom.xml (92%) rename vector-stores/{spring-ai-cassandra => spring-ai-cassandra-store}/README.md (100%) rename vector-stores/{spring-ai-cassandra => spring-ai-cassandra-store}/pom.xml (97%) rename vector-stores/{spring-ai-cassandra => spring-ai-cassandra-store}/src/main/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverter.java (100%) rename vector-stores/{spring-ai-cassandra => spring-ai-cassandra-store}/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java (100%) rename vector-stores/{spring-ai-cassandra => spring-ai-cassandra-store}/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java (100%) rename vector-stores/{spring-ai-cassandra => spring-ai-cassandra-store}/src/main/resources/application.conf (100%) rename vector-stores/{spring-ai-cassandra => spring-ai-cassandra-store}/src/test/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverterTests.java (100%) rename vector-stores/{spring-ai-cassandra => spring-ai-cassandra-store}/src/test/java/org/springframework/ai/vectorstore/CassandraRichSchemaVectorStoreIT.java (100%) rename vector-stores/{spring-ai-cassandra => spring-ai-cassandra-store}/src/test/java/org/springframework/ai/vectorstore/CassandraVectorStoreIT.java (100%) rename vector-stores/{spring-ai-cassandra => spring-ai-cassandra-store}/src/test/java/org/springframework/ai/vectorstore/WikiVectorStoreExample.java (100%) rename vector-stores/{spring-ai-cassandra => spring-ai-cassandra-store}/src/test/resources/application.conf (100%) rename vector-stores/{spring-ai-cassandra => spring-ai-cassandra-store}/src/test/resources/test_wiki_full_schema.cql (100%) rename vector-stores/{spring-ai-cassandra => spring-ai-cassandra-store}/src/test/resources/test_wiki_partial_0_schema.cql (100%) rename vector-stores/{spring-ai-cassandra => spring-ai-cassandra-store}/src/test/resources/test_wiki_partial_1_schema.cql (100%) rename vector-stores/{spring-ai-cassandra => spring-ai-cassandra-store}/src/test/resources/test_wiki_partial_2_schema.cql (100%) rename vector-stores/{spring-ai-cassandra => spring-ai-cassandra-store}/src/test/resources/test_wiki_partial_3_schema.cql (100%) rename vector-stores/{spring-ai-cassandra => spring-ai-cassandra-store}/src/test/resources/test_wiki_partial_4_schema.cql (100%) rename vector-stores/{spring-ai-gemfire => spring-ai-gemfire-store}/README.md (100%) rename vector-stores/{spring-ai-gemfire => spring-ai-gemfire-store}/pom.xml (98%) rename vector-stores/{spring-ai-gemfire => spring-ai-gemfire-store}/src/main/java/org/springframework/ai/vectorstore/GemFireVectorStore.java (100%) rename vector-stores/{spring-ai-gemfire => spring-ai-gemfire-store}/src/test/java/org/springframework/ai/vectorstore/GemFireVectorStoreIT.java (100%) rename vector-stores/{spring-ai-pinecone => spring-ai-pinecone-store}/README.md (100%) rename vector-stores/{spring-ai-pinecone => spring-ai-pinecone-store}/pom.xml (98%) rename vector-stores/{spring-ai-pinecone => spring-ai-pinecone-store}/src/main/java/org/springframework/ai/vectorstore/PineconeVectorStore.java (100%) rename vector-stores/{spring-ai-pinecone => spring-ai-pinecone-store}/src/test/java/org/springframework/ai/vectorstore/PineconeVectorStoreIT.java (100%) rename vector-stores/{spring-ai-qdrant => spring-ai-qdrant-store}/README.md (100%) rename vector-stores/{spring-ai-qdrant => spring-ai-qdrant-store}/pom.xml (98%) rename vector-stores/{spring-ai-qdrant => spring-ai-qdrant-store}/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantFilterExpressionConverter.java (100%) rename vector-stores/{spring-ai-qdrant => spring-ai-qdrant-store}/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantObjectFactory.java (100%) rename vector-stores/{spring-ai-qdrant => spring-ai-qdrant-store}/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantValueFactory.java (100%) rename vector-stores/{spring-ai-qdrant => spring-ai-qdrant-store}/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java (95%) rename vector-stores/{spring-ai-qdrant => spring-ai-qdrant-store}/src/test/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStoreIT.java (100%) rename vector-stores/{spring-ai-redis => spring-ai-redis-store}/README.md (100%) rename vector-stores/{spring-ai-redis => spring-ai-redis-store}/pom.xml (98%) rename vector-stores/{spring-ai-redis => spring-ai-redis-store}/src/main/java/org/springframework/ai/vectorstore/RedisFilterExpressionConverter.java (100%) rename vector-stores/{spring-ai-redis => spring-ai-redis-store}/src/main/java/org/springframework/ai/vectorstore/RedisVectorStore.java (97%) rename vector-stores/{spring-ai-redis => spring-ai-redis-store}/src/test/java/org/springframework/ai/vectorstore/RedisFilterExpressionConverterTests.java (100%) rename vector-stores/{spring-ai-redis => spring-ai-redis-store}/src/test/java/org/springframework/ai/vectorstore/RedisVectorStoreIT.java (100%) diff --git a/models/spring-ai-openai/pom.xml b/models/spring-ai-openai/pom.xml index 715e3474f7c..135b95f140c 100644 --- a/models/spring-ai-openai/pom.xml +++ b/models/spring-ai-openai/pom.xml @@ -77,7 +77,7 @@ org.springframework.ai - spring-ai-qdrant + spring-ai-qdrant-store ${project.version} diff --git a/pom.xml b/pom.xml index d9f873b09d1..c5e2e767d89 100644 --- a/pom.xml +++ b/pom.xml @@ -13,67 +13,73 @@ Building AI applications with Spring Boot + spring-ai-docs spring-ai-bom spring-ai-core - models/spring-ai-transformers - models/spring-ai-postgresml - models/spring-ai-bedrock + spring-ai-test + spring-ai-spring-boot-autoconfigure + spring-ai-retry + spring-ai-spring-boot-testcontainers + + document-readers/pdf-reader + document-readers/tika-reader + + vector-stores/spring-ai-azure-store + vector-stores/spring-ai-cassandra-store + vector-stores/spring-ai-chroma-store + vector-stores/spring-ai-elasticsearch-store + vector-stores/spring-ai-gemfire-store + vector-stores/spring-ai-hanadb-store + vector-stores/spring-ai-milvus-store + vector-stores/spring-ai-mongodb-atlas-store + vector-stores/spring-ai-neo4j-store + vector-stores/spring-ai-pgvector-store + vector-stores/spring-ai-pinecone-store + vector-stores/spring-ai-qdrant-store + vector-stores/spring-ai-redis-store + vector-stores/spring-ai-weaviate-store + + spring-ai-spring-boot-starters/spring-ai-starter-azure-store + spring-ai-spring-boot-starters/spring-ai-starter-cassandra-store + spring-ai-spring-boot-starters/spring-ai-starter-chroma-store + spring-ai-spring-boot-starters/spring-ai-starter-elasticsearch-store + spring-ai-spring-boot-starters/spring-ai-starter-hanadb-store + spring-ai-spring-boot-starters/spring-ai-starter-milvus-store + spring-ai-spring-boot-starters/spring-ai-starter-mongodb-atlas-store + spring-ai-spring-boot-starters/spring-ai-starter-neo4j-store + spring-ai-spring-boot-starters/spring-ai-starter-pgvector-store + spring-ai-spring-boot-starters/spring-ai-starter-pinecone-store + spring-ai-spring-boot-starters/spring-ai-starter-qdrant-store + spring-ai-spring-boot-starters/spring-ai-starter-redis-store + spring-ai-spring-boot-starters/spring-ai-starter-weaviate-store + + models/spring-ai-anthropic models/spring-ai-azure-openai + models/spring-ai-bedrock models/spring-ai-huggingface + models/spring-ai-mistral-ai models/spring-ai-ollama models/spring-ai-openai + models/spring-ai-postgresml models/spring-ai-stability-ai - models/spring-ai-mistral-ai - models/spring-ai-vertex-ai-palm2 + models/spring-ai-transformers models/spring-ai-vertex-ai-gemini - models/spring-ai-anthropic + models/spring-ai-vertex-ai-palm2 models/spring-ai-watsonx-ai - spring-ai-test - spring-ai-spring-boot-autoconfigure - spring-ai-spring-boot-starters/spring-ai-starter-openai + + spring-ai-spring-boot-starters/spring-ai-starter-anthropic spring-ai-spring-boot-starters/spring-ai-starter-azure-openai + spring-ai-spring-boot-starters/spring-ai-starter-bedrock-ai + spring-ai-spring-boot-starters/spring-ai-starter-mistral-ai spring-ai-spring-boot-starters/spring-ai-starter-ollama - spring-ai-spring-boot-starters/spring-ai-starter-transformers - spring-ai-spring-boot-starters/spring-ai-starter-cassandra - spring-ai-spring-boot-starters/spring-ai-starter-chroma-store - spring-ai-spring-boot-starters/spring-ai-starter-milvus-store - spring-ai-spring-boot-starters/spring-ai-starter-pgvector-store - spring-ai-spring-boot-starters/spring-ai-starter-hanadb-store - spring-ai-spring-boot-starters/spring-ai-starter-pinecone-store - spring-ai-spring-boot-starters/spring-ai-starter-azure-store - spring-ai-spring-boot-starters/spring-ai-starter-weaviate-store - spring-ai-spring-boot-starters/spring-ai-starter-redis - spring-ai-spring-boot-starters/spring-ai-starter-stability-ai - spring-ai-spring-boot-starters/spring-ai-starter-neo4j-store - spring-ai-spring-boot-starters/spring-ai-starter-qdrant-store + spring-ai-spring-boot-starters/spring-ai-starter-openai spring-ai-spring-boot-starters/spring-ai-starter-postgresml-embedding - spring-ai-docs - vector-stores/spring-ai-cassandra - vector-stores/spring-ai-pgvector-store - vector-stores/spring-ai-hanadb-store - vector-stores/spring-ai-milvus-store - vector-stores/spring-ai-neo4j-store - document-readers/pdf-reader - document-readers/tika-reader - vector-stores/spring-ai-pinecone - vector-stores/spring-ai-chroma-store - vector-stores/spring-ai-azure-store - vector-stores/spring-ai-weaviate-store - vector-stores/spring-ai-redis - vector-stores/spring-ai-gemfire - spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-palm2 + spring-ai-spring-boot-starters/spring-ai-starter-stability-ai + spring-ai-spring-boot-starters/spring-ai-starter-transformers spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-gemini - vector-stores/spring-ai-qdrant - spring-ai-spring-boot-starters/spring-ai-starter-bedrock-ai - spring-ai-spring-boot-starters/spring-ai-starter-mistral-ai - spring-ai-retry - vector-stores/spring-ai-mongodb-atlas-store - spring-ai-spring-boot-starters/spring-ai-starter-mongodb-atlas-store - spring-ai-spring-boot-testcontainers - spring-ai-spring-boot-starters/spring-ai-starter-anthropic - vector-stores/spring-ai-elasticsearch-store + spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-palm2 spring-ai-spring-boot-starters/spring-ai-starter-watsonx-ai - spring-ai-spring-boot-starters/spring-ai-starter-elasticsearch-store + diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index 507a8b40146..0c651b56ecf 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -134,7 +134,7 @@ org.springframework.ai - spring-ai-cassandra + spring-ai-cassandra-store ${project.version} @@ -170,13 +170,13 @@ org.springframework.ai - spring-ai-pinecone + spring-ai-pinecone-store ${project.version} org.springframework.ai - spring-ai-redis + spring-ai-redis-store ${project.version} @@ -188,7 +188,7 @@ org.springframework.ai - spring-ai-qdrant + spring-ai-qdrant-store ${project.version} @@ -206,7 +206,7 @@ org.springframework.ai - spring-ai-gemfire + spring-ai-gemfire-store ${project.version} @@ -238,7 +238,7 @@ org.springframework.ai - spring-ai-azure-vector-store-spring-boot-starter + spring-ai-azure-store-spring-boot-starter ${project.version} @@ -316,7 +316,7 @@ org.springframework.ai - spring-ai-redis-spring-boot-starter + spring-ai-redis-store-spring-boot-starter ${project.version} diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/apache-cassandra.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/apache-cassandra.adoc index 51c6110cb0f..b1c9d9f0d19 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/apache-cassandra.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/apache-cassandra.adoc @@ -57,7 +57,7 @@ Add these dependencies to your project: ---- org.springframework.ai - spring-ai-cassandra + spring-ai-cassandra-store ---- @@ -67,7 +67,7 @@ Add these dependencies to your project: ---- org.springframework.ai - spring-ai-cassandra-spring-boot-starter + spring-ai-cassandra-store-spring-boot-starter ---- diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/gemfire.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/gemfire.adoc index dff91d57515..c09a45ef744 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/gemfire.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/gemfire.adoc @@ -37,7 +37,7 @@ Add these dependencies to your project: ---- org.springframework.ai - spring-ai-gemfire + spring-ai-gemfire-store ---- diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/pinecone.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/pinecone.adoc index 7cb9132b702..ee7942711fd 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/pinecone.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/pinecone.adoc @@ -172,7 +172,7 @@ Add these dependencies to your project: ---- org.springframework.ai - spring-ai-pinecone + spring-ai-pinecone-store ---- diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc index 5ebaf926e48..87aef84f9c1 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/qdrant.adoc @@ -147,13 +147,13 @@ NOTE: These filter expressions are converted into the equivalent Qdrant link:htt == Manual Configuration -Instead of using the Spring Boot auto-configuration, you can manually configure the `QdrantVectorStore`. For this you need to add the `spring-ai-qdrant` dependency to your project: +Instead of using the Spring Boot auto-configuration, you can manually configure the `QdrantVectorStore`. For this you need to add the `spring-ai-qdrant-store` dependency to your project: [source,xml] ---- org.springframework.ai - spring-ai-qdrant + spring-ai-qdrant-store ---- diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/redis.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/redis.adoc index 7adfd60c480..436fd59ce6f 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/redis.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/vectordbs/redis.adoc @@ -162,7 +162,7 @@ Add the Redis Vector Store and Jedis dependencies ---- org.springframework.ai - spring-ai-redis + spring-ai-redis-store diff --git a/spring-ai-spring-boot-autoconfigure/pom.xml b/spring-ai-spring-boot-autoconfigure/pom.xml index 38e867b1c4e..09b48fc7189 100644 --- a/spring-ai-spring-boot-autoconfigure/pom.xml +++ b/spring-ai-spring-boot-autoconfigure/pom.xml @@ -80,7 +80,7 @@ org.springframework.ai - spring-ai-pinecone + spring-ai-pinecone-store ${project.parent.version} true @@ -149,7 +149,7 @@ org.springframework.ai - spring-ai-cassandra + spring-ai-cassandra-store ${project.parent.version} true @@ -165,7 +165,7 @@ org.springframework.ai - spring-ai-redis + spring-ai-redis-store ${project.parent.version} true @@ -242,7 +242,7 @@ org.springframework.ai - spring-ai-qdrant + spring-ai-qdrant-store ${project.parent.version} true diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-azure-store/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-azure-store/pom.xml index c6b1d727622..d9292532ea7 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-azure-store/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-azure-store/pom.xml @@ -7,7 +7,7 @@ 1.0.0-SNAPSHOT ../../pom.xml - spring-ai-azure-vector-store-spring-boot-starter + spring-ai-azure-store-spring-boot-starter jar Spring AI Starter - Azure Vector Store Spring AI Azure Vector Store Auto Configuration diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-cassandra/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-cassandra-store/pom.xml similarity index 92% rename from spring-ai-spring-boot-starters/spring-ai-starter-cassandra/pom.xml rename to spring-ai-spring-boot-starters/spring-ai-starter-cassandra-store/pom.xml index a40111ee87f..00b58ba7586 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-cassandra/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-cassandra-store/pom.xml @@ -7,7 +7,7 @@ 1.0.0-SNAPSHOT ../../pom.xml - spring-ai-cassandra-spring-boot-starter + spring-ai-cassandra-store-spring-boot-starter jar Spring AI Starter - Apache Cassandra Vector Store Spring AI Apache Cassandra Vector Store Auto Configuration @@ -34,7 +34,7 @@ org.springframework.ai - spring-ai-cassandra + spring-ai-cassandra-store ${project.parent.version} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-pinecone-store/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-pinecone-store/pom.xml index 1a46b3bd7de..aefe6a0a62c 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-pinecone-store/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-pinecone-store/pom.xml @@ -34,7 +34,7 @@ org.springframework.ai - spring-ai-pinecone + spring-ai-pinecone-store ${project.parent.version} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-qdrant-store/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-qdrant-store/pom.xml index 4a578cca655..acbfe9a28d8 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-qdrant-store/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-qdrant-store/pom.xml @@ -34,7 +34,7 @@ org.springframework.ai - spring-ai-qdrant + spring-ai-qdrant-store ${project.parent.version} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-redis/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-redis-store/pom.xml similarity index 92% rename from spring-ai-spring-boot-starters/spring-ai-starter-redis/pom.xml rename to spring-ai-spring-boot-starters/spring-ai-starter-redis-store/pom.xml index 5d6794fb20f..09fe4abb2e8 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-redis/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-redis-store/pom.xml @@ -7,7 +7,7 @@ 1.0.0-SNAPSHOT ../../pom.xml - spring-ai-redis-spring-boot-starter + spring-ai-redis-store-spring-boot-starter jar Spring AI Starter - Redis Vector Store Spring AI Redis Vector Store Auto Configuration @@ -34,7 +34,7 @@ org.springframework.ai - spring-ai-redis + spring-ai-redis-store ${project.parent.version} diff --git a/spring-ai-spring-boot-testcontainers/pom.xml b/spring-ai-spring-boot-testcontainers/pom.xml index 9758c4bc7b2..ca44ac49fb7 100644 --- a/spring-ai-spring-boot-testcontainers/pom.xml +++ b/spring-ai-spring-boot-testcontainers/pom.xml @@ -94,7 +94,7 @@ org.springframework.ai - spring-ai-redis + spring-ai-redis-store ${project.parent.version} true @@ -110,7 +110,7 @@ org.springframework.ai - spring-ai-qdrant + spring-ai-qdrant-store ${project.parent.version} true diff --git a/vector-stores/spring-ai-azure-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java b/vector-stores/spring-ai-azure-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java index 36417d66955..e0d8d9188c5 100644 --- a/vector-stores/spring-ai-azure-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java +++ b/vector-stores/spring-ai-azure-store/src/main/java/org/springframework/ai/vectorstore/azure/AzureVectorStore.java @@ -49,9 +49,7 @@ import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.VectorStore; import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.ApplicationListener; -import org.springframework.lang.NonNull; +import org.springframework.beans.factory.InitializingBean; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; @@ -65,9 +63,8 @@ * @author Greg Meyer * @author Xiangyang Yu * @author Christian Tzolov - * @author Josh Long */ -public class AzureVectorStore implements VectorStore, ApplicationListener { +public class AzureVectorStore implements VectorStore, InitializingBean { private static final Logger logger = LoggerFactory.getLogger(AzureVectorStore.class); @@ -118,53 +115,6 @@ public class AzureVectorStore implements VectorStore, ApplicationListener filterMetadataFields; - @Override - public void onApplicationEvent(@NonNull ApplicationReadyEvent event) { - - int dimensions = this.embeddingClient.dimensions(); - - List fields = new ArrayList<>(); - - fields.add(new SearchField(ID_FIELD_NAME, SearchFieldDataType.STRING).setKey(true) - .setFilterable(true) - .setSortable(true)); - fields.add(new SearchField(EMBEDDING_FIELD_NAME, SearchFieldDataType.collection(SearchFieldDataType.SINGLE)) - .setSearchable(true) - .setVectorSearchDimensions(dimensions) - // This must match a vector search configuration name. - .setVectorSearchProfileName(SPRING_AI_VECTOR_PROFILE)); - fields.add(new SearchField(CONTENT_FIELD_NAME, SearchFieldDataType.STRING).setSearchable(true) - .setFilterable(true)); - fields.add(new SearchField(METADATA_FIELD_NAME, SearchFieldDataType.STRING).setSearchable(true) - .setFilterable(true)); - - for (MetadataField filterableMetadataField : this.filterMetadataFields) { - fields.add(new SearchField(METADATA_FIELD_PREFIX + filterableMetadataField.name(), - filterableMetadataField.fieldType()) - .setSearchable(false) - .setFacetable(true)); - } - - SearchIndex searchIndex = new SearchIndex(this.indexName).setFields(fields) - // VectorSearch configuration is required for a vector field. The name used - // for the vector search algorithm configuration must match the configuration - // used by the search field used for vector search. - .setVectorSearch(new VectorSearch() - .setProfiles(Collections - .singletonList(new VectorSearchProfile(SPRING_AI_VECTOR_PROFILE, SPRING_AI_VECTOR_CONFIG))) - .setAlgorithms(Collections.singletonList(new HnswAlgorithmConfiguration(SPRING_AI_VECTOR_CONFIG) - .setParameters(new HnswParameters().setM(4) - .setEfConstruction(400) - .setEfSearch(1000) - .setMetric(VectorSearchAlgorithmMetric.COSINE))))); - - SearchIndex index = this.searchIndexClient.createOrUpdateIndex(searchIndex); - - logger.info("Created search index: " + index.getName()); - - this.searchClient = this.searchIndexClient.getSearchClient(this.indexName); - } - public record MetadataField(String name, SearchFieldDataType fieldType) { public static MetadataField text(String name) { @@ -375,4 +325,51 @@ private List toFloatList(List doubleList) { private record AzureSearchDocument(String id, String content, List embedding, String metadata) { } -} + @Override + public void afterPropertiesSet() throws Exception { + + int dimensions = this.embeddingClient.dimensions(); + + List fields = new ArrayList<>(); + + fields.add(new SearchField(ID_FIELD_NAME, SearchFieldDataType.STRING).setKey(true) + .setFilterable(true) + .setSortable(true)); + fields.add(new SearchField(EMBEDDING_FIELD_NAME, SearchFieldDataType.collection(SearchFieldDataType.SINGLE)) + .setSearchable(true) + .setVectorSearchDimensions(dimensions) + // This must match a vector search configuration name. + .setVectorSearchProfileName(SPRING_AI_VECTOR_PROFILE)); + fields.add(new SearchField(CONTENT_FIELD_NAME, SearchFieldDataType.STRING).setSearchable(true) + .setFilterable(true)); + fields.add(new SearchField(METADATA_FIELD_NAME, SearchFieldDataType.STRING).setSearchable(true) + .setFilterable(true)); + + for (MetadataField filterableMetadataField : this.filterMetadataFields) { + fields.add(new SearchField(METADATA_FIELD_PREFIX + filterableMetadataField.name(), + filterableMetadataField.fieldType()) + .setSearchable(false) + .setFacetable(true)); + } + + SearchIndex searchIndex = new SearchIndex(this.indexName).setFields(fields) + // VectorSearch configuration is required for a vector field. The name used + // for the vector search algorithm configuration must match the configuration + // used by the search field used for vector search. + .setVectorSearch(new VectorSearch() + .setProfiles(Collections + .singletonList(new VectorSearchProfile(SPRING_AI_VECTOR_PROFILE, SPRING_AI_VECTOR_CONFIG))) + .setAlgorithms(Collections.singletonList(new HnswAlgorithmConfiguration(SPRING_AI_VECTOR_CONFIG) + .setParameters(new HnswParameters().setM(4) + .setEfConstruction(400) + .setEfSearch(1000) + .setMetric(VectorSearchAlgorithmMetric.COSINE))))); + + SearchIndex index = this.searchIndexClient.createOrUpdateIndex(searchIndex); + + logger.info("Created search index: " + index.getName()); + + this.searchClient = this.searchIndexClient.getSearchClient(this.indexName); + } + +} \ No newline at end of file diff --git a/vector-stores/spring-ai-cassandra/README.md b/vector-stores/spring-ai-cassandra-store/README.md similarity index 100% rename from vector-stores/spring-ai-cassandra/README.md rename to vector-stores/spring-ai-cassandra-store/README.md diff --git a/vector-stores/spring-ai-cassandra/pom.xml b/vector-stores/spring-ai-cassandra-store/pom.xml similarity index 97% rename from vector-stores/spring-ai-cassandra/pom.xml rename to vector-stores/spring-ai-cassandra-store/pom.xml index b01d200f858..fad401cfd57 100644 --- a/vector-stores/spring-ai-cassandra/pom.xml +++ b/vector-stores/spring-ai-cassandra-store/pom.xml @@ -8,7 +8,7 @@ 1.0.0-SNAPSHOT ../../pom.xml - spring-ai-cassandra + spring-ai-cassandra-store jar Spring AI Vector Store – Apache Cassandra Spring AI Vector Store for Apache Cassandra diff --git a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverter.java b/vector-stores/spring-ai-cassandra-store/src/main/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverter.java similarity index 100% rename from vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverter.java rename to vector-stores/spring-ai-cassandra-store/src/main/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverter.java diff --git a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java b/vector-stores/spring-ai-cassandra-store/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java similarity index 100% rename from vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java rename to vector-stores/spring-ai-cassandra-store/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStore.java diff --git a/vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java b/vector-stores/spring-ai-cassandra-store/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java similarity index 100% rename from vector-stores/spring-ai-cassandra/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java rename to vector-stores/spring-ai-cassandra-store/src/main/java/org/springframework/ai/vectorstore/CassandraVectorStoreConfig.java diff --git a/vector-stores/spring-ai-cassandra/src/main/resources/application.conf b/vector-stores/spring-ai-cassandra-store/src/main/resources/application.conf similarity index 100% rename from vector-stores/spring-ai-cassandra/src/main/resources/application.conf rename to vector-stores/spring-ai-cassandra-store/src/main/resources/application.conf diff --git a/vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverterTests.java b/vector-stores/spring-ai-cassandra-store/src/test/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverterTests.java similarity index 100% rename from vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverterTests.java rename to vector-stores/spring-ai-cassandra-store/src/test/java/org/springframework/ai/vectorstore/CassandraFilterExpressionConverterTests.java diff --git a/vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/CassandraRichSchemaVectorStoreIT.java b/vector-stores/spring-ai-cassandra-store/src/test/java/org/springframework/ai/vectorstore/CassandraRichSchemaVectorStoreIT.java similarity index 100% rename from vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/CassandraRichSchemaVectorStoreIT.java rename to vector-stores/spring-ai-cassandra-store/src/test/java/org/springframework/ai/vectorstore/CassandraRichSchemaVectorStoreIT.java diff --git a/vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/CassandraVectorStoreIT.java b/vector-stores/spring-ai-cassandra-store/src/test/java/org/springframework/ai/vectorstore/CassandraVectorStoreIT.java similarity index 100% rename from vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/CassandraVectorStoreIT.java rename to vector-stores/spring-ai-cassandra-store/src/test/java/org/springframework/ai/vectorstore/CassandraVectorStoreIT.java diff --git a/vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/WikiVectorStoreExample.java b/vector-stores/spring-ai-cassandra-store/src/test/java/org/springframework/ai/vectorstore/WikiVectorStoreExample.java similarity index 100% rename from vector-stores/spring-ai-cassandra/src/test/java/org/springframework/ai/vectorstore/WikiVectorStoreExample.java rename to vector-stores/spring-ai-cassandra-store/src/test/java/org/springframework/ai/vectorstore/WikiVectorStoreExample.java diff --git a/vector-stores/spring-ai-cassandra/src/test/resources/application.conf b/vector-stores/spring-ai-cassandra-store/src/test/resources/application.conf similarity index 100% rename from vector-stores/spring-ai-cassandra/src/test/resources/application.conf rename to vector-stores/spring-ai-cassandra-store/src/test/resources/application.conf diff --git a/vector-stores/spring-ai-cassandra/src/test/resources/test_wiki_full_schema.cql b/vector-stores/spring-ai-cassandra-store/src/test/resources/test_wiki_full_schema.cql similarity index 100% rename from vector-stores/spring-ai-cassandra/src/test/resources/test_wiki_full_schema.cql rename to vector-stores/spring-ai-cassandra-store/src/test/resources/test_wiki_full_schema.cql diff --git a/vector-stores/spring-ai-cassandra/src/test/resources/test_wiki_partial_0_schema.cql b/vector-stores/spring-ai-cassandra-store/src/test/resources/test_wiki_partial_0_schema.cql similarity index 100% rename from vector-stores/spring-ai-cassandra/src/test/resources/test_wiki_partial_0_schema.cql rename to vector-stores/spring-ai-cassandra-store/src/test/resources/test_wiki_partial_0_schema.cql diff --git a/vector-stores/spring-ai-cassandra/src/test/resources/test_wiki_partial_1_schema.cql b/vector-stores/spring-ai-cassandra-store/src/test/resources/test_wiki_partial_1_schema.cql similarity index 100% rename from vector-stores/spring-ai-cassandra/src/test/resources/test_wiki_partial_1_schema.cql rename to vector-stores/spring-ai-cassandra-store/src/test/resources/test_wiki_partial_1_schema.cql diff --git a/vector-stores/spring-ai-cassandra/src/test/resources/test_wiki_partial_2_schema.cql b/vector-stores/spring-ai-cassandra-store/src/test/resources/test_wiki_partial_2_schema.cql similarity index 100% rename from vector-stores/spring-ai-cassandra/src/test/resources/test_wiki_partial_2_schema.cql rename to vector-stores/spring-ai-cassandra-store/src/test/resources/test_wiki_partial_2_schema.cql diff --git a/vector-stores/spring-ai-cassandra/src/test/resources/test_wiki_partial_3_schema.cql b/vector-stores/spring-ai-cassandra-store/src/test/resources/test_wiki_partial_3_schema.cql similarity index 100% rename from vector-stores/spring-ai-cassandra/src/test/resources/test_wiki_partial_3_schema.cql rename to vector-stores/spring-ai-cassandra-store/src/test/resources/test_wiki_partial_3_schema.cql diff --git a/vector-stores/spring-ai-cassandra/src/test/resources/test_wiki_partial_4_schema.cql b/vector-stores/spring-ai-cassandra-store/src/test/resources/test_wiki_partial_4_schema.cql similarity index 100% rename from vector-stores/spring-ai-cassandra/src/test/resources/test_wiki_partial_4_schema.cql rename to vector-stores/spring-ai-cassandra-store/src/test/resources/test_wiki_partial_4_schema.cql diff --git a/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java b/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java index a53d02e9aca..40ede3a67ee 100644 --- a/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java +++ b/vector-stores/spring-ai-chroma-store/src/main/java/org/springframework/ai/vectorstore/ChromaVectorStore.java @@ -29,9 +29,7 @@ import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; import org.springframework.ai.vectorstore.filter.converter.ChromaFilterExpressionConverter; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.ApplicationListener; -import org.springframework.lang.NonNull; +import org.springframework.beans.factory.InitializingBean; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; @@ -42,10 +40,8 @@ * their similarity to a query, using the {@link ChromaApi} and {@link EmbeddingClient} * for embedding calculations. For more information about how it does this, see the * official Chroma website. - * - * @author Christian Tzolov */ -public class ChromaVectorStore implements VectorStore, ApplicationListener { +public class ChromaVectorStore implements VectorStore, InitializingBean { public static final String DISTANCE_FIELD_NAME = "distance"; @@ -151,7 +147,7 @@ public List similaritySearch(SearchRequest request) { } @Override - public void onApplicationEvent(@NonNull ApplicationReadyEvent event) { + public void afterPropertiesSet() throws Exception { var collection = this.chromaApi.getCollection(this.collectionName); if (collection == null) { collection = this.chromaApi.createCollection(new ChromaApi.CreateCollectionRequest(this.collectionName)); @@ -159,4 +155,4 @@ public void onApplicationEvent(@NonNull ApplicationReadyEvent event) { this.collectionId = collection.id(); } -} +} \ No newline at end of file diff --git a/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/ElasticsearchVectorStore.java b/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/ElasticsearchVectorStore.java index cff3a8efdfe..ebc01eae5fe 100644 --- a/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/ElasticsearchVectorStore.java +++ b/vector-stores/spring-ai-elasticsearch-store/src/main/java/org/springframework/ai/vectorstore/ElasticsearchVectorStore.java @@ -37,8 +37,7 @@ import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.vectorstore.filter.Filter; import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.ApplicationListener; +import org.springframework.beans.factory.InitializingBean; import org.springframework.util.Assert; import java.io.IOException; @@ -50,10 +49,9 @@ /** * @author Jemin Huh * @author Wei Jiang - * @author Josh Long * @since 1.0.0 */ -public class ElasticsearchVectorStore implements VectorStore, ApplicationListener { +public class ElasticsearchVectorStore implements VectorStore, InitializingBean { // divided by 2 to get score in the range [0, 1] public static final String COSINE_SIMILARITY_FUNCTION = "(cosineSimilarity(params.query_vector, 'embedding') + 1.0) / 2"; @@ -221,10 +219,10 @@ private CreateIndexResponse createIndexMapping() { } @Override - public void onApplicationEvent(ApplicationReadyEvent event) { + public void afterPropertiesSet() { if (!indexExists()) { createIndexMapping(); } } -} +} \ No newline at end of file diff --git a/vector-stores/spring-ai-gemfire/README.md b/vector-stores/spring-ai-gemfire-store/README.md similarity index 100% rename from vector-stores/spring-ai-gemfire/README.md rename to vector-stores/spring-ai-gemfire-store/README.md diff --git a/vector-stores/spring-ai-gemfire/pom.xml b/vector-stores/spring-ai-gemfire-store/pom.xml similarity index 98% rename from vector-stores/spring-ai-gemfire/pom.xml rename to vector-stores/spring-ai-gemfire-store/pom.xml index 8b4e6407b70..45f7e71637e 100644 --- a/vector-stores/spring-ai-gemfire/pom.xml +++ b/vector-stores/spring-ai-gemfire-store/pom.xml @@ -8,7 +8,7 @@ 1.0.0-SNAPSHOT ../../pom.xml - spring-ai-gemfire + spring-ai-gemfire-store jar Spring AI Vector Store - GemFire Spring AI GemFire Vector Store diff --git a/vector-stores/spring-ai-gemfire/src/main/java/org/springframework/ai/vectorstore/GemFireVectorStore.java b/vector-stores/spring-ai-gemfire-store/src/main/java/org/springframework/ai/vectorstore/GemFireVectorStore.java similarity index 100% rename from vector-stores/spring-ai-gemfire/src/main/java/org/springframework/ai/vectorstore/GemFireVectorStore.java rename to vector-stores/spring-ai-gemfire-store/src/main/java/org/springframework/ai/vectorstore/GemFireVectorStore.java diff --git a/vector-stores/spring-ai-gemfire/src/test/java/org/springframework/ai/vectorstore/GemFireVectorStoreIT.java b/vector-stores/spring-ai-gemfire-store/src/test/java/org/springframework/ai/vectorstore/GemFireVectorStoreIT.java similarity index 100% rename from vector-stores/spring-ai-gemfire/src/test/java/org/springframework/ai/vectorstore/GemFireVectorStoreIT.java rename to vector-stores/spring-ai-gemfire-store/src/test/java/org/springframework/ai/vectorstore/GemFireVectorStoreIT.java diff --git a/vector-stores/spring-ai-milvus-store/src/main/java/org/springframework/ai/vectorstore/MilvusVectorStore.java b/vector-stores/spring-ai-milvus-store/src/main/java/org/springframework/ai/vectorstore/MilvusVectorStore.java index b86438b6cd6..0a6004a1c0d 100644 --- a/vector-stores/spring-ai-milvus-store/src/main/java/org/springframework/ai/vectorstore/MilvusVectorStore.java +++ b/vector-stores/spring-ai-milvus-store/src/main/java/org/springframework/ai/vectorstore/MilvusVectorStore.java @@ -55,16 +55,14 @@ import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; import org.springframework.ai.vectorstore.filter.converter.MilvusFilterExpressionConverter; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.ApplicationListener; +import org.springframework.beans.factory.InitializingBean; import org.springframework.util.Assert; import org.springframework.util.StringUtils; /** * @author Christian Tzolov - * @author Josh Long */ -public class MilvusVectorStore implements VectorStore, ApplicationListener { +public class MilvusVectorStore implements VectorStore, InitializingBean { private static final Logger logger = LoggerFactory.getLogger(MilvusVectorStore.class); @@ -98,11 +96,6 @@ public class MilvusVectorStore implements VectorStore, ApplicationListener toFloatList(List embeddingDouble) { // --------------------------------------------------------------------------------- // Initialization // --------------------------------------------------------------------------------- + @Override + public void afterPropertiesSet() throws Exception { + this.createCollection(); + } void releaseCollection() { if (isDatabaseCollectionExists()) { diff --git a/vector-stores/spring-ai-mongodb-atlas-store/src/main/java/org/springframework/ai/vectorstore/MongoDBAtlasVectorStore.java b/vector-stores/spring-ai-mongodb-atlas-store/src/main/java/org/springframework/ai/vectorstore/MongoDBAtlasVectorStore.java index 231c196b729..0fbb099ce1d 100644 --- a/vector-stores/spring-ai-mongodb-atlas-store/src/main/java/org/springframework/ai/vectorstore/MongoDBAtlasVectorStore.java +++ b/vector-stores/spring-ai-mongodb-atlas-store/src/main/java/org/springframework/ai/vectorstore/MongoDBAtlasVectorStore.java @@ -25,8 +25,7 @@ import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingClient; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.ApplicationListener; +import org.springframework.beans.factory.InitializingBean; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.aggregation.Aggregation; import org.springframework.data.mongodb.core.query.Criteria; @@ -37,10 +36,9 @@ /** * @author Chris Smith - * @author Josh Long * @since 1.0.0 */ -public class MongoDBAtlasVectorStore implements VectorStore, ApplicationListener { +public class MongoDBAtlasVectorStore implements VectorStore, InitializingBean { public static final String ID_FIELD_NAME = "_id"; @@ -78,6 +76,16 @@ public MongoDBAtlasVectorStore(MongoTemplate mongoTemplate, EmbeddingClient embe } + @Override + public void afterPropertiesSet() throws Exception { + // Create the collection if it does not exist + if (!mongoTemplate.collectionExists(this.config.collectionName)) { + mongoTemplate.createCollection(this.config.collectionName); + } + // Create search index, command doesn't do anything if already existing + mongoTemplate.executeCommand(createSearchIndex()); + } + /** * Provides the Definition for the search index */ @@ -166,16 +174,6 @@ public List similaritySearch(SearchRequest request) { .toList(); } - @Override - public void onApplicationEvent(ApplicationReadyEvent event) { - // Create the collection if it does not exist - if (!mongoTemplate.collectionExists(this.config.collectionName)) { - mongoTemplate.createCollection(this.config.collectionName); - } - // Create search index, command doesn't do anything if already existing - mongoTemplate.executeCommand(createSearchIndex()); - } - public static class MongoDBVectorStoreConfig { private final String collectionName; diff --git a/vector-stores/spring-ai-neo4j-store/src/main/java/org/springframework/ai/vectorstore/Neo4jVectorStore.java b/vector-stores/spring-ai-neo4j-store/src/main/java/org/springframework/ai/vectorstore/Neo4jVectorStore.java index 8b5b3024f4d..8ac8fd204ce 100644 --- a/vector-stores/spring-ai-neo4j-store/src/main/java/org/springframework/ai/vectorstore/Neo4jVectorStore.java +++ b/vector-stores/spring-ai-neo4j-store/src/main/java/org/springframework/ai/vectorstore/Neo4jVectorStore.java @@ -22,8 +22,7 @@ import org.springframework.ai.document.Document; import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.vectorstore.filter.Neo4jVectorFilterExpressionConverter; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.ApplicationListener; +import org.springframework.beans.factory.InitializingBean; import org.springframework.util.Assert; import java.util.HashMap; @@ -35,9 +34,8 @@ /** * @author Gerrit Meier * @author Michael Simons - * @author Josh Long */ -public class Neo4jVectorStore implements VectorStore, ApplicationListener { +public class Neo4jVectorStore implements VectorStore, InitializingBean { /** * An enum to configure the distance function used in the Neo4j vector index. @@ -72,7 +70,6 @@ public static final class Neo4jVectorStoreConfig { private final String indexName; // needed for similarity search call - private final String indexNameNotSanitized; private final String idProperty; @@ -256,29 +253,6 @@ public Neo4jVectorStoreConfig build() { } - @Override - public void onApplicationEvent(ApplicationReadyEvent event) { - - try (var session = this.driver.session(this.config.sessionConfig)) { - - session - .run("CREATE CONSTRAINT %s IF NOT EXISTS FOR (n:%s) REQUIRE n.%s IS UNIQUE" - .formatted(this.config.constraintName, this.config.label, this.config.idProperty)) - .consume(); - - var statement = """ - CREATE VECTOR INDEX %s IF NOT EXISTS FOR (n:%s) ON (n.%s) - OPTIONS {indexConfig: { - `vector.dimensions`: %d, - `vector.similarity_function`: '%s' - }} - """.formatted(this.config.indexName, this.config.label, this.config.embeddingProperty, - this.config.embeddingDimension, this.config.distanceType.name); - session.run(statement).consume(); - session.run("CALL db.awaitIndexes()").consume(); - } - } - public static final int DEFAULT_EMBEDDING_DIMENSION = 1536; public static final String DEFAULT_LABEL = "Document"; @@ -374,6 +348,29 @@ public List similaritySearch(SearchRequest request) { } } + @Override + public void afterPropertiesSet() { + + try (var session = this.driver.session(this.config.sessionConfig)) { + + session + .run("CREATE CONSTRAINT %s IF NOT EXISTS FOR (n:%s) REQUIRE n.%s IS UNIQUE" + .formatted(this.config.constraintName, this.config.label, this.config.idProperty)) + .consume(); + + var statement = """ + CREATE VECTOR INDEX %s IF NOT EXISTS FOR (n:%s) ON (n.%s) + OPTIONS {indexConfig: { + `vector.dimensions`: %d, + `vector.similarity_function`: '%s' + }} + """.formatted(this.config.indexName, this.config.label, this.config.embeddingProperty, + this.config.embeddingDimension, this.config.distanceType.name); + session.run(statement).consume(); + session.run("CALL db.awaitIndexes()").consume(); + } + } + private Map documentToRecord(Document document) { var embedding = this.embeddingClient.embed(document); document.setEmbedding(embedding); @@ -416,4 +413,4 @@ private Document recordToDocument(org.neo4j.driver.Record neoRecord) { Map.copyOf(metaData)); } -} +} \ No newline at end of file diff --git a/vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/PgVectorStore.java b/vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/PgVectorStore.java index 2eb9eefeef6..c2b7954d900 100644 --- a/vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/PgVectorStore.java +++ b/vector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/PgVectorStore.java @@ -35,8 +35,7 @@ import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; import org.springframework.ai.vectorstore.filter.converter.PgVectorFilterExpressionConverter; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.ApplicationListener; +import org.springframework.beans.factory.InitializingBean; import org.springframework.jdbc.core.BatchPreparedStatementSetter; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; @@ -50,9 +49,8 @@ * vector index will be auto-created if not available. * * @author Christian Tzolov - * @author Josh Long */ -public class PgVectorStore implements VectorStore, ApplicationListener { +public class PgVectorStore implements VectorStore, InitializingBean { private static final Logger logger = LoggerFactory.getLogger(PgVectorStore.class); @@ -80,40 +78,6 @@ public class PgVectorStore implements VectorStore, ApplicationListener1.0.0-SNAPSHOT ../../pom.xml - spring-ai-pinecone + spring-ai-pinecone-store jar Spring AI Vector Store - Pinecone https://github.com/spring-projects/spring-ai diff --git a/vector-stores/spring-ai-pinecone/src/main/java/org/springframework/ai/vectorstore/PineconeVectorStore.java b/vector-stores/spring-ai-pinecone-store/src/main/java/org/springframework/ai/vectorstore/PineconeVectorStore.java similarity index 100% rename from vector-stores/spring-ai-pinecone/src/main/java/org/springframework/ai/vectorstore/PineconeVectorStore.java rename to vector-stores/spring-ai-pinecone-store/src/main/java/org/springframework/ai/vectorstore/PineconeVectorStore.java diff --git a/vector-stores/spring-ai-pinecone/src/test/java/org/springframework/ai/vectorstore/PineconeVectorStoreIT.java b/vector-stores/spring-ai-pinecone-store/src/test/java/org/springframework/ai/vectorstore/PineconeVectorStoreIT.java similarity index 100% rename from vector-stores/spring-ai-pinecone/src/test/java/org/springframework/ai/vectorstore/PineconeVectorStoreIT.java rename to vector-stores/spring-ai-pinecone-store/src/test/java/org/springframework/ai/vectorstore/PineconeVectorStoreIT.java diff --git a/vector-stores/spring-ai-qdrant/README.md b/vector-stores/spring-ai-qdrant-store/README.md similarity index 100% rename from vector-stores/spring-ai-qdrant/README.md rename to vector-stores/spring-ai-qdrant-store/README.md diff --git a/vector-stores/spring-ai-qdrant/pom.xml b/vector-stores/spring-ai-qdrant-store/pom.xml similarity index 98% rename from vector-stores/spring-ai-qdrant/pom.xml rename to vector-stores/spring-ai-qdrant-store/pom.xml index 77ec45ffa96..df834e266fc 100644 --- a/vector-stores/spring-ai-qdrant/pom.xml +++ b/vector-stores/spring-ai-qdrant-store/pom.xml @@ -8,7 +8,7 @@ 1.0.0-SNAPSHOT ../../pom.xml - spring-ai-qdrant + spring-ai-qdrant-store jar Spring AI Vector Store - QDrant diff --git a/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantFilterExpressionConverter.java b/vector-stores/spring-ai-qdrant-store/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantFilterExpressionConverter.java similarity index 100% rename from vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantFilterExpressionConverter.java rename to vector-stores/spring-ai-qdrant-store/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantFilterExpressionConverter.java diff --git a/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantObjectFactory.java b/vector-stores/spring-ai-qdrant-store/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantObjectFactory.java similarity index 100% rename from vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantObjectFactory.java rename to vector-stores/spring-ai-qdrant-store/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantObjectFactory.java diff --git a/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantValueFactory.java b/vector-stores/spring-ai-qdrant-store/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantValueFactory.java similarity index 100% rename from vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantValueFactory.java rename to vector-stores/spring-ai-qdrant-store/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantValueFactory.java diff --git a/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java b/vector-stores/spring-ai-qdrant-store/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java similarity index 95% rename from vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java rename to vector-stores/spring-ai-qdrant-store/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java index 607a1ecb7a5..f9d3a250caf 100644 --- a/vector-stores/spring-ai-qdrant/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java +++ b/vector-stores/spring-ai-qdrant-store/src/main/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStore.java @@ -15,12 +15,24 @@ */ package org.springframework.ai.vectorstore.qdrant; +import static io.qdrant.client.PointIdFactory.id; +import static io.qdrant.client.ValueFactory.value; +import static io.qdrant.client.VectorsFactory.vectors; +import static io.qdrant.client.WithPayloadSelectorFactory.enable; + import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.concurrent.ExecutionException; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.util.Assert; + import io.qdrant.client.QdrantClient; import io.qdrant.client.grpc.Collections.Distance; import io.qdrant.client.grpc.Collections.VectorParams; @@ -32,19 +44,6 @@ import io.qdrant.client.grpc.Points.SearchPoints; import io.qdrant.client.grpc.Points.UpdateStatus; -import org.springframework.ai.document.Document; -import org.springframework.ai.embedding.EmbeddingClient; -import org.springframework.ai.vectorstore.SearchRequest; -import org.springframework.ai.vectorstore.VectorStore; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.ApplicationListener; -import org.springframework.util.Assert; - -import static io.qdrant.client.PointIdFactory.id; -import static io.qdrant.client.ValueFactory.value; -import static io.qdrant.client.VectorsFactory.vectors; -import static io.qdrant.client.WithPayloadSelectorFactory.enable; - /** * Qdrant vectorStore implementation. This store supports creating, updating, deleting, * and similarity searching of documents in a Qdrant collection. @@ -52,10 +51,9 @@ * @author Anush Shetty * @author Christian Tzolov * @author Eddú Meléndez - * @author Josh Long * @since 0.8.1 */ -public class QdrantVectorStore implements VectorStore, ApplicationListener { +public class QdrantVectorStore implements VectorStore, InitializingBean { private static final String CONTENT_FIELD_NAME = "doc_content"; @@ -86,7 +84,6 @@ public static final class QdrantVectorStoreConfig { * * @param builder The configuration builder. */ - private QdrantVectorStoreConfig(Builder builder) { this.collectionName = builder.collectionName; } @@ -161,24 +158,6 @@ public QdrantVectorStore(QdrantClient qdrantClient, String collectionName, Embed this.qdrantClient = qdrantClient; } - @Override - public void onApplicationEvent(ApplicationReadyEvent event) { - - // Create the collection if it does not exist. - if (!isCollectionExists()) { - var vectorParams = VectorParams.newBuilder() - .setDistance(Distance.Cosine) - .setSize(this.embeddingClient.dimensions()) - .build(); - try { - this.qdrantClient.createCollectionAsync(this.collectionName, vectorParams).get(); - } // - catch (Exception e) { - throw new RuntimeException(e); - } - } - } - /** * Adds a list of documents to the vector store. * @param documents The list of documents to be added. @@ -261,6 +240,7 @@ public List similaritySearch(SearchRequest request) { /** * Extracts metadata from a Protobuf Struct. + * @param metadataStruct The Protobuf Struct containing metadata. * @return The metadata as a map. */ private Document toDocument(ScoredPoint point) { @@ -304,6 +284,18 @@ private List toFloatList(List doubleList) { return doubleList.stream().map(d -> d.floatValue()).toList(); } + @Override + public void afterPropertiesSet() throws Exception { + // Create the collection if it does not exist. + if (!isCollectionExists()) { + var vectorParams = VectorParams.newBuilder() + .setDistance(Distance.Cosine) + .setSize(this.embeddingClient.dimensions()) + .build(); + this.qdrantClient.createCollectionAsync(this.collectionName, vectorParams).get(); + } + } + private boolean isCollectionExists() { try { return this.qdrantClient.listCollectionsAsync().get().stream().anyMatch(c -> c.equals(this.collectionName)); @@ -313,4 +305,4 @@ private boolean isCollectionExists() { } } -} +} \ No newline at end of file diff --git a/vector-stores/spring-ai-qdrant/src/test/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStoreIT.java b/vector-stores/spring-ai-qdrant-store/src/test/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStoreIT.java similarity index 100% rename from vector-stores/spring-ai-qdrant/src/test/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStoreIT.java rename to vector-stores/spring-ai-qdrant-store/src/test/java/org/springframework/ai/vectorstore/qdrant/QdrantVectorStoreIT.java diff --git a/vector-stores/spring-ai-redis/README.md b/vector-stores/spring-ai-redis-store/README.md similarity index 100% rename from vector-stores/spring-ai-redis/README.md rename to vector-stores/spring-ai-redis-store/README.md diff --git a/vector-stores/spring-ai-redis/pom.xml b/vector-stores/spring-ai-redis-store/pom.xml similarity index 98% rename from vector-stores/spring-ai-redis/pom.xml rename to vector-stores/spring-ai-redis-store/pom.xml index 2d66f35c732..729ba0fe8ba 100644 --- a/vector-stores/spring-ai-redis/pom.xml +++ b/vector-stores/spring-ai-redis-store/pom.xml @@ -8,7 +8,7 @@ 1.0.0-SNAPSHOT ../../pom.xml - spring-ai-redis + spring-ai-redis-store jar Spring AI Vector Store - Redis Spring AI Vector Store - Redis diff --git a/vector-stores/spring-ai-redis/src/main/java/org/springframework/ai/vectorstore/RedisFilterExpressionConverter.java b/vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/RedisFilterExpressionConverter.java similarity index 100% rename from vector-stores/spring-ai-redis/src/main/java/org/springframework/ai/vectorstore/RedisFilterExpressionConverter.java rename to vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/RedisFilterExpressionConverter.java diff --git a/vector-stores/spring-ai-redis/src/main/java/org/springframework/ai/vectorstore/RedisVectorStore.java b/vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/RedisVectorStore.java similarity index 97% rename from vector-stores/spring-ai-redis/src/main/java/org/springframework/ai/vectorstore/RedisVectorStore.java rename to vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/RedisVectorStore.java index 47982bf9281..cb34e3b8be6 100644 --- a/vector-stores/spring-ai-redis/src/main/java/org/springframework/ai/vectorstore/RedisVectorStore.java +++ b/vector-stores/spring-ai-redis-store/src/main/java/org/springframework/ai/vectorstore/RedisVectorStore.java @@ -28,6 +28,13 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.EmbeddingClient; +import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + import redis.clients.jedis.JedisPooled; import redis.clients.jedis.Pipeline; import redis.clients.jedis.json.Path2; @@ -44,14 +51,6 @@ import redis.clients.jedis.search.schemafields.VectorField; import redis.clients.jedis.search.schemafields.VectorField.VectorAlgorithm; -import org.springframework.ai.document.Document; -import org.springframework.ai.embedding.EmbeddingClient; -import org.springframework.ai.vectorstore.filter.FilterExpressionConverter; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.ApplicationListener; -import org.springframework.util.Assert; -import org.springframework.util.CollectionUtils; - /** * The RedisVectorStore is for managing and querying vector data in a Redis database. It * offers functionalities like adding, deleting, and performing similarity searches on @@ -69,27 +68,11 @@ * * @author Julien Ruaux * @author Christian Tzolov - * @author Josh Long * @see VectorStore * @see RedisVectorStoreConfig * @see EmbeddingClient */ -public class RedisVectorStore implements VectorStore, ApplicationListener { - - @Override - public void onApplicationEvent(ApplicationReadyEvent event) { - // If index already exists don't do anything - if (this.jedis.ftList().contains(this.config.indexName)) { - return; - } - - String response = this.jedis.ftCreate(this.config.indexName, - FTCreateParams.createParams().on(IndexDataType.JSON).addPrefix(this.config.prefix), schemaFields()); - if (!RESPONSE_OK.test(response)) { - String message = MessageFormat.format("Could not create index: {0}", response); - throw new RuntimeException(message); - } - } +public class RedisVectorStore implements VectorStore, InitializingBean { public enum Algorithm { @@ -419,6 +402,22 @@ private String nativeExpressionFilter(SearchRequest request) { return "(" + this.filterExpressionConverter.convertExpression(request.getFilterExpression()) + ")"; } + @Override + public void afterPropertiesSet() { + + // If index already exists don't do anything + if (this.jedis.ftList().contains(this.config.indexName)) { + return; + } + + String response = this.jedis.ftCreate(this.config.indexName, + FTCreateParams.createParams().on(IndexDataType.JSON).addPrefix(this.config.prefix), schemaFields()); + if (!RESPONSE_OK.test(response)) { + String message = MessageFormat.format("Could not create index: {0}", response); + throw new RuntimeException(message); + } + } + private Iterable schemaFields() { Map vectorAttrs = new HashMap<>(); vectorAttrs.put("DIM", this.embeddingClient.dimensions()); @@ -476,4 +475,4 @@ private static float[] toFloatArray(List embeddingDouble) { return embeddingFloat; } -} +} \ No newline at end of file diff --git a/vector-stores/spring-ai-redis/src/test/java/org/springframework/ai/vectorstore/RedisFilterExpressionConverterTests.java b/vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/RedisFilterExpressionConverterTests.java similarity index 100% rename from vector-stores/spring-ai-redis/src/test/java/org/springframework/ai/vectorstore/RedisFilterExpressionConverterTests.java rename to vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/RedisFilterExpressionConverterTests.java diff --git a/vector-stores/spring-ai-redis/src/test/java/org/springframework/ai/vectorstore/RedisVectorStoreIT.java b/vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/RedisVectorStoreIT.java similarity index 100% rename from vector-stores/spring-ai-redis/src/test/java/org/springframework/ai/vectorstore/RedisVectorStoreIT.java rename to vector-stores/spring-ai-redis-store/src/test/java/org/springframework/ai/vectorstore/RedisVectorStoreIT.java diff --git a/vector-stores/spring-ai-weaviate-store/src/main/java/org/springframework/ai/vectorstore/WeaviateVectorStore.java b/vector-stores/spring-ai-weaviate-store/src/main/java/org/springframework/ai/vectorstore/WeaviateVectorStore.java index 6e56e8e95f1..db676aaddcd 100644 --- a/vector-stores/spring-ai-weaviate-store/src/main/java/org/springframework/ai/vectorstore/WeaviateVectorStore.java +++ b/vector-stores/spring-ai-weaviate-store/src/main/java/org/springframework/ai/vectorstore/WeaviateVectorStore.java @@ -551,4 +551,4 @@ public void afterPropertiesSet() throws Exception { this.delete(List.of(document.getId())); } -} +} \ No newline at end of file From d02661431605f37e4adf2b27c2d65fa0ec9dd492 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Fri, 17 May 2024 17:31:48 +0200 Subject: [PATCH 30/39] Update README.md Breaking changes --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 6feaf43c51b..1b798a2c0a2 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,20 @@ Let's make your `@Beans` intelligent! For further information go to our [Spring AI reference documentation](https://docs.spring.io/spring-ai/reference/). +### Breadking changes +(15.05.2024) +On our march to release 1.0 M1 we have made several breaking changes. Apologies, it is for the best! + +Renamed POM artifact names: + - spring-ai-qdrant -> spring-ai-qdrant-store + - spring-ai-cassandra -> spring-ai-cassandra-store + - spring-ai-pinecone -> spring-ai-pinecone-store + - spring-ai-redis -> spring-ai-redis-store + - spring-ai-qdrant -> spring-ai-qdrant-store + - spring-ai-gemfire -> spring-ai-gemfire-store + - spring-ai-azure-vector-store-spring-boot-starter -> spring-ai-azure-store-spring-boot-starter + - spring-ai-redis-spring-boot-starter -> spring-ai-redis-store-spring-boot-starter + ## Project Links * [Documentation](https://docs.spring.io/spring-ai/reference/) From 517df45cd8782bfb75653167ae60be649dbf136e Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Sat, 18 May 2024 04:50:20 +0200 Subject: [PATCH 31/39] Merge ParametrizedTypeReferenceBeanOutputConverter into BeanOutputConverter Add ParametrizedTypeReference constructoers along the Clas such. Convert the Class into ParametrizedTypeReference internally. Update tests and docs. --- .../ai/openai/chat/OpenAiChatClientIT.java | 3 +- ...ntTypeReferenceBeanOutputConverterIT.java} | 10 +- ...exAiGeminiChatClientFunctionCallingIT.java | 10 +- .../ai/converter/BeanOutputConverter.java | 99 +++++---- ...meterizedTypeReferenceOutputConverter.java | 179 ----------------- .../ai/converter/BeanOutputConverterTest.java | 145 +++++++++++-- .../TypeReferenceOutputConverterTest.java | 190 ------------------ .../images/structured-output-hierarchy4.jpg | Bin 255213 -> 229437 bytes .../api/structured-output-converter.adoc | 27 ++- 9 files changed, 227 insertions(+), 436 deletions(-) rename models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/{OpenAiChatClientParametrizedTypeReferencefOutputConverterIT.java => OpenAiChatClientTypeReferenceBeanOutputConverterIT.java} (87%) delete mode 100644 spring-ai-core/src/main/java/org/springframework/ai/converter/ParameterizedTypeReferenceOutputConverter.java delete mode 100644 spring-ai-core/src/test/java/org/springframework/ai/converter/TypeReferenceOutputConverterTest.java diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java index 0366aa626d9..56ac4da9f73 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientIT.java @@ -298,7 +298,8 @@ void streamingMultiModalityImageUrl() throws IOException { .map(AssistantMessage::getContent) .collect(Collectors.joining()); logger.info("Response: {}", content); - assertThat(content).contains("bananas", "apple", "bowl"); + assertThat(content).contains("bananas", "apple"); + assertThat(content).containsAnyOf("bowl", "basket"); } } \ No newline at end of file diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientParametrizedTypeReferencefOutputConverterIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientTypeReferenceBeanOutputConverterIT.java similarity index 87% rename from models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientParametrizedTypeReferencefOutputConverterIT.java rename to models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientTypeReferenceBeanOutputConverterIT.java index 146715f34d4..4626af1fae1 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientParametrizedTypeReferencefOutputConverterIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatClientTypeReferenceBeanOutputConverterIT.java @@ -29,7 +29,7 @@ import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.PromptTemplate; -import org.springframework.ai.converter.ParameterizedTypeReferenceOutputConverter; +import org.springframework.ai.converter.BeanOutputConverter; import org.springframework.ai.openai.OpenAiTestConfiguration; import org.springframework.ai.openai.testutils.AbstractIT; import org.springframework.boot.test.context.SpringBootTest; @@ -39,10 +39,10 @@ @SpringBootTest(classes = OpenAiTestConfiguration.class) @EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") -class OpenAiChatClientParametrizedTypeReferencefOutputConverterIT extends AbstractIT { +class OpenAiChatClientTypeReferenceBeanOutputConverterIT extends AbstractIT { private static final Logger logger = LoggerFactory - .getLogger(OpenAiChatClientParametrizedTypeReferencefOutputConverterIT.class); + .getLogger(OpenAiChatClientTypeReferenceBeanOutputConverterIT.class); record ActorsFilmsRecord(String actor, List movies) { } @@ -50,7 +50,7 @@ record ActorsFilmsRecord(String actor, List movies) { @Test void typeRefOutputConverterRecords() { - ParameterizedTypeReferenceOutputConverter> outputConverter = new ParameterizedTypeReferenceOutputConverter<>( + BeanOutputConverter> outputConverter = new BeanOutputConverter<>( new ParameterizedTypeReference>() { }); @@ -75,7 +75,7 @@ void typeRefOutputConverterRecords() { @Test void typeRefStreamOutputConverterRecords() { - ParameterizedTypeReferenceOutputConverter> outputConverter = new ParameterizedTypeReferenceOutputConverter<>( + BeanOutputConverter> outputConverter = new BeanOutputConverter<>( new ParameterizedTypeReference>() { }); diff --git a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatClientFunctionCallingIT.java b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatClientFunctionCallingIT.java index d5209e82e81..11b85222259 100644 --- a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatClientFunctionCallingIT.java +++ b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatClientFunctionCallingIT.java @@ -68,7 +68,7 @@ public void afterEach() { } @Test - // @Disabled("Google Vertex AI degraded support for parallel function calls") + @Disabled("Google Vertex AI degraded support for parallel function calls") public void functionCallExplicitOpenApiSchema() { UserMessage userMessage = new UserMessage( @@ -98,8 +98,8 @@ public void functionCallExplicitOpenApiSchema() { """; var promptOptions = VertexAiGeminiChatOptions.builder() - // .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO) - .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO_1_5_PRO) + .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO) + // .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO_1_5_PRO) .withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService()) .withName("get_current_weather") .withDescription("Get the current weather in a given location") @@ -126,8 +126,8 @@ public void functionCallTestInferredOpenApiSchema() { List messages = new ArrayList<>(List.of(userMessage)); var promptOptions = VertexAiGeminiChatOptions.builder() - .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO_1_5_PRO) - // .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO.getValue()) + // .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO_1_5_PRO) + .withModel(VertexAiGeminiChatClient.ChatModel.GEMINI_PRO.getValue()) .withFunctionCallbacks(List.of( FunctionCallbackWrapper.builder(new MockWeatherService()) .withSchemaType(SchemaType.OPEN_API_SCHEMA) diff --git a/spring-ai-core/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java b/spring-ai-core/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java index a26b5e97bd6..aca2902b0bf 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java @@ -15,7 +15,10 @@ */ package org.springframework.ai.converter; +import java.util.Objects; + import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.util.DefaultIndenter; import com.fasterxml.jackson.core.util.DefaultPrettyPrinter; import com.fasterxml.jackson.databind.DeserializationFeature; @@ -27,10 +30,9 @@ import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; import com.github.victools.jsonschema.module.jackson.JacksonModule; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.lang.NonNull; - -import java.util.Map; -import java.util.Objects; +import java.lang.reflect.Type; import static com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON; import static com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12; @@ -38,23 +40,26 @@ /** * An implementation of {@link StructuredOutputConverter} that transforms the LLM output * to a specific object type using JSON schema. This parser works by generating a JSON - * schema based on a given Java class, which is then used to validate and transform the - * LLM output into the desired type. + * schema based on a given Java class or parameterized type reference, which is then used + * to validate and transform the LLM output into the desired type. * * @param The target type to which the output will be converted. * @author Mark Pollack * @author Christian Tzolov * @author Sebastian Ullrich * @author Kirk Lund + * @author Josh Long */ public class BeanOutputConverter implements StructuredOutputConverter { /** Holds the generated JSON schema for the target type. */ private String jsonSchema; - /** The Java class representing the target type. */ + /** + * The target class type reference to which the output will be converted. + */ @SuppressWarnings({ "FieldMayBeFinal", "rawtypes" }) - private Class clazz; + private TypeReference typeRef; /** The object mapper used for deserialization and other JSON operations. */ @SuppressWarnings("FieldMayBeFinal") @@ -64,8 +69,8 @@ public class BeanOutputConverter implements StructuredOutputConverter { * Constructor to initialize with the target type's class. * @param clazz The target type's class. */ - public BeanOutputConverter(Class clazz) { - this(clazz, null); + public BeanOutputConverter(Class typeClass) { + this(ParameterizedTypeReference.forType(typeClass)); } /** @@ -75,8 +80,53 @@ public BeanOutputConverter(Class clazz) { * @param objectMapper Custom object mapper for JSON operations. endings. */ public BeanOutputConverter(Class clazz, ObjectMapper objectMapper) { - Objects.requireNonNull(clazz, "Java Class cannot be null;"); - this.clazz = clazz; + this(ParameterizedTypeReference.forType(clazz), objectMapper); + } + + /** + * Constructor to initialize with the target class type reference. + * @param typeRef The target class type reference. + */ + public BeanOutputConverter(ParameterizedTypeReference typeRef) { + this(new CustomizedTypeReference<>(typeRef), null); + } + + /** + * Constructor to initialize with the target class type reference, a custom object + * mapper, and a line endings normalizer to ensure consistent line endings on any + * platform. + * @param typeRef The target class type reference. + * @param objectMapper Custom object mapper for JSON operations. endings. + */ + public BeanOutputConverter(ParameterizedTypeReference typeRef, ObjectMapper objectMapper) { + this(new CustomizedTypeReference<>(typeRef), objectMapper); + } + + private static class CustomizedTypeReference extends TypeReference { + + private final Type type; + + CustomizedTypeReference(ParameterizedTypeReference typeRef) { + this.type = typeRef.getType(); + } + + @Override + public Type getType() { + return this.type; + } + + } + + /** + * Constructor to initialize with the target class type reference, a custom object + * mapper, and a line endings normalizer to ensure consistent line endings on any + * platform. + * @param typeRef The target class type reference. + * @param objectMapper Custom object mapper for JSON operations. endings. + */ + private BeanOutputConverter(TypeReference typeRef, ObjectMapper objectMapper) { + Objects.requireNonNull(typeRef, "Type reference cannot be null;"); + this.typeRef = typeRef; this.objectMapper = objectMapper != null ? objectMapper : getObjectMapper(); generateSchema(); } @@ -90,14 +140,14 @@ private void generateSchema() { .with(jacksonModule); SchemaGeneratorConfig config = configBuilder.build(); SchemaGenerator generator = new SchemaGenerator(config); - JsonNode jsonNode = generator.generateSchema(this.clazz); + JsonNode jsonNode = generator.generateSchema(this.typeRef.getType()); ObjectWriter objectWriter = new ObjectMapper().writer(new DefaultPrettyPrinter() .withObjectIndenter(new DefaultIndenter().withLinefeed(System.lineSeparator()))); try { this.jsonSchema = objectWriter.writeValueAsString(jsonNode); } catch (JsonProcessingException e) { - throw new RuntimeException("Could not pretty print json schema for " + this.clazz, e); + throw new RuntimeException("Could not pretty print json schema for " + this.typeRef, e); } } @@ -109,34 +159,13 @@ private void generateSchema() { */ public T convert(@NonNull String text) { try { - // If the response is a JSON Schema, extract the properties and use them as - // the response. - text = this.jsonSchemaToInstance(text); - return (T) this.objectMapper.readValue(text, this.clazz); + return (T) this.objectMapper.readValue(text, this.typeRef); } catch (JsonProcessingException e) { throw new RuntimeException(e); } } - /** - * Converts a JSON Schema to an instance based on a given text. - * @param text The JSON Schema in string format. - * @return The JSON instance generated from the JSON Schema, or the original text if - * the input is not a JSON Schema. - */ - private String jsonSchemaToInstance(String text) { - try { - Map map = this.objectMapper.readValue(text, Map.class); - if (map.containsKey("$schema")) { - return this.objectMapper.writeValueAsString(map.get("properties")); - } - } - catch (Exception e) { - } - return text; - } - /** * Configures and returns an object mapper for JSON operations. * @return Configured object mapper. diff --git a/spring-ai-core/src/main/java/org/springframework/ai/converter/ParameterizedTypeReferenceOutputConverter.java b/spring-ai-core/src/main/java/org/springframework/ai/converter/ParameterizedTypeReferenceOutputConverter.java deleted file mode 100644 index a0b79261956..00000000000 --- a/spring-ai-core/src/main/java/org/springframework/ai/converter/ParameterizedTypeReferenceOutputConverter.java +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright 2023 - 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.converter; - -import java.util.Objects; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.core.util.DefaultIndenter; -import com.fasterxml.jackson.core.util.DefaultPrettyPrinter; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.ObjectWriter; -import com.github.victools.jsonschema.generator.SchemaGenerator; -import com.github.victools.jsonschema.generator.SchemaGeneratorConfig; -import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; -import com.github.victools.jsonschema.module.jackson.JacksonModule; - -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.lang.NonNull; -import java.lang.reflect.Type; - -import static com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON; -import static com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12; - -/** - * An implementation of {@link StructuredOutputConverter} that transforms the LLM output - * to a specific object type using JSON schema. This parser works by generating a JSON - * schema based on a given Java class type reference, which is then used to validate and - * transform the LLM output into the desired type. - * - * @param The target type to which the output will be converted. - * @author Mark Pollack - * @author Christian Tzolov - * @author Sebastian Ullrich - * @author Kirk Lund - * @author Josh Long - */ -public class ParameterizedTypeReferenceOutputConverter implements StructuredOutputConverter { - - /** Holds the generated JSON schema for the target type. */ - private String jsonSchema; - - /** - * The target class type reference to which the output will be converted. - */ - @SuppressWarnings({ "FieldMayBeFinal", "rawtypes" }) - private TypeReference typeRef; - - /** The object mapper used for deserialization and other JSON operations. */ - @SuppressWarnings("FieldMayBeFinal") - private ObjectMapper objectMapper; - - /** - * Constructor to initialize with the target class type reference. - * @param typeRef The target type's class. - */ - public ParameterizedTypeReferenceOutputConverter(ParameterizedTypeReference typeRef) { - this(new CustomizedTypeReference<>(typeRef), null); - } - - /** - * Constructor to initialize with the target class type reference, a custom object - * mapper, and a line endings normalizer to ensure consistent line endings on any - * platform. - * @param typeRef The target class type reference. - * @param objectMapper Custom object mapper for JSON operations. endings. - */ - public ParameterizedTypeReferenceOutputConverter(ParameterizedTypeReference typeRef, ObjectMapper objectMapper) { - this(new CustomizedTypeReference<>(typeRef), objectMapper); - } - - private static class CustomizedTypeReference extends TypeReference { - - private final Type type; - - CustomizedTypeReference(ParameterizedTypeReference typeRef) { - this.type = typeRef.getType(); - } - - @Override - public Type getType() { - return this.type; - } - - } - - /** - * Constructor to initialize with the target class type reference, a custom object - * mapper, and a line endings normalizer to ensure consistent line endings on any - * platform. - * @param typeRef The target class type reference. - * @param objectMapper Custom object mapper for JSON operations. endings. - */ - private ParameterizedTypeReferenceOutputConverter(TypeReference typeRef, ObjectMapper objectMapper) { - Objects.requireNonNull(typeRef, "Type reference cannot be null;"); - this.typeRef = typeRef; - this.objectMapper = objectMapper != null ? objectMapper : getObjectMapper(); - generateSchema(); - } - - /** - * Generates the JSON schema for the target type. - */ - private void generateSchema() { - JacksonModule jacksonModule = new JacksonModule(); - SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(DRAFT_2020_12, PLAIN_JSON) - .with(jacksonModule); - SchemaGeneratorConfig config = configBuilder.build(); - SchemaGenerator generator = new SchemaGenerator(config); - JsonNode jsonNode = generator.generateSchema(this.typeRef.getType()); - ObjectWriter objectWriter = new ObjectMapper().writer(new DefaultPrettyPrinter() - .withObjectIndenter(new DefaultIndenter().withLinefeed(System.lineSeparator()))); - try { - this.jsonSchema = objectWriter.writeValueAsString(jsonNode); - } - catch (JsonProcessingException e) { - throw new RuntimeException("Could not pretty print json schema for " + this.typeRef, e); - } - } - - @Override - /** - * Parses the given text to transform it to the desired target type. - * @param text The LLM output in string format. - * @return The parsed output in the desired target type. - */ - public T convert(@NonNull String text) { - try { - return (T) this.objectMapper.readValue(text, this.typeRef); - } - catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - } - - /** - * Configures and returns an object mapper for JSON operations. - * @return Configured object mapper. - */ - protected ObjectMapper getObjectMapper() { - ObjectMapper mapper = new ObjectMapper(); - mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - return mapper; - } - - /** - * Provides the expected format of the response, instructing that it should adhere to - * the generated JSON schema. - * @return The instruction format string. - */ - @Override - public String getFormat() { - String template = """ - Your response should be in JSON format. - Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation. - Do not include markdown code blocks in your response. - Remove the ```json markdown from the output. - Here is the JSON Schema instance your output must adhere to: - ```%s``` - """; - return String.format(template, this.jsonSchema); - } - -} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/converter/BeanOutputConverterTest.java b/spring-ai-core/src/test/java/org/springframework/ai/converter/BeanOutputConverterTest.java index dacad64ffdc..24cfd0a5cbe 100644 --- a/spring-ai-core/src/test/java/org/springframework/ai/converter/BeanOutputConverterTest.java +++ b/spring-ai-core/src/test/java/org/springframework/ai/converter/BeanOutputConverterTest.java @@ -15,9 +15,10 @@ */ package org.springframework.ai.converter; +import java.util.List; + import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyDescription; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Nested; @@ -26,10 +27,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.ParameterizedTypeReference; + import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.when; /** * @author Sebastian Ullrich @@ -44,43 +44,72 @@ class BeanOutputConverterTest { @Test public void shouldHavePreConfiguredDefaultObjectMapper() { - var converter = new BeanOutputConverter<>(TestClass.class); + var converter = new BeanOutputConverter<>(new ParameterizedTypeReference() { + }); var objectMapper = converter.getObjectMapper(); assertThat(objectMapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse(); } - @Test - public void shouldUseProvidedObjectMapperForParsing() throws JsonProcessingException { - var testClass = new TestClass("some string"); - when(objectMapperMock.readValue(anyString(), eq(TestClass.class))).thenReturn(testClass); - var converter = new BeanOutputConverter<>(TestClass.class, objectMapperMock); - assertThat(converter.convert("{}")).isEqualTo(testClass); - } - @Nested - class ParserTest { + class ConverterTest { @Test - public void shouldParseFieldNamesFromString() { + public void convertClassType() { var converter = new BeanOutputConverter<>(TestClass.class); var testClass = converter.convert("{ \"someString\": \"some value\" }"); assertThat(testClass.getSomeString()).isEqualTo("some value"); } @Test - public void shouldParseJsonPropertiesFromString() { + public void convertTypeReference() { + var converter = new BeanOutputConverter<>(new ParameterizedTypeReference() { + }); + var testClass = converter.convert("{ \"someString\": \"some value\" }"); + assertThat(testClass.getSomeString()).isEqualTo("some value"); + } + + @Test + public void convertTypeReferenceArray() { + var converter = new BeanOutputConverter<>(new ParameterizedTypeReference>() { + }); + List testClass = converter.convert("[{ \"someString\": \"some value\" }]"); + assertThat(testClass).hasSize(1); + assertThat(testClass.get(0).getSomeString()).isEqualTo("some value"); + } + + @Test + public void convertClassTypeWithJsonAnnotations() { var converter = new BeanOutputConverter<>(TestClassWithJsonAnnotations.class); var testClass = converter.convert("{ \"string_property\": \"some value\" }"); assertThat(testClass.getSomeString()).isEqualTo("some value"); } + @Test + public void convertTypeReferenceWithJsonAnnotations() { + var converter = new BeanOutputConverter<>(new ParameterizedTypeReference() { + }); + var testClass = converter.convert("{ \"string_property\": \"some value\" }"); + assertThat(testClass.getSomeString()).isEqualTo("some value"); + } + + @Test + public void convertTypeReferenceArrayWithJsonAnnotations() { + var converter = new BeanOutputConverter<>( + new ParameterizedTypeReference>() { + }); + List testClass = converter + .convert("[{ \"string_property\": \"some value\" }]"); + assertThat(testClass).hasSize(1); + assertThat(testClass.get(0).getSomeString()).isEqualTo("some value"); + } + } @Nested class FormatTest { @Test - public void shouldReturnFormatContainingResponseInstructionsAndJsonSchema() { + public void formatClassType() { var converter = new BeanOutputConverter<>(TestClass.class); assertThat(converter.getFormat()).isEqualTo( """ @@ -102,7 +131,56 @@ public void shouldReturnFormatContainingResponseInstructionsAndJsonSchema() { } @Test - public void shouldReturnFormatContainingJsonSchemaIncludingPropertyAndPropertyDescription() { + public void formatTypeReference() { + var converter = new BeanOutputConverter<>(new ParameterizedTypeReference() { + }); + assertThat(converter.getFormat()).isEqualTo( + """ + Your response should be in JSON format. + Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation. + Do not include markdown code blocks in your response. + Remove the ```json markdown from the output. + Here is the JSON Schema instance your output must adhere to: + ```{ + "$schema" : "https://json-schema.org/draft/2020-12/schema", + "type" : "object", + "properties" : { + "someString" : { + "type" : "string" + } + } + }``` + """); + } + + @Test + public void formatTypeReferenceArray() { + var converter = new BeanOutputConverter<>(new ParameterizedTypeReference>() { + }); + assertThat(converter.getFormat()).isEqualTo( + """ + Your response should be in JSON format. + Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation. + Do not include markdown code blocks in your response. + Remove the ```json markdown from the output. + Here is the JSON Schema instance your output must adhere to: + ```{ + "$schema" : "https://json-schema.org/draft/2020-12/schema", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "someString" : { + "type" : "string" + } + } + } + }``` + """); + } + + @Test + public void formatClassTypeWithAnnotations() { var converter = new BeanOutputConverter<>(TestClassWithJsonAnnotations.class); assertThat(converter.getFormat()).contains(""" ```{ @@ -119,7 +197,25 @@ public void shouldReturnFormatContainingJsonSchemaIncludingPropertyAndPropertyDe } @Test - void normalizesLineEndings() { + public void formatTypeReferenceWithAnnotations() { + var converter = new BeanOutputConverter<>(new ParameterizedTypeReference() { + }); + assertThat(converter.getFormat()).contains(""" + ```{ + "$schema" : "https://json-schema.org/draft/2020-12/schema", + "type" : "object", + "properties" : { + "string_property" : { + "type" : "string", + "description" : "string_property_description" + } + } + }``` + """); + } + + @Test + void normalizesLineEndingsClassType() { var converter = new BeanOutputConverter<>(TestClass.class); String formatOutput = converter.getFormat(); @@ -128,6 +224,17 @@ void normalizesLineEndings() { assertThat(formatOutput).contains(System.lineSeparator()).doesNotContain("\r\n").doesNotContain("\r"); } + @Test + void normalizesLineEndingsTypeReference() { + var converter = new BeanOutputConverter<>(new ParameterizedTypeReference() { + }); + + String formatOutput = converter.getFormat(); + + // validate that output contains \n line endings + assertThat(formatOutput).contains(System.lineSeparator()).doesNotContain("\r\n").doesNotContain("\r"); + } + } public static class TestClass { diff --git a/spring-ai-core/src/test/java/org/springframework/ai/converter/TypeReferenceOutputConverterTest.java b/spring-ai-core/src/test/java/org/springframework/ai/converter/TypeReferenceOutputConverterTest.java deleted file mode 100644 index abddd15673b..00000000000 --- a/spring-ai-core/src/test/java/org/springframework/ai/converter/TypeReferenceOutputConverterTest.java +++ /dev/null @@ -1,190 +0,0 @@ -/* - * Copyright 2023 - 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.converter; - -import java.util.List; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonPropertyDescription; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import org.springframework.core.ParameterizedTypeReference; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * @author Sebastian Ullrich - * @author Kirk Lund - * @author Christian Tzolov - */ -@ExtendWith(MockitoExtension.class) -class TypeReferenceOutputConverterTest { - - @Mock - private ObjectMapper objectMapperMock; - - @Test - public void shouldHavePreConfiguredDefaultObjectMapper() { - var converter = new ParameterizedTypeReferenceOutputConverter<>(new ParameterizedTypeReference() { - }); - var objectMapper = converter.getObjectMapper(); - assertThat(objectMapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse(); - } - - @Nested - class ParserTest { - - @Test - public void shouldParseFieldNamesFromString() { - var converter = new ParameterizedTypeReferenceOutputConverter<>( - new ParameterizedTypeReference() { - }); - var testClass = converter.convert("{ \"someString\": \"some value\" }"); - assertThat(testClass.getSomeString()).isEqualTo("some value"); - } - - @Test - public void shouldParseFieldNamesFromArrayString() { - var converter = new ParameterizedTypeReferenceOutputConverter<>( - new ParameterizedTypeReference>() { - }); - List testClass = converter.convert("[{ \"someString\": \"some value\" }]"); - assertThat(testClass).hasSize(1); - assertThat(testClass.get(0).getSomeString()).isEqualTo("some value"); - } - - @Test - public void shouldParseJsonPropertiesFromString() { - var converter = new ParameterizedTypeReferenceOutputConverter<>( - new ParameterizedTypeReference() { - }); - var testClass = converter.convert("{ \"string_property\": \"some value\" }"); - assertThat(testClass.getSomeString()).isEqualTo("some value"); - } - - @Test - public void shouldParseJsonPropertiesFromArrayString() { - var converter = new ParameterizedTypeReferenceOutputConverter<>( - new ParameterizedTypeReference>() { - }); - List testClass = converter - .convert("[{ \"string_property\": \"some value\" }]"); - assertThat(testClass).hasSize(1); - assertThat(testClass.get(0).getSomeString()).isEqualTo("some value"); - } - - } - - @Nested - class FormatTest { - - @Test - public void shouldReturnFormatContainingResponseInstructionsAndJsonSchema() { - var converter = new ParameterizedTypeReferenceOutputConverter<>( - new ParameterizedTypeReference() { - }); - assertThat(converter.getFormat()).isEqualTo( - """ - Your response should be in JSON format. - Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation. - Do not include markdown code blocks in your response. - Remove the ```json markdown from the output. - Here is the JSON Schema instance your output must adhere to: - ```{ - "$schema" : "https://json-schema.org/draft/2020-12/schema", - "type" : "object", - "properties" : { - "someString" : { - "type" : "string" - } - } - }``` - """); - } - - @Test - public void shouldReturnFormatContainingJsonSchemaIncludingPropertyAndPropertyDescription() { - var converter = new ParameterizedTypeReferenceOutputConverter<>( - new ParameterizedTypeReference() { - }); - assertThat(converter.getFormat()).contains(""" - ```{ - "$schema" : "https://json-schema.org/draft/2020-12/schema", - "type" : "object", - "properties" : { - "string_property" : { - "type" : "string", - "description" : "string_property_description" - } - } - }``` - """); - } - - @Test - void normalizesLineEndings() { - var converter = new ParameterizedTypeReferenceOutputConverter<>( - new ParameterizedTypeReference() { - }); - - String formatOutput = converter.getFormat(); - - // validate that output contains \n line endings - assertThat(formatOutput).contains(System.lineSeparator()).doesNotContain("\r\n").doesNotContain("\r"); - } - - } - - public static class TestClass { - - private String someString; - - @SuppressWarnings("unused") - public TestClass() { - } - - public TestClass(String someString) { - this.someString = someString; - } - - public String getSomeString() { - return someString; - } - - } - - public static class TestClassWithJsonAnnotations { - - @JsonProperty("string_property") - @JsonPropertyDescription("string_property_description") - private String someString; - - public TestClassWithJsonAnnotations() { - } - - public String getSomeString() { - return someString; - } - - } - -} \ No newline at end of file diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/images/structured-output-hierarchy4.jpg b/spring-ai-docs/src/main/antora/modules/ROOT/images/structured-output-hierarchy4.jpg index 6aa6d138da5455265ac711b838f660d9ef49f954..c2226ca330df33b9bd8e0e901c665760c621c055 100644 GIT binary patch literal 229437 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