Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/release_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

### ✨ New Functionality

-
- [Orchestration] [Added support to locally test prompt template files](https://sap.github.io/ai-sdk/docs/java/orchestration/chat-completion#locally-test-a-prompt-template)

### 📈 Improvements

Expand Down
6 changes: 5 additions & 1 deletion orchestration/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
<coverage.complexity>83%</coverage.complexity>
<coverage.line>94%</coverage.line>
<coverage.instruction>94%</coverage.instruction>
<coverage.branch>77%</coverage.branch>
<coverage.branch>78%</coverage.branch>
<coverage.method>93%</coverage.method>
<coverage.class>100%</coverage.class>
</properties>
Expand Down Expand Up @@ -108,6 +108,10 @@
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-module-jackson</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
</dependency>
<!-- scope "provided" -->
<dependency>
<groupId>org.projectlombok</groupId>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.sap.ai.sdk.orchestration;

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.sap.ai.sdk.orchestration.model.LLMChoice;
Expand All @@ -21,4 +22,22 @@ interface ModuleResultsOutputUnmaskingInnerMixIn {}

@JsonTypeInfo(use = JsonTypeInfo.Id.NONE)
interface NoneTypeInfoMixin {}

@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "type",
visible = true)
@JsonSubTypes({
@JsonSubTypes.Type(
value = com.sap.ai.sdk.orchestration.model.ResponseFormatJsonSchema.class,
name = "json_schema"),
@JsonSubTypes.Type(
value = com.sap.ai.sdk.orchestration.model.ResponseFormatJsonObject.class,
name = "json_object"),
@JsonSubTypes.Type(
value = com.sap.ai.sdk.orchestration.model.ResponseFormatText.class,
name = "text")
})
interface ResponseFormatSubTypesMixin {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.sap.ai.sdk.orchestration.model.ChatMessage;
import com.sap.ai.sdk.orchestration.model.LLMModuleResult;
import com.sap.ai.sdk.orchestration.model.ModuleResultsOutputUnmaskingInner;
import com.sap.ai.sdk.orchestration.model.TemplateResponseFormat;
import javax.annotation.Nonnull;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
Expand Down Expand Up @@ -47,7 +48,12 @@ public static ObjectMapper getOrchestrationObjectMapper() {
.addDeserializer(
ChatMessage.class,
PolymorphicFallbackDeserializer.fromJsonSubTypes(ChatMessage.class))
.setMixInAnnotation(ChatMessage.class, JacksonMixins.NoneTypeInfoMixin.class);
.addDeserializer(
TemplateResponseFormat.class,
PolymorphicFallbackDeserializer.fromJsonSubTypes(TemplateResponseFormat.class))
.setMixInAnnotation(ChatMessage.class, JacksonMixins.NoneTypeInfoMixin.class)
.setMixInAnnotation(
TemplateResponseFormat.class, JacksonMixins.ResponseFormatSubTypesMixin.class);
jackson.registerModule(module);
return jackson;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
package com.sap.ai.sdk.orchestration;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.google.common.annotations.Beta;
import com.sap.ai.sdk.orchestration.model.ChatCompletionTool;
import com.sap.ai.sdk.orchestration.model.ChatMessage;
Expand All @@ -9,6 +14,7 @@
import com.sap.ai.sdk.orchestration.model.Template;
import com.sap.ai.sdk.orchestration.model.TemplateResponseFormat;
import com.sap.ai.sdk.orchestration.model.TemplatingModuleConfig;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
Expand All @@ -35,14 +41,22 @@
@NoArgsConstructor(force = true, access = AccessLevel.PACKAGE)
@Beta
public class OrchestrationTemplate extends TemplateConfig {
@Nullable List<ChatMessage> template;
@Nullable Map<String, String> defaults;
@JsonProperty("template")
@Nullable
List<ChatMessage> template;

@JsonProperty("defaults")
@Nullable
Map<String, String> defaults;

@JsonProperty("response_format")
@With(AccessLevel.PRIVATE)
@Nullable
TemplateResponseFormat responseFormat;

@Nullable List<ChatCompletionTool> tools;
@JsonProperty("tools")
@Nullable
List<ChatCompletionTool> tools;

/**
* Create a low-level representation of the template.
Expand Down Expand Up @@ -93,4 +107,45 @@ public OrchestrationTemplate withJsonResponse() {
ResponseFormatJsonObject.create().type(ResponseFormatJsonObject.TypeEnum.JSON_OBJECT);
return this.withResponseFormat(responseFormatJsonObject);
}

/**
* Create a {@link Template} object from a JSON provided as String.
*
* @throws IOException if the JSON cannot be deserialized
* @param inputString the provided JSON
* @return A Template object representing the provided JSON
* @since 1.7.0
*/
@Nullable
private OrchestrationTemplate fromJson(@Nonnull final String inputString) throws IOException {
final ObjectMapper objectMapper =
OrchestrationJacksonConfiguration.getOrchestrationObjectMapper();
final JsonNode rootNode = objectMapper.readTree(inputString);
return objectMapper.treeToValue(rootNode.get("spec"), OrchestrationTemplate.class);
}
Comment on lines +120 to +125
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method could principally be made public as an additional feature. I left it private because it is not part of the ticket, not tested on its own, and not aligned with JS SDK (which only has the yaml feature afaik).


/**
* Create a {@link Template} object from a YAML provided as String.
*
* @throws IOException if the YAML cannot be parsed or deserialized
* @param inputYaml the provided YAML
* @return A Template object representing the provided YAML
* @since 1.7.0
*/
@Nullable
public OrchestrationTemplate fromYaml(@Nonnull final String inputYaml) throws IOException {
Copy link
Member Author

@Jonas-Isr Jonas-Isr Apr 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Question to reviewer)
Should we maybe annotate this with @Beta?

Pro: We might not be able to continue to keep this method/feature alive without additional work if the specs of prompt-registry and orchestration were to diverge at some point (see "Note 1" in the PR description).

Con: Additional beta annotation for convenience code.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as discussed, since this API has no alternative to users, I would keep it non-Beta

final Object obj;
try {
final ObjectMapper yamlReader = new ObjectMapper(new YAMLFactory());
obj = yamlReader.readValue(inputYaml, Object.class);
} catch (JsonProcessingException ex) {
throw new IOException("Failed to parse the YAML input: " + ex.getMessage(), ex);
}
try {
final ObjectMapper jsonWriter = new ObjectMapper();
return fromJson(jsonWriter.writeValueAsString(obj));
} catch (JsonProcessingException ex) {
throw new IOException("Failed to deserialize the input: " + ex.getMessage(), ex);
}
Comment on lines +137 to +149
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if these are the correct/best Exception types. I am open to suggestions :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using two objectmappers, I was about to pitch something like...

  private static class TemplateYaml {
    @JsonProperty("spec")
    private OrchestrationTemplate spec;
  }

But that would've required additional customization to propagate the modules/rules along for Yaml Factory.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
import com.sap.ai.sdk.orchestration.model.TemplateRef;
import com.sap.ai.sdk.orchestration.model.TemplateRefByID;
import com.sap.ai.sdk.orchestration.model.TemplateRefByScenarioNameVersion;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -177,4 +180,96 @@ void testTemplateReferenceConstruction() {
assertThat(templateReferenceScenarioNameVersion.toLowLevel())
.isEqualTo(templateReferenceScenarioNameVersionLowLevel);
}

@Test
void testTemplateFromLocalFileWithJsonSchemaAndTools() throws IOException {
String promptTemplateYaml =
Files.readString(Path.of("src/test/resources/promptTemplateExample.yaml"));
var templateWithJsonSchemaTools = TemplateConfig.create().fromYaml(promptTemplateYaml);
var schema =
Map.of(
"type",
"object",
"properties",
Map.of(
"language", Map.of("type", "string"),
"translation", Map.of("type", "string")),
"required",
List.of("language", "translation"),
"additionalProperties",
false);
var expectedTemplateWithJsonSchemaTools =
OrchestrationTemplate.create()
.withTemplate(
List.of(
SingleChatMessage.create()
.role("system")
.content("You are a language translator."),
SingleChatMessage.create()
.role("user")
.content("Whats {{ ?word }} in {{ ?language }}?")))
.withDefaults(Map.of("word", "apple"))
.withJsonSchemaResponse(
ResponseJsonSchema.fromMap(schema, "translation-schema")
.withDescription("Translate the given word into the provided language.")
.withStrict(true))
.withTools(
List.of(
ChatCompletionTool.create()
.type(ChatCompletionTool.TypeEnum.FUNCTION)
.function(
FunctionObject.create()
.name("translate")
.parameters(
Map.of(
"type",
"object",
"additionalProperties",
false,
"required",
List.of("language", "wordToTranslate"),
"properties",
Map.of(
"language", Map.of("type", "string"),
"wordToTranslate", Map.of("type", "string"))))
.description("Translate a word.")
.strict(true))));
assertThat(templateWithJsonSchemaTools).isEqualTo(expectedTemplateWithJsonSchemaTools);
}

@Test
void testTemplateFromLocalFileWithJsonObject() throws IOException {
String promptTemplateWithJsonObject =
"""
name: translator
version: 0.0.1
scenario: translation scenario
spec:
template:
- role: "system"
content: |-
You are a language translator.
- role: "user"
content: |-
Whats {{ ?word }} in {{ ?language }}?
defaults:
word: "apple"
response_format:
type: json_object
""";
var templateWithJsonObject = TemplateConfig.create().fromYaml(promptTemplateWithJsonObject);
var expectedTemplateWithJsonObject =
OrchestrationTemplate.create()
.withTemplate(
List.of(
SingleChatMessage.create()
.role("system")
.content("You are a language translator."),
SingleChatMessage.create()
.role("user")
.content("Whats {{ ?word }} in {{ ?language }}?")))
.withDefaults(Map.of("word", "apple"))
.withJsonResponse();
assertThat(templateWithJsonObject).isEqualTo(expectedTemplateWithJsonObject);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Objects;
Expand Down Expand Up @@ -978,4 +980,46 @@ void testTemplateFromPromptRegistryByScenario() throws IOException {
verify(postRequestedFor(anyUrl()).withRequestBody(equalToJson(request)));
}
}

@Test
void testTemplateFromInput() throws IOException {
stubFor(
post(anyUrl())
.willReturn(
aResponse()
.withBodyFile("templateReferenceResponse.json")
.withHeader("Content-Type", "application/json")));

var promptTemplateYaml =
Files.readString(Path.of("src/test/resources/promptTemplateExample.yaml"));

var template = TemplateConfig.create().fromYaml(promptTemplateYaml);
var configWithTemplate = template != null ? config.withTemplateConfig(template) : config;

var inputParams = Map.of("language", "German");
var prompt = new OrchestrationPrompt(inputParams);

final var response = client.chatCompletion(prompt, configWithTemplate);

try (var requestInputStream = fileLoader.apply("localTemplateRequest.json")) {
final String request = new String(requestInputStream.readAllBytes());
verify(postRequestedFor(anyUrl()).withRequestBody(equalToJson(request)));
}
}

@Test
void testTemplateFromInputThrows() {
assertThatThrownBy(() -> TemplateConfig.create().fromYaml(": what?"))
.isInstanceOf(IOException.class)
.hasMessageContaining("Failed to parse");

prompt = new OrchestrationPrompt(Map.of());
assertThatThrownBy(
() ->
TemplateConfig.create()
.fromYaml(
"name: translator\nversion: 0.0.1\nscenario: translation scenario\nspec:\n template: what?"))
.isInstanceOf(IOException.class)
.hasMessageContaining("Failed to deserialize");
}
}
Loading