diff --git a/core-services/prompt-registry/pom.xml b/core-services/prompt-registry/pom.xml index 13541e763..940ea87b5 100644 --- a/core-services/prompt-registry/pom.xml +++ b/core-services/prompt-registry/pom.xml @@ -38,11 +38,11 @@ ${project.basedir}/../../ - 84% - 90% - 92% + 92% + 94% + 95% 100% - 80% + 85% 100% @@ -85,6 +85,10 @@ com.fasterxml.jackson.core jackson-databind + + com.fasterxml.jackson.core + jackson-core + com.google.guava guava diff --git a/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/PromptClient.java b/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/PromptClient.java index c614abf33..5471676dd 100644 --- a/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/PromptClient.java +++ b/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/PromptClient.java @@ -4,10 +4,17 @@ import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.google.common.collect.Iterables; import com.sap.ai.sdk.core.AiCoreService; import com.sap.ai.sdk.prompt.registry.client.PromptTemplatesApi; +import com.sap.ai.sdk.prompt.registry.model.MultiChatContent; +import com.sap.ai.sdk.prompt.registry.model.MultiChatTemplate; import com.sap.ai.sdk.prompt.registry.model.PromptTemplate; import com.sap.ai.sdk.prompt.registry.model.PromptTemplateSpecResponseFormat; import com.sap.ai.sdk.prompt.registry.model.ResponseFormatJsonObject; @@ -16,6 +23,8 @@ import com.sap.ai.sdk.prompt.registry.model.SingleChatTemplate; import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; import com.sap.cloud.sdk.services.openapi.apiclient.ApiClient; +import java.io.IOException; +import java.util.ArrayList; import javax.annotation.Nonnull; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -75,7 +84,7 @@ private static ApiClient addMixin(@Nonnull final AiCoreService service) { @NoArgsConstructor(access = AccessLevel.PRIVATE) private static class JacksonMixin { @JsonTypeInfo(use = JsonTypeInfo.Id.NONE) - @JsonDeserialize(as = SingleChatTemplate.class) + @JsonDeserialize(using = PromptTemplateDeserializer.class) interface TemplateMixIn {} @JsonTypeInfo( @@ -90,4 +99,40 @@ interface TemplateMixIn {} }) interface ResponseFormat {} } + + private static class PromptTemplateDeserializer extends JsonDeserializer { + + @Override + public PromptTemplate deserialize( + @Nonnull final JsonParser jsonParser, + @Nonnull final DeserializationContext deserializationContext) + throws IOException { + + final JsonNode root = jsonParser.readValueAsTree(); + final JsonNode roleNode = root.path("role"); + final String role = roleNode.asText(); + final JsonNode content = root.path("content"); + + if (!roleNode.isTextual()) { + throw JsonMappingException.from( + jsonParser, "PromptTemplate requires textual 'role' property."); + } + + if (content.isTextual()) { + return SingleChatTemplate.create().role(role).content(content.asText()); + } + if (content.isArray()) { + final var contentList = new ArrayList(); + for (final JsonNode item : content) { + contentList.add(jsonParser.getCodec().treeToValue(item, MultiChatContent.class)); + } + return MultiChatTemplate.create().role(role).content(contentList); + } + + throw JsonMappingException.from( + jsonParser, + "PromptTemplate content must be either a string or an array, but found: " + + content.getNodeType()); + } + } } diff --git a/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/spring/SpringAiConverter.java b/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/spring/SpringAiConverter.java index 37e383add..7ab8ed62e 100644 --- a/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/spring/SpringAiConverter.java +++ b/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/spring/SpringAiConverter.java @@ -1,9 +1,13 @@ package com.sap.ai.sdk.prompt.registry.spring; +import com.sap.ai.sdk.prompt.registry.model.ImageContent; +import com.sap.ai.sdk.prompt.registry.model.MultiChatTemplate; import com.sap.ai.sdk.prompt.registry.model.PromptTemplate; import com.sap.ai.sdk.prompt.registry.model.PromptTemplateSubstitutionResponse; import com.sap.ai.sdk.prompt.registry.model.SingleChatTemplate; +import com.sap.ai.sdk.prompt.registry.model.TextContent; import java.util.List; +import java.util.stream.Collectors; import javax.annotation.Nonnull; import lombok.val; import org.springframework.ai.chat.messages.AssistantMessage; @@ -34,17 +38,44 @@ public static List promptTemplateToMessages( // TRANSFORM TEMPLATE TO SPRING AI MESSAGES return res.stream() .map( - (PromptTemplate t) -> { - final SingleChatTemplate message = (SingleChatTemplate) t; - return (Message) - switch (message.getRole()) { - case "system" -> new SystemMessage(message.getContent()); - case "user" -> new UserMessage(message.getContent()); - case "assistant" -> new AssistantMessage(message.getContent()); - default -> - throw new IllegalArgumentException("Unknown role: " + message.getRole()); - }; + (PromptTemplate template) -> { + if (template instanceof SingleChatTemplate message) { + return fromRole(message.getRole(), message.getContent()); + } + if (template instanceof MultiChatTemplate message) { + return fromRole(message.getRole(), getMultiTemplateTextContent(message)); + } + throw new IllegalArgumentException( + "Unsupported PromptTemplate type: " + template.getClass().getName()); }) .toList(); } + + @Nonnull + private static String getMultiTemplateTextContent(@Nonnull final MultiChatTemplate message) { + return message.getContent().stream() + .map( + item -> { + if (item instanceof TextContent textContent) { + return textContent.getText(); + } + if (item instanceof ImageContent) { + throw new UnsupportedOperationException( + "MultiChatTemplate with image content is not supported by SpringAiConverter yet."); + } + throw new UnsupportedOperationException( + "Unsupported MultiChatContent type: " + item.getClass().getName()); + }) + .collect(Collectors.joining("\n")); + } + + @Nonnull + private static Message fromRole(@Nonnull final String role, @Nonnull final String content) { + return switch (role) { + case "system" -> new SystemMessage(content); + case "user" -> new UserMessage(content); + case "assistant" -> new AssistantMessage(content); + default -> throw new IllegalArgumentException("Unknown role: " + role); + }; + } } diff --git a/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/PromptRegistryClientTest.java b/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/PromptRegistryClientTest.java index 428a87eed..b81131c0a 100644 --- a/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/PromptRegistryClientTest.java +++ b/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/PromptRegistryClientTest.java @@ -2,15 +2,21 @@ import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.github.tomakehurst.wiremock.junit5.WireMockExtension; import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.prompt.registry.model.MultiChatTemplate; import com.sap.ai.sdk.prompt.registry.model.PromptTemplateGetResponse; +import com.sap.ai.sdk.prompt.registry.model.PromptTemplateSubstitutionRequest; import com.sap.ai.sdk.prompt.registry.model.ResponseFormatJsonObject; import com.sap.ai.sdk.prompt.registry.model.ResponseFormatJsonSchema; import com.sap.ai.sdk.prompt.registry.model.ResponseFormatText; +import com.sap.ai.sdk.prompt.registry.model.SingleChatTemplate; +import com.sap.ai.sdk.prompt.registry.model.TextContent; import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import java.util.Map; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -95,4 +101,66 @@ void testGetTemplateWithResponseFormatJsonSchema() { assertThat(format.getJsonSchema().isStrict()).isFalse(); assertThat(format.getJsonSchema().getSchema()).isNotNull(); } + + @Test + void testGetTemplateWithMultiChatTemplate() { + final var uuid = UUID.fromString("8f79fec4-ae07-4c35-96e3-df7f4a3f1df5"); + final var response = client.getPromptTemplateByUuid(uuid); + + assertThat(response.getSpec()).isNotNull(); + assertThat(response.getSpec().getTemplate()).hasSize(2); + assertThat(response.getSpec().getTemplate().get(0)).isInstanceOf(SingleChatTemplate.class); + assertThat(response.getSpec().getTemplate().get(1)).isInstanceOf(MultiChatTemplate.class); + + final var multiTemplate = (MultiChatTemplate) response.getSpec().getTemplate().get(1); + assertThat(multiTemplate.getRole()).isEqualTo("user"); + assertThat(multiTemplate.getContent()).hasSize(2); + assertThat(multiTemplate.getContent().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) multiTemplate.getContent().get(0)).getText()) + .isEqualTo("First content line"); + assertThat(((TextContent) multiTemplate.getContent().get(1)).getText()) + .isEqualTo("Second content line"); + } + + @Test + void testGetTemplateWithInvalidRoleType() { + final var uuid = UUID.fromString("45cb1358-0bf1-4f43-870b-00f14d0f9f16"); + + assertThatThrownBy(() -> client.getPromptTemplateByUuid(uuid)) + .hasStackTraceContaining("PromptTemplate requires textual 'role' property."); + } + + @Test + void testGetTemplateWithInvalidContentType() { + final var uuid = UUID.fromString("55cb1358-0bf1-4f43-870b-00f14d0f9f16"); + + assertThatThrownBy(() -> client.getPromptTemplateByUuid(uuid)) + .hasStackTraceContaining( + "PromptTemplate content must be either a string or an array, but found: BOOLEAN"); + } + + @Test + void testParsePromptTemplateHotPath() { + final var request = + PromptTemplateSubstitutionRequest.create() + .inputParams(Map.of("inputExample", "I love football")); + + final var response = + client.parsePromptTemplateByNameVersion( + "categorization", "0.0.1", "hotpath-serde", "default", null, false, request); + + assertThat(response.getParsedPrompt()).hasSize(2); + assertThat(response.getParsedPrompt().get(0)).isInstanceOf(SingleChatTemplate.class); + assertThat(response.getParsedPrompt().get(1)).isInstanceOf(SingleChatTemplate.class); + + final var systemTemplate = (SingleChatTemplate) response.getParsedPrompt().get(0); + assertThat(systemTemplate.getRole()).isEqualTo("system"); + assertThat(systemTemplate.getContent()) + .isEqualTo( + "You classify input text into the two following categories: Finance, Tech, Sports"); + + final var userTemplate = (SingleChatTemplate) response.getParsedPrompt().get(1); + assertThat(userTemplate.getRole()).isEqualTo("user"); + assertThat(userTemplate.getContent()).isEqualTo("I love football"); + } } diff --git a/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/PromptTemplateSerdeUnitTest.java b/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/PromptTemplateSerdeUnitTest.java new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/PromptTemplateSerdeUnitTest.java @@ -0,0 +1 @@ + diff --git a/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/spring/SpringAiConverterTest.java b/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/spring/SpringAiConverterTest.java index de3053bf7..7008efabe 100644 --- a/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/spring/SpringAiConverterTest.java +++ b/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/spring/SpringAiConverterTest.java @@ -7,7 +7,11 @@ import com.github.tomakehurst.wiremock.junit5.WireMockExtension; import com.sap.ai.sdk.core.AiCoreService; import com.sap.ai.sdk.prompt.registry.PromptClient; +import com.sap.ai.sdk.prompt.registry.model.MultiChatContent; +import com.sap.ai.sdk.prompt.registry.model.MultiChatTemplate; +import com.sap.ai.sdk.prompt.registry.model.PromptTemplate; import com.sap.ai.sdk.prompt.registry.model.PromptTemplateSubstitutionRequest; +import com.sap.ai.sdk.prompt.registry.model.PromptTemplateSubstitutionResponse; import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; import java.util.List; @@ -68,4 +72,65 @@ void testInvalidRoleThrowsException() { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Unknown role: error"); } + + @Test + void testMultiChatTemplateTextContentToSpringAi() { + var client = new PromptClient(SERVICE); + val promptResponse = + client.parsePromptTemplateByNameVersion( + "categorization", + "0.0.1", + "multi-text", + "default", + null, + false, + PromptTemplateSubstitutionRequest.create() + .inputParams(Map.of("inputExample", "I love football"))); + + List messages = SpringAiConverter.promptTemplateToMessages(promptResponse); + assertThat(messages) + .isEqualTo(List.of(new UserMessage("First content line\nSecond content line"))); + } + + @Test + void testMultiChatTemplateImageContentThrowsException() { + var client = new PromptClient(SERVICE); + val promptResponse = + client.parsePromptTemplateByNameVersion( + "categorization", + "0.0.1", + "multi-image", + "default", + null, + false, + PromptTemplateSubstitutionRequest.create() + .inputParams(Map.of("inputExample", "I love football"))); + + assertThatThrownBy(() -> SpringAiConverter.promptTemplateToMessages(promptResponse)) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("image content is not supported"); + } + + @Test + void testUnsupportedPromptTemplateTypeThrowsException() { + final PromptTemplate unsupportedTemplate = new PromptTemplate() {}; + final var promptResponse = + PromptTemplateSubstitutionResponse.create().parsedPrompt(List.of(unsupportedTemplate)); + + assertThatThrownBy(() -> SpringAiConverter.promptTemplateToMessages(promptResponse)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unsupported PromptTemplate type"); + } + + @Test + void testUnsupportedMultiChatContentTypeThrowsException() { + final MultiChatContent unsupportedContent = new MultiChatContent() {}; + final var message = MultiChatTemplate.create().role("user").content(unsupportedContent); + final var promptResponse = + PromptTemplateSubstitutionResponse.create().parsedPrompt(List.of(message)); + + assertThatThrownBy(() -> SpringAiConverter.promptTemplateToMessages(promptResponse)) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("Unsupported MultiChatContent type"); + } } diff --git a/core-services/prompt-registry/src/test/resources/mappings/templateWithInvalidContentType.json b/core-services/prompt-registry/src/test/resources/mappings/templateWithInvalidContentType.json new file mode 100644 index 000000000..97a07228e --- /dev/null +++ b/core-services/prompt-registry/src/test/resources/mappings/templateWithInvalidContentType.json @@ -0,0 +1,30 @@ +{ + "request": { + "method": "GET", + "url": "/v2/lm/promptTemplates/55cb1358-0bf1-4f43-870b-00f14d0f9f16" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "id": "55cb1358-0bf1-4f43-870b-00f14d0f9f16", + "name": "invalid-content", + "version": "0.0.1", + "scenario": "test-retrival", + "creationTimestamp": "2025-12-02T16:06:05.400000", + "managedBy": "imperative", + "isVersionHead": true, + "spec": { + "template": [ + { + "role": "user", + "content": true + } + ], + "tools": [] + } + } + } +} diff --git a/core-services/prompt-registry/src/test/resources/mappings/templateWithInvalidRoleType.json b/core-services/prompt-registry/src/test/resources/mappings/templateWithInvalidRoleType.json new file mode 100644 index 000000000..2ad8a29ee --- /dev/null +++ b/core-services/prompt-registry/src/test/resources/mappings/templateWithInvalidRoleType.json @@ -0,0 +1,30 @@ +{ + "request": { + "method": "GET", + "url": "/v2/lm/promptTemplates/45cb1358-0bf1-4f43-870b-00f14d0f9f16" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "id": "45cb1358-0bf1-4f43-870b-00f14d0f9f16", + "name": "invalid-role", + "version": "0.0.1", + "scenario": "test-retrival", + "creationTimestamp": "2025-12-02T16:06:05.400000", + "managedBy": "imperative", + "isVersionHead": true, + "spec": { + "template": [ + { + "role": 123, + "content": "Test content" + } + ], + "tools": [] + } + } + } +} diff --git a/core-services/prompt-registry/src/test/resources/mappings/templateWithMultiChat.json b/core-services/prompt-registry/src/test/resources/mappings/templateWithMultiChat.json new file mode 100644 index 000000000..6c1a789f4 --- /dev/null +++ b/core-services/prompt-registry/src/test/resources/mappings/templateWithMultiChat.json @@ -0,0 +1,43 @@ +{ + "request": { + "method": "GET", + "url": "/v2/lm/promptTemplates/8f79fec4-ae07-4c35-96e3-df7f4a3f1df5" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "id": "8f79fec4-ae07-4c35-96e3-df7f4a3f1df5", + "name": "test-multi", + "version": "0.0.1", + "scenario": "test-retrival", + "creationTimestamp": "2025-12-02T16:06:05.400000", + "managedBy": "imperative", + "isVersionHead": true, + "spec": { + "template": [ + { + "role": "system", + "content": "System content" + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "First content line" + }, + { + "type": "text", + "text": "Second content line" + } + ] + } + ], + "tools": [] + } + } + } +} diff --git a/core-services/prompt-registry/src/test/resources/mappings/templatesInputParamsHotPathSerde.json b/core-services/prompt-registry/src/test/resources/mappings/templatesInputParamsHotPathSerde.json new file mode 100644 index 000000000..1c3e09173 --- /dev/null +++ b/core-services/prompt-registry/src/test/resources/mappings/templatesInputParamsHotPathSerde.json @@ -0,0 +1,29 @@ +{ + "request": { + "method": "POST", + "url": "/v2/lm/scenarios/categorization/promptTemplates/hotpath-serde/versions/0.0.1/substitution?metadata=false", + "bodyPatterns": [ + { + "matchesJsonPath": "$.inputParams.inputExample" + } + ] + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "parsedPrompt": [ + { + "role": "system", + "content": "You classify input text into the two following categories: Finance, Tech, Sports" + }, + { + "role": "user", + "content": "I love football" + } + ] + } + } +} diff --git a/core-services/prompt-registry/src/test/resources/mappings/templatesInputParamsMultiImage.json b/core-services/prompt-registry/src/test/resources/mappings/templatesInputParamsMultiImage.json new file mode 100644 index 000000000..ff8de11ef --- /dev/null +++ b/core-services/prompt-registry/src/test/resources/mappings/templatesInputParamsMultiImage.json @@ -0,0 +1,31 @@ +{ + "request": { + "method": "POST", + "url": "/v2/lm/scenarios/categorization/promptTemplates/multi-image/versions/0.0.1/substitution?metadata=false" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "parsedPrompt": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Describe this image" + }, + { + "type": "image_url", + "image_url": { + "url": "https://example.invalid/image.png" + } + } + ] + } + ] + } + } +} diff --git a/core-services/prompt-registry/src/test/resources/mappings/templatesInputParamsMultiText.json b/core-services/prompt-registry/src/test/resources/mappings/templatesInputParamsMultiText.json new file mode 100644 index 000000000..78059c450 --- /dev/null +++ b/core-services/prompt-registry/src/test/resources/mappings/templatesInputParamsMultiText.json @@ -0,0 +1,29 @@ +{ + "request": { + "method": "POST", + "url": "/v2/lm/scenarios/categorization/promptTemplates/multi-text/versions/0.0.1/substitution?metadata=false" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "parsedPrompt": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "First content line" + }, + { + "type": "text", + "text": "Second content line" + } + ] + } + ] + } + } +}