Skip to content

Commit 7152799

Browse files
authored
fix: compile render issue (#404)
1 parent 8bbd99d commit 7152799

File tree

6 files changed

+124
-33
lines changed

6 files changed

+124
-33
lines changed

java.MODULE.bazel

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,12 @@ maven.install(
3939
"com.fasterxml.jackson.core:jackson-databind:2.20.1",
4040
"com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1",
4141
],
42-
lock_file = "//:maven_install.json",
4342
known_contributing_modules = [
4443
"bazel_worker_java",
4544
"dotprompt",
4645
"protobuf",
4746
],
47+
lock_file = "//:maven_install.json",
4848
repositories = [
4949
"https://repo1.maven.org/maven2",
5050
],

java/com/google/dotprompt/Dotprompt.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -700,12 +700,16 @@ private RenderedPrompt renderWithTemplate(
700700
mergedData.putAll(data);
701701
}
702702

703-
// Context handling
703+
// Context handling: Add all context entries as @-prefixed variables
704+
// Each key in context becomes accessible as @key in templates
705+
// e.g., context: {state: {...}, auth: {...}} creates @state and @auth
704706
Map<String, Object> contextData = new HashMap<>();
705707
if (data != null && data.containsKey("context")) {
708+
@SuppressWarnings("unchecked")
706709
Map<String, Object> ctx = (Map<String, Object>) data.get("context");
707-
if (ctx.containsKey("state")) {
708-
contextData.put("state", ctx.get("state"));
710+
if (ctx != null) {
711+
// Add all context entries, not just state
712+
contextData.putAll(ctx);
709713
}
710714
}
711715

java/com/google/dotprompt/SpecTest.java

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,23 @@ private void runTestGroup(Map<String, Object> group) throws Exception {
142142
Map<String, Object> options = (Map<String, Object>) test.get("options");
143143
Map<String, Object> expect = (Map<String, Object>) test.get("expect");
144144

145-
Map<String, Object> renderData = data;
145+
// Build render data: merge input values into top-level, but also preserve context
146+
// for @-prefixed variable access and messages for history helper
147+
Map<String, Object> renderData = new HashMap<>();
146148
if (data != null && data.containsKey("input")) {
147-
renderData = (Map<String, Object>) data.get("input");
149+
@SuppressWarnings("unchecked")
150+
Map<String, Object> inputData = (Map<String, Object>) data.get("input");
151+
if (inputData != null) {
152+
renderData.putAll(inputData);
153+
}
154+
}
155+
// Preserve context for @-prefixed variables (e.g., @auth, @state, @user)
156+
if (data != null && data.containsKey("context")) {
157+
renderData.put("context", data.get("context"));
158+
}
159+
// Preserve messages for {{history}} helper
160+
if (data != null && data.containsKey("messages")) {
161+
renderData.put("messages", data.get("messages"));
148162
}
149163

150164
try {

python/dotpromptz/tests/dotpromptz/dotprompt_test.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@
3939
import pytest
4040

4141
from dotpromptz.dotprompt import Dotprompt, _identify_partials
42-
from dotpromptz.typing import ModelConfigT, ParsedPrompt, PromptMetadata, ToolDefinition
42+
from dotpromptz.typing import (
43+
ModelConfigT,
44+
ParsedPrompt,
45+
PromptMetadata,
46+
ToolDefinition,
47+
)
4348
from handlebarrz import HelperFn, HelperOptions
4449

4550

@@ -144,6 +149,29 @@ def test_define_tool(mock_handlebars: Mock) -> None:
144149
assert result == dotprompt
145150

146151

152+
class TestCompileRender(IsolatedAsyncioTestCase):
153+
async def test_compile_render_mock(self) -> None:
154+
"""Test that handlebarrz compile produces a working render function.
155+
156+
The handlebarrz.compile() method returns a function that takes:
157+
- context: A dict with the template variables
158+
- options: RuntimeOptions with a 'data' key for @ variables
159+
"""
160+
dotprompt = Dotprompt()
161+
template_source = 'hello {{name}} ({{@state.name}}, {{@auth.email}})'
162+
163+
render_fn = dotprompt._handlebars.compile(template_source)
164+
165+
# Call with positional args as the compiled function expects
166+
# context dict for regular variables, RuntimeOptions for @ variables
167+
result = render_fn(
168+
{'name': 'foo'}, # context - accessed as {{name}}
169+
{'data': {'state': {'name': 'bar'}, 'auth': {'email': 'a@b.c'}}}, # RuntimeOptions - accessed as {{@...}}
170+
)
171+
172+
assert result == 'hello foo (bar, a@b.c)'
173+
174+
147175
@patch('dotpromptz.dotprompt.parse_document')
148176
def test_parse(mock_parse_document: Mock, mock_handlebars: Mock) -> None:
149177
"""Test parsing a prompt."""

rs/dotprompt/src/dotprompt.rs

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -341,41 +341,37 @@ impl Dotprompt {
341341
},
342342
);
343343

344-
// Add @state from context.state if available
344+
// Add all context variables as @-prefixed variables
345+
// Each key in context becomes accessible as @key in templates
346+
// e.g., context: {state: {...}, auth: {...}} creates @state and @auth
347+
let mut template_to_render = parsed.template.clone();
345348
if let (serde_json::Value::Object(map), Some(context)) =
346349
(&mut render_context, &data.context)
347350
{
348-
// context is HashMap<String, Value>, get "state" key
349-
// Add state as __state (workaround for Handlebars @ prefix)
350-
if let Some(state) = context.get("state") {
351-
if let Some(state_obj) = state.as_object() {
352-
for (k, v) in state_obj {
353-
// Add each state field as __state.field
354-
let at_state = map
355-
.entry("__state".to_string())
356-
.or_insert(serde_json::Value::Object(serde_json::Map::new()));
357-
if let serde_json::Value::Object(at_state_map) = at_state {
358-
at_state_map.insert(k.clone(), v.clone());
359-
}
360-
}
361-
} else {
362-
// If state is not an object, just insert it directly
363-
map.insert("__state".to_string(), state.clone());
364-
}
351+
for (key, value) in context {
352+
// Use __ctx_{key} as workaround since Handlebars treats @ as private data prefix
353+
let internal_key = format!("__ctx_{key}");
354+
map.insert(internal_key.clone(), value.clone());
355+
356+
// Replace all occurrences of @{key} with __ctx_{key} in template
357+
template_to_render = template_to_render
358+
.replace(&format!("{{{{@{key}."), &format!("{{{{__{key}."))
359+
.replace(&format!("{{{{ @{key}."), &format!("{{{{ __{key}."));
360+
361+
// Also handle shortcut for just the key (e.g., {{@isAdmin}})
362+
template_to_render = template_to_render
363+
.replace(&format!("{{{{@{key}}}}}"), &format!("{{{{__{key}}}}}"))
364+
.replace(&format!("{{{{ @{key} }}}}"), &format!("{{{{ __{key} }}}}"));
365+
366+
// Insert with __ prefix for template access
367+
map.insert(format!("__{key}"), value.clone());
365368
}
366369
}
367370

368-
// Preprocess template to replace @state with __state for Handlebars compatibility
369-
// Handlebars treats @ as special prefix for private data, so we use __state as workaround
370-
let preprocessed_template = parsed
371-
.template
372-
.replace("{{@state.", "{{__state.")
373-
.replace("{{ @state.", "{{ __state.");
374-
375371
// Render template
376372
let rendered_string = self
377373
.handlebars
378-
.render_template(&preprocessed_template, &render_context)
374+
.render_template(&template_to_render, &render_context)
379375
.map_err(|e| DotpromptError::RenderError(e.to_string()))?;
380376

381377
// Convert to messages (passing data for history)

spec/metadata.yaml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,55 @@
5959
- role: user
6060
content: [{ text: "Current count is 100\nStatus is pending\n" }]
6161

62+
# Tests accessing arbitrary context variables beyond @state, such as
63+
# @auth and @user. Any key in the context object should be accessible
64+
# as an @ variable in the template.
65+
- name: metadata_context_variables
66+
template: |
67+
Hello {{name}} ({{@auth.email}}, {{@user.role}})
68+
tests:
69+
- desc: accesses arbitrary context variables
70+
data:
71+
input:
72+
name: "Alice"
73+
context:
74+
auth:
75+
email: "alice@example.com"
76+
user:
77+
role: "admin"
78+
expect:
79+
messages:
80+
- role: user
81+
content: [{ text: "Hello Alice (alice@example.com, admin)\n" }]
82+
83+
- desc: handles missing context variables gracefully
84+
data:
85+
input:
86+
name: "Bob"
87+
context:
88+
auth:
89+
email: "bob@example.com"
90+
expect:
91+
messages:
92+
- role: user
93+
content: [{ text: "Hello Bob (bob@example.com, )\n" }]
94+
95+
- desc: handles deeply nested context variables
96+
data:
97+
input:
98+
name: "Carol"
99+
context:
100+
auth:
101+
email: "carol@example.com"
102+
permissions:
103+
canEdit: true
104+
user:
105+
role: "editor"
106+
expect:
107+
messages:
108+
- role: user
109+
content: [{ text: "Hello Carol (carol@example.com, editor)\n" }]
110+
62111
# Tests that raw frontmatter is preserved alongside parsed frontmatter,
63112
# allowing access to both structured and unstructured metadata.
64113
- name: raw

0 commit comments

Comments
 (0)