From a64dd06c4845d1448aec78657771723a5f3932ea Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Mon, 27 May 2024 18:11:37 +0200 Subject: [PATCH] Add payment transaction status Function Calling ITs - OpenAI and Vertex AI Gemeini --- .../chat/OpenAiPaymentTransactionIT.java | 220 ++++++++++++++++++ .../gemini/VertexAiGeminiChatOptions.java | 13 ++ ...texAiGeminiChatModelFunctionCallingIT.java | 10 +- .../VertexAiGeminiPaymentTransactionIT.java | 213 +++++++++++++++++ .../ai/converter/BeanOutputConverter.java | 9 + 5 files changed, 460 insertions(+), 5 deletions(-) create mode 100644 models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiPaymentTransactionIT.java create mode 100644 models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiPaymentTransactionIT.java diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiPaymentTransactionIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiPaymentTransactionIT.java new file mode 100644 index 000000000..c204f96e6 --- /dev/null +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiPaymentTransactionIT.java @@ -0,0 +1,220 @@ +/* + * Copyright 2024-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openai.chat; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +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; + +import org.springframework.ai.chat.client.AdvisedRequest; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.RequestResponseAdvisor; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.ai.model.function.FunctionCallbackContext; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.openai.api.OpenAiApi.ChatModel; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Description; +import org.springframework.core.ParameterizedTypeReference; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + */ +@SpringBootTest +@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_PROJECT_ID", matches = ".*") +@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_LOCATION", matches = ".*") +public class OpenAiPaymentTransactionIT { + + private final static Logger logger = LoggerFactory.getLogger(OpenAiPaymentTransactionIT.class); + + @Autowired + ChatClient chatClient; + + record TransactionStatusResponse(String id, String status) { + } + + private static class LoggingAdvisor implements RequestResponseAdvisor { + + private final Logger logger = LoggerFactory.getLogger(LoggingAdvisor.class); + + @Override + public AdvisedRequest adviseRequest(AdvisedRequest request, Map context) { + logger.info("System text: \n" + request.systemText()); + logger.info("System params: " + request.systemParams()); + logger.info("User text: \n" + request.userText()); + logger.info("User params:" + request.userParams()); + logger.info("Function names: " + request.functionNames()); + + logger.info("Options: " + request.chatOptions().toString()); + + return request; + } + + @Override + public ChatResponse adviseResponse(ChatResponse response, Map context) { + logger.info("Response: " + response); + return response; + } + + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "paymentStatus", "paymentStatuses" }) + public void transactionPaymentStatuses(String functionName) { + List content = this.chatClient.prompt() + .advisors(new LoggingAdvisor()) + .functions(functionName) + .user(""" + What is the status of my payment transactions 001, 002 and 003? + """) + .call() + .entity(new ParameterizedTypeReference>() { + }); + + logger.info("" + content); + + assertThat(content.get(0).id()).isEqualTo("001"); + assertThat(content.get(0).status()).isEqualTo("pending"); + + assertThat(content.get(1).id()).isEqualTo("002"); + assertThat(content.get(1).status()).isEqualTo("approved"); + + assertThat(content.get(2).id()).isEqualTo("003"); + assertThat(content.get(2).status()).isEqualTo("rejected"); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "paymentStatus", "paymentStatuses" }) + public void streamingPaymentStatuses(String functionName) { + + var converter = new BeanOutputConverter<>(new ParameterizedTypeReference>() { + }); + + Flux flux = this.chatClient.prompt() + .advisors(new LoggingAdvisor()) + .functions(functionName) + .user(u -> u.text(""" + What is the status of my payment transactions 001, 002 and 003? + + {format} + """).param("format", converter.getFormat())) + .stream() + .content(); + + String content = flux.collectList().block().stream().collect(Collectors.joining()); + + List structure = converter.convert(content); + logger.info("" + content); + + assertThat(structure.get(0).id()).isEqualTo("001"); + assertThat(structure.get(0).status()).isEqualTo("pending"); + + assertThat(structure.get(1).id()).isEqualTo("002"); + assertThat(structure.get(1).status()).isEqualTo("approved"); + + assertThat(structure.get(2).id()).isEqualTo("003"); + assertThat(structure.get(2).status()).isEqualTo("rejected"); + } + + record Transaction(String id) { + } + + record Status(String name) { + } + + record Transactions(List transactions) { + } + + record Statuses(List statuses) { + } + + private static final Map DATASET = Map.of(new Transaction("001"), new Status("pending"), + new Transaction("002"), new Status("approved"), new Transaction("003"), new Status("rejected")); + + @SpringBootConfiguration + public static class TestConfiguration { + + @Bean + @Description("Get the status of a single payment transaction") + public Function paymentStatus() { + return transaction -> { + logger.info("Single transaction: " + transaction); + return DATASET.get(transaction); + }; + } + + @Bean + @Description("Get the list statuses of a list of payment transactions") + public Function paymentStatuses() { + return transactions -> { + logger.info("List of transactions: " + transactions); + return new Statuses(transactions.transactions().stream().map(t -> DATASET.get(t)).toList()); + }; + } + + @Bean + public ChatClient chatClient(OpenAiChatModel chatModel) { + return ChatClient.builder(chatModel).build(); + } + + @Bean + public OpenAiApi chatCompletionApi() { + return new OpenAiApi(System.getenv("OPENAI_API_KEY")); + } + + @Bean + public OpenAiChatModel openAiClient(OpenAiApi openAiApi, FunctionCallbackContext functionCallbackContext) { + return new OpenAiChatModel(openAiApi, + OpenAiChatOptions.builder() + .withModel(ChatModel.GPT_4_0_TURBO.getModelName()) + .withTemperature(0.1f) + .build(), + functionCallbackContext, RetryUtils.DEFAULT_RETRY_TEMPLATE); + } + + /** + * Because of the OPEN_API_SCHEMA type, the FunctionCallbackContext instance must + * different from the other JSON schema types. + */ + @Bean + public FunctionCallbackContext springAiFunctionManager(ApplicationContext context) { + FunctionCallbackContext manager = new FunctionCallbackContext(); + manager.setApplicationContext(context); + return manager; + } + + } + +} 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 6a83f4a00..c19c25633 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 @@ -336,6 +336,19 @@ public class VertexAiGeminiChatOptions implements FunctionCallingOptions, ChatOp return true; } + @Override + public String toString() { + return "VertexAiGeminiChatOptions [stopSequences=" + stopSequences + ", temperature=" + temperature + ", topP=" + + topP + ", topK=" + topK + ", candidateCount=" + candidateCount + ", maxOutputTokens=" + + maxOutputTokens + ", model=" + model + ", functionCallbacks=" + functionCallbacks + ", functions=" + + functions + ", getClass()=" + getClass() + ", getStopSequences()=" + getStopSequences() + + ", getTemperature()=" + getTemperature() + ", getTopP()=" + getTopP() + ", getTopK()=" + getTopK() + + ", getCandidateCount()=" + getCandidateCount() + ", getMaxOutputTokens()=" + getMaxOutputTokens() + + ", getModel()=" + getModel() + ", getFunctionCallbacks()=" + getFunctionCallbacks() + + ", getFunctions()=" + getFunctions() + ", hashCode()=" + hashCode() + ", toString()=" + + super.toString() + "]"; + } + public static VertexAiGeminiChatOptions fromOptions(VertexAiGeminiChatOptions fromOptions) { VertexAiGeminiChatOptions options = new VertexAiGeminiChatOptions(); options.setStopSequences(fromOptions.getStopSequences()); diff --git a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatModelFunctionCallingIT.java b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatModelFunctionCallingIT.java index d9d0f38fc..39ea0bc0f 100644 --- a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatModelFunctionCallingIT.java +++ b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiChatModelFunctionCallingIT.java @@ -55,7 +55,7 @@ public class VertexAiGeminiChatModelFunctionCallingIT { private final Logger logger = LoggerFactory.getLogger(getClass()); @Autowired - private VertexAiGeminiChatModel vertexGeminiClient; + private VertexAiGeminiChatModel chatModel; @AfterEach public void afterEach() { @@ -107,7 +107,7 @@ public class VertexAiGeminiChatModelFunctionCallingIT { .build())) .build(); - ChatResponse response = vertexGeminiClient.call(new Prompt(messages, promptOptions)); + ChatResponse response = chatModel.call(new Prompt(messages, promptOptions)); logger.info("Response: {}", response); @@ -142,13 +142,13 @@ public class VertexAiGeminiChatModelFunctionCallingIT { .build())) .build(); - ChatResponse response = vertexGeminiClient.call(new Prompt(messages, promptOptions)); + ChatResponse response = chatModel.call(new Prompt(messages, promptOptions)); logger.info("Response: {}", response); assertThat(response.getResult().getOutput().getContent()).containsAnyOf("15.0", "15"); - ChatResponse response2 = vertexGeminiClient + ChatResponse response2 = chatModel .call(new Prompt("What is the payment status for transaction 696?", promptOptions)); logger.info("Response: {}", response2); @@ -176,7 +176,7 @@ public class VertexAiGeminiChatModelFunctionCallingIT { .build())) .build(); - Flux response = vertexGeminiClient.stream(new Prompt(messages, promptOptions)); + Flux response = chatModel.stream(new Prompt(messages, promptOptions)); String responseString = response.collectList() .block() diff --git a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiPaymentTransactionIT.java b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiPaymentTransactionIT.java new file mode 100644 index 000000000..dd0f447a8 --- /dev/null +++ b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/function/VertexAiGeminiPaymentTransactionIT.java @@ -0,0 +1,213 @@ +/* + * 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.vertexai.gemini.function; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import com.google.cloud.vertexai.Transport; +import com.google.cloud.vertexai.VertexAI; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; + +import org.springframework.ai.chat.client.AdvisedRequest; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.RequestResponseAdvisor; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.model.function.FunctionCallbackContext; +import org.springframework.ai.model.function.FunctionCallbackWrapper.Builder.SchemaType; +import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel; +import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatOptions; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Description; + +/** + * @author Christian Tzolov + */ +@Disabled("Vertex AI Gemini function calling is very unstable.") +@SpringBootTest +@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_PROJECT_ID", matches = ".*") +@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_LOCATION", matches = ".*") +public class VertexAiGeminiPaymentTransactionIT { + + private final static Logger logger = LoggerFactory.getLogger(VertexAiGeminiPaymentTransactionIT.class); + + @Autowired + ChatClient chatClient; + + record TransactionStatusResponse(String id, String status) { + } + + private static class LoggingAdvisor implements RequestResponseAdvisor { + + private final Logger logger = LoggerFactory.getLogger(LoggingAdvisor.class); + + @Override + public AdvisedRequest adviseRequest(AdvisedRequest request, Map context) { + logger.info("System text: \n" + request.systemText()); + logger.info("System params: " + request.systemParams()); + logger.info("User text: \n" + request.userText()); + logger.info("User params:" + request.userParams()); + logger.info("Function names: " + request.functionNames()); + + logger.info("Options: " + request.chatOptions().toString()); + + return request; + } + + @Override + public ChatResponse adviseResponse(ChatResponse response, Map context) { + logger.info("Response: " + response); + return response; + } + + } + + @Test + public void paymentStatuses() { + String content = this.chatClient.prompt().advisors(new LoggingAdvisor()).functions("paymentStatus").user(""" + What is the status of my payment transactions 001, 002 and 003? + + To answer this question invoke the 'paymentStatus' function per transaction. + """).call().content(); + + logger.info("" + content); + } + + @RepeatedTest(10) + public void streamingPaymentStatuses() { + + Flux streamContent = this.chatClient.prompt() + .advisors(new LoggingAdvisor()) + .functions("paymentStatus") + // .functions("paymentStatuses") + .user(""" + What is the status of my payment transactions 001, 002 and 003? + To answer this question invoke the paymentStatus function per transaction. + Return the transaction id and the transaction status for each transaction. + """) + .stream() + .content(); + + String content = streamContent.collectList().block().stream().collect(Collectors.joining()); + + logger.info(content); + + // Quota rate + try { + Thread.sleep(20000); + } + catch (InterruptedException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + } + + record Transaction(String id) { + } + + record Status(String name) { + } + + record Transactions(List transactions) { + } + + record Statuses(List statuses) { + } + + private static final Map DATASET = Map.of(new Transaction("001"), new Status("pending"), + new Transaction("002"), new Status("approved"), new Transaction("003"), new Status("rejected")); + + @SpringBootConfiguration + public static class TestConfiguration { + + @Bean + @Description("Get the status of a single payment transaction") + public Function paymentStatus() { + return transaction -> { + logger.info("Single Transaction: " + transaction); + return DATASET.get(transaction); + }; + } + + @Bean + @Description("Get the list statuses of a list of payment transactions") + public Function paymentStatuses() { + return transactions -> { + logger.info("Transactions: " + transactions); + return new Statuses(transactions.transactions().stream().map(t -> DATASET.get(t)).toList()); + }; + } + + @Bean + public ChatClient chatClient(VertexAiGeminiChatModel chatModel) { + return ChatClient.builder(chatModel).build(); + } + + @Bean + public VertexAI vertexAiApi() { + + String projectId = System.getenv("VERTEX_AI_GEMINI_PROJECT_ID"); + String location = System.getenv("VERTEX_AI_GEMINI_LOCATION"); + + return new VertexAI.Builder().setLocation(location) + .setProjectId(projectId) + .setTransport(Transport.REST) + // .setTransport(Transport.GRPC) + .build(); + } + + @Bean + public VertexAiGeminiChatModel vertexAiChatModel(VertexAI vertexAi, ApplicationContext context) { + + FunctionCallbackContext functionCallbackContext = springAiFunctionManager(context); + + return new VertexAiGeminiChatModel(vertexAi, + VertexAiGeminiChatOptions.builder() + .withModel(VertexAiGeminiChatModel.ChatModel.GEMINI_PRO_1_5_FLASH) + // .withModel(VertexAiGeminiChatModel.ChatModel.GEMINI_PRO_1_5_PRO) + .withTemperature(0.1f) + // .withResponseMimeType(ResponseMimeType.JSON) + .build(), + functionCallbackContext); + } + + /** + * Because of the OPEN_API_SCHEMA type, the FunctionCallbackContext instance must + * different from the other JSON schema types. + */ + private FunctionCallbackContext springAiFunctionManager(ApplicationContext context) { + FunctionCallbackContext manager = new FunctionCallbackContext(); + manager.setSchemaType(SchemaType.OPEN_API_SCHEMA); + manager.setApplicationContext(context); + return manager; + } + + } + +} 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 04bb6d0d2..8cfdabdb6 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 @@ -29,6 +29,8 @@ 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.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.core.ParameterizedTypeReference; import org.springframework.lang.NonNull; @@ -52,6 +54,8 @@ import static com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_ */ public class BeanOutputConverter implements StructuredOutputConverter { + private final Logger logger = LoggerFactory.getLogger(BeanOutputConverter.class); + /** Holds the generated JSON schema for the target type. */ private String jsonSchema; @@ -147,6 +151,7 @@ public class BeanOutputConverter implements StructuredOutputConverter { this.jsonSchema = objectWriter.writeValueAsString(jsonNode); } catch (JsonProcessingException e) { + logger.error("Could not pretty print json schema for jsonNode: " + jsonNode); throw new RuntimeException("Could not pretty print json schema for " + this.typeRef, e); } } @@ -159,9 +164,13 @@ public class BeanOutputConverter implements StructuredOutputConverter { */ public T convert(@NonNull String text) { try { + if (text.startsWith("```json") && text.endsWith("```")) { + text = text.substring(7, text.length() - 3); + } return (T) this.objectMapper.readValue(text, this.typeRef); } catch (JsonProcessingException e) { + logger.error("Could not parse the given text to the desired target type:" + text + " into " + this.typeRef); throw new RuntimeException(e); } }