From 08b432af0d47e796ad412eb36b3b4a8fdb89919b Mon Sep 17 00:00:00 2001 From: Mingi Cho <81455273+ChoMinGi@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:13:31 +0900 Subject: [PATCH] feat: add prompt variable compilation Add PromptTemplate and PromptCompiler utilities for {{variable}} template compilation, matching the Python/JS SDK compile() behavior. --- .../client/prompt/PromptCompiler.java | 53 +++++++ .../client/prompt/PromptTemplate.java | 86 ++++++++++++ .../client/prompt/PromptCompilerTest.java | 101 ++++++++++++++ .../client/prompt/PromptTemplateTest.java | 129 ++++++++++++++++++ 4 files changed, 369 insertions(+) create mode 100644 src/main/java/com/langfuse/client/prompt/PromptCompiler.java create mode 100644 src/main/java/com/langfuse/client/prompt/PromptTemplate.java create mode 100644 src/test/java/com/langfuse/client/prompt/PromptCompilerTest.java create mode 100644 src/test/java/com/langfuse/client/prompt/PromptTemplateTest.java diff --git a/src/main/java/com/langfuse/client/prompt/PromptCompiler.java b/src/main/java/com/langfuse/client/prompt/PromptCompiler.java new file mode 100644 index 0000000..d774b4d --- /dev/null +++ b/src/main/java/com/langfuse/client/prompt/PromptCompiler.java @@ -0,0 +1,53 @@ +package com.langfuse.client.prompt; + +import com.langfuse.client.resources.prompts.types.ChatMessage; +import com.langfuse.client.resources.prompts.types.ChatMessageWithPlaceholders; +import com.langfuse.client.resources.prompts.types.ChatPrompt; +import com.langfuse.client.resources.prompts.types.PlaceholderMessage; +import com.langfuse.client.resources.prompts.types.TextPrompt; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public final class PromptCompiler { + + private PromptCompiler() { + } + + public static String compile(TextPrompt prompt, Map variables) { + return PromptTemplate.compile(prompt.getPrompt(), variables); + } + + public static List compile(ChatPrompt prompt, Map variables) { + if (prompt.getPrompt() == null || prompt.getPrompt().isEmpty()) { + return Collections.emptyList(); + } + + List compiled = new ArrayList<>(); + + for (ChatMessageWithPlaceholders messageOrPlaceholder : prompt.getPrompt()) { + messageOrPlaceholder.visit(new ChatMessageWithPlaceholders.Visitor() { + @Override + public Void visit(ChatMessage chatMessage) { + String compiledContent = PromptTemplate.compile(chatMessage.getContent(), variables); + compiled.add(ChatMessage.builder() + .role(chatMessage.getRole()) + .content(compiledContent) + .build()); + return null; + } + + @Override + public Void visit(PlaceholderMessage placeholderMessage) { + // Placeholder messages are skipped in this version. + // A future version may support placeholder expansion. + return null; + } + }); + } + + return Collections.unmodifiableList(compiled); + } +} diff --git a/src/main/java/com/langfuse/client/prompt/PromptTemplate.java b/src/main/java/com/langfuse/client/prompt/PromptTemplate.java new file mode 100644 index 0000000..6b4be78 --- /dev/null +++ b/src/main/java/com/langfuse/client/prompt/PromptTemplate.java @@ -0,0 +1,86 @@ +package com.langfuse.client.prompt; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public final class PromptTemplate { + + private static final String OPENING = "{{"; + private static final String CLOSING = "}}"; + + private PromptTemplate() { + } + + public static String compile(String template, Map variables) { + if (template == null || template.isEmpty()) { + return template; + } + if (variables == null || variables.isEmpty()) { + return template; + } + + StringBuilder result = new StringBuilder(); + int currentIndex = 0; + + while (currentIndex < template.length()) { + int openIndex = template.indexOf(OPENING, currentIndex); + if (openIndex == -1) { + result.append(template, currentIndex, template.length()); + break; + } + + int closeIndex = template.indexOf(CLOSING, openIndex + OPENING.length()); + if (closeIndex == -1) { + result.append(template, currentIndex, template.length()); + break; + } + + result.append(template, currentIndex, openIndex); + + String variableName = template.substring(openIndex + OPENING.length(), closeIndex).trim(); + + if (variables.containsKey(variableName)) { + Object value = variables.get(variableName); + result.append(value != null ? String.valueOf(value) : ""); + } else { + result.append(template, openIndex, closeIndex + CLOSING.length()); + } + + currentIndex = closeIndex + CLOSING.length(); + } + + return result.toString(); + } + + public static List extractVariables(String template) { + if (template == null || template.isEmpty()) { + return Collections.emptyList(); + } + + List variables = new ArrayList<>(); + int currentIndex = 0; + + while (currentIndex < template.length()) { + int openIndex = template.indexOf(OPENING, currentIndex); + if (openIndex == -1) { + break; + } + + int closeIndex = template.indexOf(CLOSING, openIndex + OPENING.length()); + if (closeIndex == -1) { + break; + } + + String variableName = template.substring(openIndex + OPENING.length(), closeIndex).trim(); + if (!variableName.isEmpty() && !variables.contains(variableName)) { + variables.add(variableName); + } + + currentIndex = closeIndex + CLOSING.length(); + } + + return Collections.unmodifiableList(variables); + } +} diff --git a/src/test/java/com/langfuse/client/prompt/PromptCompilerTest.java b/src/test/java/com/langfuse/client/prompt/PromptCompilerTest.java new file mode 100644 index 0000000..8dbfe4c --- /dev/null +++ b/src/test/java/com/langfuse/client/prompt/PromptCompilerTest.java @@ -0,0 +1,101 @@ +package com.langfuse.client.prompt; + +import com.langfuse.client.resources.prompts.types.ChatMessage; +import com.langfuse.client.resources.prompts.types.ChatMessageWithPlaceholders; +import com.langfuse.client.resources.prompts.types.ChatPrompt; +import com.langfuse.client.resources.prompts.types.PlaceholderMessage; +import com.langfuse.client.resources.prompts.types.TextPrompt; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class PromptCompilerTest { + + @Test + void compileTextPrompt() { + TextPrompt prompt = TextPrompt.builder() + .name("test") + .version(1) + .config(Map.of()) + .prompt("Hello {{name}}, you are a {{role}}") + .build(); + + String result = PromptCompiler.compile(prompt, Map.of("name", "Alice", "role", "developer")); + assertEquals("Hello Alice, you are a developer", result); + } + + @Test + void compileTextPromptMissingVariable() { + TextPrompt prompt = TextPrompt.builder() + .name("test") + .version(1) + .config(Map.of()) + .prompt("Hello {{name}}, age {{age}}") + .build(); + + String result = PromptCompiler.compile(prompt, Map.of("name", "Alice")); + assertEquals("Hello Alice, age {{age}}", result); + } + + @Test + void compileChatPrompt() { + ChatPrompt prompt = ChatPrompt.builder() + .name("test") + .version(1) + .config(Map.of()) + .prompt(List.of( + ChatMessageWithPlaceholders.of( + ChatMessage.builder().role("system").content("You are a {{role}}").build()), + ChatMessageWithPlaceholders.of( + ChatMessage.builder().role("user").content("{{user_input}}").build()) + )) + .build(); + + List result = PromptCompiler.compile(prompt, Map.of("role", "helpful assistant", "user_input", "Hi")); + + assertEquals(2, result.size()); + assertEquals("system", result.get(0).getRole()); + assertEquals("You are a helpful assistant", result.get(0).getContent()); + assertEquals("user", result.get(1).getRole()); + assertEquals("Hi", result.get(1).getContent()); + } + + @Test + void compileChatPromptSkipsPlaceholders() { + ChatPrompt prompt = ChatPrompt.builder() + .name("test") + .version(1) + .config(Map.of()) + .prompt(List.of( + ChatMessageWithPlaceholders.of( + ChatMessage.builder().role("system").content("You are a {{role}}").build()), + ChatMessageWithPlaceholders.of( + PlaceholderMessage.builder().name("examples").build()), + ChatMessageWithPlaceholders.of( + ChatMessage.builder().role("user").content("{{question}}").build()) + )) + .build(); + + List result = PromptCompiler.compile(prompt, Map.of("role", "tutor", "question", "What is Java?")); + + assertEquals(2, result.size()); + assertEquals("You are a tutor", result.get(0).getContent()); + assertEquals("What is Java?", result.get(1).getContent()); + } + + @Test + void compileChatPromptEmpty() { + ChatPrompt prompt = ChatPrompt.builder() + .name("test") + .version(1) + .config(Map.of()) + .build(); + + List result = PromptCompiler.compile(prompt, Map.of("x", "y")); + assertTrue(result.isEmpty()); + } +} diff --git a/src/test/java/com/langfuse/client/prompt/PromptTemplateTest.java b/src/test/java/com/langfuse/client/prompt/PromptTemplateTest.java new file mode 100644 index 0000000..6c04182 --- /dev/null +++ b/src/test/java/com/langfuse/client/prompt/PromptTemplateTest.java @@ -0,0 +1,129 @@ +package com.langfuse.client.prompt; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class PromptTemplateTest { + + @Test + void basicReplacement() { + String result = PromptTemplate.compile("Hello {{name}}", Map.of("name", "World")); + assertEquals("Hello World", result); + } + + @Test + void multipleVariables() { + String template = "{{greeting}}, {{name}}! Welcome to {{place}}."; + Map vars = Map.of("greeting", "Hi", "name", "Alice", "place", "Langfuse"); + assertEquals("Hi, Alice! Welcome to Langfuse.", PromptTemplate.compile(template, vars)); + } + + @Test + void duplicateVariables() { + String result = PromptTemplate.compile("{{x}} and {{x}}", Map.of("x", "same")); + assertEquals("same and same", result); + } + + @Test + void missingVariableKeepsOriginal() { + String result = PromptTemplate.compile("Hello {{name}}, age {{age}}", Map.of("name", "Alice")); + assertEquals("Hello Alice, age {{age}}", result); + } + + @Test + void extraVariablesIgnored() { + String result = PromptTemplate.compile("Hello {{name}}", Map.of("name", "Alice", "extra", "ignored")); + assertEquals("Hello Alice", result); + } + + @Test + void nullValueBecomesEmpty() { + Map vars = new HashMap<>(); + vars.put("name", null); + assertEquals("Hello ", PromptTemplate.compile("Hello {{name}}", vars)); + } + + @Test + void noVariablesReturnsOriginal() { + assertEquals("No variables here", PromptTemplate.compile("No variables here", Map.of("x", "y"))); + } + + @Test + void emptyTemplate() { + assertEquals("", PromptTemplate.compile("", Map.of("x", "y"))); + } + + @Test + void nullTemplate() { + assertNull(PromptTemplate.compile(null, Map.of("x", "y"))); + } + + @Test + void nullVariables() { + assertEquals("Hello {{name}}", PromptTemplate.compile("Hello {{name}}", null)); + } + + @Test + void emptyVariables() { + assertEquals("Hello {{name}}", PromptTemplate.compile("Hello {{name}}", Collections.emptyMap())); + } + + @Test + void whitespaceInVariableName() { + String result = PromptTemplate.compile("Hello {{ name }}", Map.of("name", "Alice")); + assertEquals("Hello Alice", result); + } + + @Test + void unclosedBracketKeepsOriginal() { + assertEquals("Hello {{name", PromptTemplate.compile("Hello {{name", Map.of("name", "Alice"))); + } + + @Test + void singleCurlyBracesIgnored() { + assertEquals("Hello {name}", PromptTemplate.compile("Hello {name}", Map.of("name", "Alice"))); + } + + @Test + void caseSensitive() { + String result = PromptTemplate.compile("{{Name}} and {{name}}", Map.of("Name", "A", "name", "b")); + assertEquals("A and b", result); + } + + @Test + void objectValueUsesToString() { + String result = PromptTemplate.compile("Count: {{n}}", Map.of("n", 42)); + assertEquals("Count: 42", result); + } + + @Test + void jsonInTemplate() { + String template = "Config: {\"key\": \"{{value}}\"}"; + assertEquals("Config: {\"key\": \"test\"}", PromptTemplate.compile(template, Map.of("value", "test"))); + } + + @Test + void extractVariablesBasic() { + List vars = PromptTemplate.extractVariables("Hello {{name}}, welcome to {{place}}"); + assertEquals(List.of("name", "place"), vars); + } + + @Test + void extractVariablesDeduplicates() { + List vars = PromptTemplate.extractVariables("{{x}} and {{x}} and {{y}}"); + assertEquals(List.of("x", "y"), vars); + } + + @Test + void extractVariablesEmpty() { + assertTrue(PromptTemplate.extractVariables("no variables").isEmpty()); + assertTrue(PromptTemplate.extractVariables("").isEmpty()); + assertTrue(PromptTemplate.extractVariables(null).isEmpty()); + } +}