feat(vertex-ai-gemini): enhance jsonToStruct to support JSON arrays
- Improve the jsonToStruct method in VertexAiGeminiChatModel to handle JSON arrays in addition to JSON objects. When a JSON array is detected, it's now properly converted to a Protobuf Struct with an items field containing the array elements. - Added test Resolves #2647 , #2849 Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
committed by
Mark Pollack
parent
2cbfb22db6
commit
78d90cd134
@@ -16,7 +16,6 @@
|
||||
|
||||
package org.springframework.ai.vertexai.gemini;
|
||||
|
||||
import com.google.cloud.vertexai.api.Tool.GoogleSearch;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
@@ -24,6 +23,7 @@ import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude.Include;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.google.cloud.vertexai.VertexAI;
|
||||
import com.google.cloud.vertexai.api.Candidate;
|
||||
import com.google.cloud.vertexai.api.Candidate.FinishReason;
|
||||
@@ -33,15 +33,16 @@ import com.google.cloud.vertexai.api.FunctionDeclaration;
|
||||
import com.google.cloud.vertexai.api.FunctionResponse;
|
||||
import com.google.cloud.vertexai.api.GenerateContentResponse;
|
||||
import com.google.cloud.vertexai.api.GenerationConfig;
|
||||
import com.google.cloud.vertexai.api.GoogleSearchRetrieval;
|
||||
import com.google.cloud.vertexai.api.Part;
|
||||
import com.google.cloud.vertexai.api.SafetySetting;
|
||||
import com.google.cloud.vertexai.api.Schema;
|
||||
import com.google.cloud.vertexai.api.Tool;
|
||||
import com.google.cloud.vertexai.api.Tool.GoogleSearch;
|
||||
import com.google.cloud.vertexai.generativeai.GenerativeModel;
|
||||
import com.google.cloud.vertexai.generativeai.PartMaker;
|
||||
import com.google.cloud.vertexai.generativeai.ResponseStream;
|
||||
import com.google.protobuf.Struct;
|
||||
import com.google.protobuf.Value;
|
||||
import com.google.protobuf.util.JsonFormat;
|
||||
import io.micrometer.observation.Observation;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
@@ -226,7 +227,8 @@ public class VertexAiGeminiChatModel implements ChatModel, DisposableBean {
|
||||
this.observationRegistry = observationRegistry;
|
||||
this.toolExecutionEligibilityPredicate = toolExecutionEligibilityPredicate;
|
||||
|
||||
// Wrap the provided tool calling manager in a VertexToolCallingManager to ensure
|
||||
// Wrap the provided tool calling manager in a VertexToolCallingManager to
|
||||
// ensure
|
||||
// compatibility with Vertex AI's OpenAPI schema format.
|
||||
if (toolCallingManager instanceof VertexToolCallingManager) {
|
||||
this.toolCallingManager = toolCallingManager;
|
||||
@@ -334,8 +336,34 @@ public class VertexAiGeminiChatModel implements ChatModel, DisposableBean {
|
||||
|
||||
private static Struct jsonToStruct(String json) {
|
||||
try {
|
||||
var structBuilder = Struct.newBuilder();
|
||||
JsonFormat.parser().ignoringUnknownFields().merge(json, structBuilder);
|
||||
JsonNode rootNode = ModelOptionsUtils.OBJECT_MAPPER.readTree(json);
|
||||
|
||||
Struct.Builder structBuilder = Struct.newBuilder();
|
||||
|
||||
if (rootNode.isArray()) {
|
||||
// Handle JSON array
|
||||
List<Value> values = new ArrayList<>();
|
||||
|
||||
for (JsonNode element : rootNode) {
|
||||
String elementJson = element.toString();
|
||||
Struct.Builder elementBuilder = Struct.newBuilder();
|
||||
JsonFormat.parser().ignoringUnknownFields().merge(elementJson, elementBuilder);
|
||||
|
||||
// Add each parsed object as a value in an array field
|
||||
values.add(Value.newBuilder().setStructValue(elementBuilder.build()).build());
|
||||
}
|
||||
|
||||
// Add the array to the main struct with a field name like "items"
|
||||
structBuilder.putFields("items",
|
||||
Value.newBuilder()
|
||||
.setListValue(com.google.protobuf.ListValue.newBuilder().addAllValues(values).build())
|
||||
.build());
|
||||
}
|
||||
else {
|
||||
// Original behavior for single JSON object
|
||||
JsonFormat.parser().ignoringUnknownFields().merge(json, structBuilder);
|
||||
}
|
||||
|
||||
return structBuilder.build();
|
||||
}
|
||||
catch (Exception e) {
|
||||
|
||||
@@ -25,11 +25,13 @@ import java.util.stream.Stream;
|
||||
|
||||
import com.google.cloud.vertexai.Transport;
|
||||
import com.google.cloud.vertexai.VertexAI;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
@@ -42,6 +44,8 @@ import org.springframework.ai.content.Media;
|
||||
import org.springframework.ai.converter.BeanOutputConverter;
|
||||
import org.springframework.ai.converter.ListOutputConverter;
|
||||
import org.springframework.ai.converter.MapOutputConverter;
|
||||
import org.springframework.ai.model.tool.ToolCallingManager;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel.ChatModel;
|
||||
import org.springframework.ai.vertexai.gemini.common.VertexAiGeminiSafetySetting;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -293,6 +297,70 @@ class VertexAiGeminiChatModelIT {
|
||||
assertThat(response.getResult().getOutput().getText()).containsAnyOf("Spring AI", "portable API");
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create a VertexAI instance for tests
|
||||
*/
|
||||
private VertexAI vertexAiApi() {
|
||||
String projectId = System.getenv("VERTEX_AI_GEMINI_PROJECT_ID");
|
||||
String location = System.getenv("VERTEX_AI_GEMINI_LOCATION");
|
||||
return new VertexAI.Builder().setProjectId(projectId)
|
||||
.setLocation(location)
|
||||
.setTransport(Transport.REST)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonArrayToolCallingTest() {
|
||||
// Test for the improved jsonToStruct method that handles JSON arrays in tool
|
||||
// calling
|
||||
|
||||
ToolCallingManager toolCallingManager = ToolCallingManager.builder()
|
||||
.observationRegistry(ObservationRegistry.NOOP)
|
||||
.build();
|
||||
|
||||
VertexAiGeminiChatModel chatModelWithTools = VertexAiGeminiChatModel.builder()
|
||||
.vertexAI(vertexAiApi())
|
||||
.toolCallingManager(toolCallingManager)
|
||||
.defaultOptions(VertexAiGeminiChatOptions.builder()
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH)
|
||||
.temperature(0.1)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
ChatClient chatClient = ChatClient.builder(chatModelWithTools).build();
|
||||
|
||||
// Create a prompt that will trigger the tool call with a specific request that
|
||||
// should invoke the tool
|
||||
String response = chatClient.prompt()
|
||||
.tools(new ScientistTools())
|
||||
.user("List 3 famous scientists and their discoveries. Make sure to use the tool to get this information.")
|
||||
.call()
|
||||
.content();
|
||||
|
||||
assertThat(response).isNotEmpty();
|
||||
|
||||
assertThat(response).satisfiesAnyOf(content -> assertThat(content).contains("Einstein"),
|
||||
content -> assertThat(content).contains("Newton"), content -> assertThat(content).contains("Curie"));
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool class that returns a JSON array to test the jsonToStruct method's ability to
|
||||
* handle JSON arrays. This specifically tests the PR changes that improve the
|
||||
* jsonToStruct method to handle JSON arrays in addition to JSON objects.
|
||||
*/
|
||||
public static class ScientistTools {
|
||||
|
||||
@Tool(description = "Get information about famous scientists and their discoveries")
|
||||
public List<Map<String, String>> getScientists() {
|
||||
// Return a JSON array with scientist information
|
||||
return List.of(Map.of("name", "Albert Einstein", "discovery", "Theory of Relativity"),
|
||||
Map.of("name", "Isaac Newton", "discovery", "Laws of Motion"),
|
||||
Map.of("name", "Marie Curie", "discovery", "Radioactivity"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
record ActorsFilmsRecord(String actor, List<String> movies) {
|
||||
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ public class VertexAiGeminiChatModelToolCallingIT {
|
||||
|
||||
assertThat(chatResponse.getMetadata()).isNotNull();
|
||||
assertThat(chatResponse.getMetadata().getUsage()).isNotNull();
|
||||
assertThat(chatResponse.getMetadata().getUsage().getTotalTokens()).isGreaterThan(150).isLessThan(310);
|
||||
assertThat(chatResponse.getMetadata().getUsage().getTotalTokens()).isGreaterThan(150).isLessThan(330);
|
||||
|
||||
ChatResponse response2 = this.chatModel
|
||||
.call(new Prompt("What is the payment status for transaction 696?", promptOptions));
|
||||
@@ -201,7 +201,7 @@ public class VertexAiGeminiChatModelToolCallingIT {
|
||||
assertThat(chatResponse).isNotNull();
|
||||
assertThat(chatResponse.getMetadata()).isNotNull();
|
||||
assertThat(chatResponse.getMetadata().getUsage()).isNotNull();
|
||||
assertThat(chatResponse.getMetadata().getUsage().getTotalTokens()).isGreaterThan(150).isLessThan(310);
|
||||
assertThat(chatResponse.getMetadata().getUsage().getTotalTokens()).isGreaterThan(150).isLessThan(330);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ import org.springframework.lang.NonNull;
|
||||
*/
|
||||
public class ListOutputConverter extends AbstractConversionServiceOutputConverter<List<String>> {
|
||||
|
||||
public ListOutputConverter() {
|
||||
this(new DefaultConversionService());
|
||||
}
|
||||
|
||||
public ListOutputConverter(DefaultConversionService defaultConversionService) {
|
||||
super(defaultConversionService);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user