Created
February 28, 2025 10:26
-
-
Save Valian/9331de25e6fa714ed2bae07f8f2ad145 to your computer and use it in GitHub Desktop.
Sigils for AI prompts supporting Liquid syntax in Elixir. Requires Solid package.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule Prompts.AIMessageSigils do | |
@moduledoc """ | |
Provides sigils for creating AI message structures with liquid template validation. | |
This module includes: | |
- `~SYSTEM` for system messages | |
- `~ASSISTANT` for assistant messages | |
- `~USER` for user messages | |
Each sigil validates the Liquid template syntax at compile time and returns a | |
message structure with the role and a parsed Solid template. | |
## Examples | |
iex> import Prompts.AIMessageSigils | |
iex> message = ~SYSTEM\"\"\" | |
...> You are a helpful assistant. Speak in {{ language }}. | |
...> \"\"\" | |
iex> message.role | |
:system | |
iex> Solid.render(message.content, %{"language" => "French"}) | |
{:ok, "You are a helpful assistant. Speak in French."} | |
""" | |
# Make SigilLiquid available for internal compilation | |
require Prompts.SigilLiquid | |
# Define a __using__ macro to simplify imports for users | |
defmacro __using__(_opts) do | |
quote do | |
import Prompts.AIMessageSigils | |
# Require SigilLiquid to use LIQUID sigil | |
require Prompts.SigilLiquid | |
# Import all macros from this module | |
end | |
end | |
# System message sigil | |
defmacro sigil_SYSTEM({:<<>>, _meta, [_string]} = ast, modifiers) do | |
quote_result = | |
quote do | |
Prompts.SigilLiquid.sigil_LIQUID(unquote(ast), unquote(modifiers)) | |
end | |
template_expr = Macro.expand(quote_result, __CALLER__) | |
quote do | |
%{role: :system, content: unquote(template_expr)} | |
end | |
end | |
# Assistant message sigil | |
defmacro sigil_ASSISTANT({:<<>>, _meta, [_string]} = ast, modifiers) do | |
quote_result = | |
quote do | |
Prompts.SigilLiquid.sigil_LIQUID(unquote(ast), unquote(modifiers)) | |
end | |
template_expr = Macro.expand(quote_result, __CALLER__) | |
quote do | |
%{role: :assistant, content: unquote(template_expr)} | |
end | |
end | |
# User message sigil | |
defmacro sigil_USER({:<<>>, _meta, [_string]} = ast, modifiers) do | |
quote_result = | |
quote do | |
Prompts.SigilLiquid.sigil_LIQUID(unquote(ast), unquote(modifiers)) | |
end | |
template_expr = Macro.expand(quote_result, __CALLER__) | |
quote do | |
%{role: :user, content: unquote(template_expr)} | |
end | |
end | |
@doc """ | |
Renders a message or list of messages with the provided variables. | |
## Parameters | |
* `messages` - A single message or list of messages to render | |
* `variables` - A map of variables to use in template rendering | |
* `opts` - Options to pass to `Solid.render/3` (default: []) | |
## Examples | |
iex> import Prompts.AIMessageSigils | |
iex> message = ~SYSTEM\"\"\"Hello, {{ name }}!\"\"\" | |
iex> render(message, %{"name" => "World"}) | |
{:ok, [%{role: :system, content: "Hello, World!"}]} | |
iex> import Prompts.AIMessageSigils | |
iex> messages = [ | |
...> ~SYSTEM\"\"\"System: {{ sys_var }}\"\"\" | |
...> ~USER\"\"\"User: {{ user_var }}\"\"\" | |
...> ] | |
iex> render(messages, %{"sys_var" => "Init", "user_var" => "Question"}) | |
{:ok, [ | |
%{role: :system, content: "System: Init"}, | |
%{role: :user, content: "User: Question"} | |
]} | |
iex> # With strict variables option | |
iex> import Prompts.AIMessageSigils | |
iex> message = ~SYSTEM\"\"\"Hello, {{ undefined_var }}!\"\"\" | |
iex> render(message, %{}, strict_variables: true) | |
{:error, "Variable 'undefined_var' not found"} | |
""" | |
# Define the function with default arguments only once | |
def render(messages, variables, opts \\ []) | |
# Handle a list of messages | |
def render(messages, variables, opts) when is_list(messages) do | |
# Process a list of messages | |
results = | |
Enum.map(messages, fn message -> | |
case render_single_message(message, variables, opts) do | |
{:ok, rendered_message} -> {:ok, rendered_message} | |
{:error, reason} -> {:error, reason} | |
end | |
end) | |
# Check if any rendering failed | |
case Enum.find(results, fn result -> match?({:error, _}, result) end) do | |
nil -> | |
# All succeeded | |
{:ok, Enum.map(results, fn {:ok, message} -> message end)} | |
{:error, reason} -> | |
# Return the first error | |
{:error, reason} | |
end | |
end | |
# Handle a single message | |
def render(message, variables, opts) do | |
# Process a single message by wrapping it in a list | |
case render_single_message(message, variables, opts) do | |
{:ok, rendered_message} -> {:ok, [rendered_message]} | |
{:error, reason} -> {:error, reason} | |
end | |
end | |
# Private helper to render a single message | |
defp render_single_message(%{role: role, content: content}, variables, opts) do | |
case Solid.render(content, variables, opts) do | |
{:ok, rendered_content} -> | |
# Convert the rendered content (iolist) to a binary string | |
rendered_string = :erlang.iolist_to_binary(rendered_content) | |
{:ok, %{role: role, content: rendered_string}} | |
# Handle the error format from Solid | |
{:error, errors, _partial_result} when is_list(errors) -> | |
error_message = | |
Enum.map_join(errors, ", ", fn | |
%Solid.UndefinedVariableError{variable: [var]} -> "Variable '#{var}' not found" | |
err -> inspect(err) | |
end) | |
{:error, error_message} | |
{:error, reason} -> | |
{:error, reason} | |
end | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule Prompts.SigilLiquid do | |
@moduledoc """ | |
Provides the `~LIQUID` sigil for validating and compiling Liquid templates using Solid. | |
This sigil validates the template at compile time and returns a compiled Solid template. | |
If the template has syntax errors, it will raise a CompileError with detailed information. | |
## Examples | |
iex> import Prompts.SigilLiquid | |
iex> template = ~LIQUID\"\"\" | |
...> Hello, {{ name }}! | |
...> \"\"\" | |
iex> Solid.render(template, %{"name" => "World"}) | |
{:ok, "Hello, World!"} | |
""" | |
# Import Solid to use parse! function | |
require Solid | |
# Custom sigil for validating and compiling Liquid templates using Solid | |
defmacro sigil_LIQUID({:<<>>, _meta, [string]}, _modifiers) do | |
line = __CALLER__.line | |
file = __CALLER__.file | |
try do | |
# Validate the template during compile time | |
parsed_template = Solid.parse!(string) | |
# Return the parsed template | |
Macro.escape(parsed_template) | |
rescue | |
e in Solid.TemplateError -> | |
# Extract template line number (first element of the tuple) | |
template_line = elem(e.line, 0) | |
# Calculate actual line number in the file | |
actual_line = line + template_line | |
# Extract just the problematic portion of the template | |
template_lines = String.split(string, "\n") | |
context_start = max(0, template_line - 2) | |
context_end = min(length(template_lines), template_line + 2) | |
context_lines = | |
template_lines | |
|> Enum.slice(context_start, context_end - context_start) | |
|> Enum.with_index(line + context_start + 1) | |
|> Enum.map_join("\n", fn {line_text, idx} -> | |
indicator = if idx == actual_line, do: "→ ", else: " " | |
"#{indicator}#{idx}: #{line_text}" | |
end) | |
# Prepare a more helpful error message | |
message = """ | |
Liquid template syntax error at line #{actual_line}: | |
#{context_lines} | |
Error: #{e.reason} | |
""" | |
# Re-raise with better context | |
reraise %CompileError{ | |
file: file, | |
line: actual_line, | |
description: message | |
}, | |
__STACKTRACE__ | |
end | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule Prompts.AIMessageSigilsTest do | |
use ExUnit.Case, async: true | |
# Use the AIMessageSigils module, which handles all required imports | |
use Prompts.AIMessageSigils | |
describe "AI message sigils" do | |
test "~SYSTEM creates a system message" do | |
message = ~SYSTEM""" | |
You are a helpful assistant. | |
""" | |
assert message.role == :system | |
assert is_struct(message.content, Solid.Template) | |
{:ok, rendered} = Solid.render(message.content, %{}) | |
assert is_list(rendered) or is_binary(rendered) | |
assert :erlang.iolist_to_binary(rendered) == "You are a helpful assistant.\n" | |
end | |
test "~ASSISTANT creates an assistant message" do | |
message = ~ASSISTANT""" | |
I'll help you with {{ topic }}. | |
""" | |
assert message.role == :assistant | |
assert is_struct(message.content, Solid.Template) | |
{:ok, rendered} = Solid.render(message.content, %{"topic" => "programming"}) | |
assert is_list(rendered) or is_binary(rendered) | |
assert :erlang.iolist_to_binary(rendered) == "I'll help you with programming.\n" | |
end | |
test "~USER creates a user message" do | |
message = ~USER""" | |
Please explain {{ concept }}. | |
""" | |
assert message.role == :user | |
assert is_struct(message.content, Solid.Template) | |
{:ok, rendered} = Solid.render(message.content, %{"concept" => "quantum physics"}) | |
assert is_list(rendered) or is_binary(rendered) | |
assert :erlang.iolist_to_binary(rendered) == "Please explain quantum physics.\n" | |
end | |
end | |
describe "render/3" do | |
test "renders a single message with variables" do | |
message = ~SYSTEM""" | |
You are a {{ role }} assistant. | |
""" | |
assert {:ok, [%{role: :system, content: "You are a helpful assistant.\n"}]} = | |
render(message, %{"role" => "helpful"}) | |
end | |
test "returns error when rendering fails with strict variables" do | |
message = ~SYSTEM""" | |
You are a {{ missing_var }} assistant. | |
""" | |
# Using strict_variables option to make it fail on missing variables | |
assert {:error, _} = render(message, %{}, strict_variables: true) | |
end | |
test "renders a list of messages" do | |
messages = [ | |
~SYSTEM""" | |
{% if role == "friendly" %} | |
I'm a friendly assistant. | |
{% else %} | |
I'm a helpful assistant. | |
{% endif %} | |
""", | |
~USER""" | |
Help me with {{ topic }}. | |
""" | |
] | |
variables = %{ | |
"role" => "friendly", | |
"topic" => "Elixir programming" | |
} | |
assert {:ok, rendered} = render(messages, variables) | |
assert rendered == [ | |
%{role: :system, content: "You are a friendly assistant.\n"}, | |
%{role: :user, content: "Help me with Elixir programming.\n"} | |
] | |
end | |
test "handles a single message" do | |
message = ~ASSISTANT""" | |
I'm an {{ type }} AI model. | |
""" | |
assert {:ok, [rendered]} = render(message, %{"type" => "advanced"}) | |
assert rendered.role == :assistant | |
assert rendered.content == "I'm an advanced AI model.\n" | |
end | |
test "returns first error when any message fails to render with strict variables" do | |
messages = [ | |
~SYSTEM""" | |
System: {{ sys_var }} | |
""", | |
~USER""" | |
User: {{ undefined_var }} | |
""" | |
] | |
# Using strict_variables option | |
assert {:error, _} = render(messages, %{"sys_var" => "Hello"}, strict_variables: true) | |
end | |
test "succeeds with undefined variables when not in strict mode" do | |
message = ~SYSTEM""" | |
Hello, {{ undefined_var }}! | |
""" | |
# In non-strict mode, undefined variables are replaced with empty strings | |
assert {:ok, [rendered]} = render(message, %{}) | |
assert rendered.role == :system | |
assert rendered.content == "Hello, !\n" | |
end | |
end | |
describe "LIQUID sigil" do | |
import Prompts.SigilLiquid | |
test "can use the LIQUID sigil directly" do | |
template = ~LIQUID""" | |
Hello, {{ name }}! | |
""" | |
assert is_struct(template, Solid.Template) | |
{:ok, rendered} = Solid.render(template, %{"name" => "World"}) | |
assert :erlang.iolist_to_binary(rendered) == "Hello, World!\n" | |
end | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment