Skip to content

Instantly share code, notes, and snippets.

@Valian
Created February 28, 2025 10:26
Show Gist options
  • Save Valian/9331de25e6fa714ed2bae07f8f2ad145 to your computer and use it in GitHub Desktop.
Save Valian/9331de25e6fa714ed2bae07f8f2ad145 to your computer and use it in GitHub Desktop.
Sigils for AI prompts supporting Liquid syntax in Elixir. Requires Solid package.
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
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
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