Current section

Files

Jump to
phoenix_kit_ai lib phoenix_kit_ai prompt.ex
Raw

lib/phoenix_kit_ai/prompt.ex

defmodule PhoenixKitAI.Prompt do
@moduledoc """
AI prompt schema for PhoenixKit AI system.
A prompt is a reusable text template with variable substitution support.
Variables use the `{{VariableName}}` syntax and are automatically extracted
from the content when saved.
## Schema Fields
### Identity
- `name`: Display name for the prompt (unique)
- `slug`: URL-friendly identifier (auto-generated from name, unique)
- `description`: Optional description of the prompt's purpose
### Content
- `content`: The prompt template text with optional `{{variables}}`
- `variables`: Auto-extracted variable names from content
### Status
- `enabled`: Whether the prompt is active
- `sort_order`: Display order for listing
### Usage Tracking
- `usage_count`: Number of times the prompt has been used
- `last_used_at`: Timestamp of the last usage
### Metadata
- `metadata`: Flexible JSON storage for additional data
## Variable Syntax
Variables use double curly braces: `{{VariableName}}`
- Variable names must be alphanumeric with underscores
- Variables are case-sensitive
- Unmatched variables remain in the output as-is
## Usage Examples
# Create a prompt
{:ok, prompt} = PhoenixKitAI.create_prompt(%{
name: "Translator",
content: "Translate the following text to {{Language}}:\\n\\n{{Text}}"
})
# Variables auto-extracted: ["Language", "Text"]
# Render with variables
{:ok, text} = PhoenixKitAI.Prompt.render(prompt, %{
"Language" => "French",
"Text" => "Hello, world!"
})
# => "Translate the following text to French:\\n\\nHello, world!"
# Use with AI completion
{:ok, response} = PhoenixKitAI.ask_with_prompt(
endpoint_uuid,
prompt.uuid,
%{"Language" => "Spanish", "Text" => "Good morning"}
)
"""
use Ecto.Schema
use PhoenixKit.SchemaPrefix
import Ecto.Changeset
alias PhoenixKit.Utils.Date, as: UtilsDate
alias PhoenixKit.Utils.Slug
@type t :: %__MODULE__{}
@primary_key {:uuid, UUIDv7, autogenerate: true}
# Regex for extracting variable names from content
@variable_regex ~r/\{\{(\w+)\}\}/
@derive {Jason.Encoder,
only: [
:uuid,
:name,
:slug,
:description,
:system_prompt,
:content,
:variables,
:enabled,
:sort_order,
:usage_count,
:last_used_at,
:metadata,
:inserted_at,
:updated_at
]}
schema "phoenix_kit_ai_prompts" do
# Identity
field(:name, :string)
field(:slug, :string)
field(:description, :string)
# Content
field(:system_prompt, :string)
field(:content, :string)
field(:variables, {:array, :string}, default: [])
# Status
field(:enabled, :boolean, default: true)
field(:sort_order, :integer, default: 0)
# Usage tracking
field(:usage_count, :integer, default: 0)
field(:last_used_at, :utc_datetime)
# Flexible metadata
field(:metadata, :map, default: %{})
timestamps(type: :utc_datetime)
end
@doc """
Creates a changeset for prompt creation and updates.
"""
def changeset(prompt, attrs) do
prompt
|> cast(attrs, [
:name,
:slug,
:description,
:system_prompt,
:content,
:variables,
:enabled,
:sort_order,
:usage_count,
:last_used_at,
:metadata
])
|> validate_required([:name, :content])
|> validate_length(:name, min: 1, max: 100)
|> validate_length(:description, max: 500)
|> unique_constraint(:name, name: :phoenix_kit_ai_prompts_name_uidx)
|> unique_constraint(:slug, name: :phoenix_kit_ai_prompts_slug_uidx)
|> maybe_generate_slug()
|> auto_extract_variables()
end
@doc """
Creates a changeset for incrementing usage.
"""
def usage_changeset(prompt) do
change(prompt,
usage_count: (prompt.usage_count || 0) + 1,
last_used_at: UtilsDate.utc_now()
)
end
@doc """
Extracts variable names from content.
Variables are matched using the `{{VariableName}}` syntax.
Returns a list of unique variable names in order of appearance.
## Examples
iex> PhoenixKitAI.Prompt.extract_variables("Hello {{Name}}, welcome to {{Place}}!")
["Name", "Place"]
iex> PhoenixKitAI.Prompt.extract_variables("No variables here")
[]
iex> PhoenixKitAI.Prompt.extract_variables("{{A}} and {{B}} and {{A}} again")
["A", "B"]
"""
def extract_variables(content) when is_binary(content) do
@variable_regex
|> Regex.scan(content)
|> Enum.map(fn [_full, name] -> name end)
|> Enum.uniq()
end
def extract_variables(_), do: []
@doc """
Renders a prompt by replacing variables with provided values.
Variables not found in the values map remain as-is in the output.
Supports both string and atom keys in the values map.
## Examples
iex> prompt = %PhoenixKitAI.Prompt{content: "Hello {{Name}}!"}
iex> PhoenixKitAI.Prompt.render(prompt, %{"Name" => "World"})
{:ok, "Hello World!"}
iex> prompt = %PhoenixKitAI.Prompt{content: "Translate to {{Lang}}: {{Text}}"}
iex> PhoenixKitAI.Prompt.render(prompt, %{Lang: "French", Text: "Hello"})
{:ok, "Translate to French: Hello"}
iex> prompt = %PhoenixKitAI.Prompt{content: "Missing {{Var}}"}
iex> PhoenixKitAI.Prompt.render(prompt, %{})
{:ok, "Missing {{Var}}"}
"""
def render(%__MODULE__{content: content}, variables) when is_map(variables) do
result =
Regex.replace(@variable_regex, content, fn full_match, var_name ->
get_variable_value(variables, var_name, full_match)
end)
{:ok, result}
end
def render(%__MODULE__{content: content}, _variables) do
{:ok, content}
end
@doc """
Renders the system prompt by replacing variables with provided values.
Returns `{:ok, rendered}` if system_prompt is set, or `{:ok, nil}` if not.
## Examples
iex> prompt = %PhoenixKitAI.Prompt{system_prompt: "You are a {{Role}}"}
iex> PhoenixKitAI.Prompt.render_system_prompt(prompt, %{"Role" => "translator"})
{:ok, "You are a translator"}
iex> prompt = %PhoenixKitAI.Prompt{system_prompt: nil}
iex> PhoenixKitAI.Prompt.render_system_prompt(prompt, %{})
{:ok, nil}
"""
def render_system_prompt(%__MODULE__{system_prompt: nil}, _variables), do: {:ok, nil}
def render_system_prompt(%__MODULE__{system_prompt: ""}, _variables), do: {:ok, nil}
def render_system_prompt(%__MODULE__{system_prompt: system_prompt}, variables)
when is_map(variables) do
result =
Regex.replace(@variable_regex, system_prompt, fn full_match, var_name ->
get_variable_value(variables, var_name, full_match)
end)
{:ok, result}
end
def render_system_prompt(%__MODULE__{system_prompt: system_prompt}, _variables) do
{:ok, system_prompt}
end
@doc """
Renders content string directly (without a Prompt struct).
Useful for previewing variable substitution.
## Examples
iex> PhoenixKitAI.Prompt.render_content("Hello {{Name}}!", %{"Name" => "World"})
{:ok, "Hello World!"}
"""
def render_content(content, variables) when is_binary(content) and is_map(variables) do
result =
Regex.replace(@variable_regex, content, fn full_match, var_name ->
get_variable_value(variables, var_name, full_match)
end)
{:ok, result}
end
def render_content(content, _) when is_binary(content), do: {:ok, content}
@doc """
Validates that all required variables are provided.
Returns `:ok` if all variables are present, or `{:error, missing}` with
a list of missing variable names.
## Examples
iex> prompt = %PhoenixKitAI.Prompt{variables: ["Name", "Age"]}
iex> PhoenixKitAI.Prompt.validate_variables(prompt, %{"Name" => "John", "Age" => "30"})
:ok
iex> prompt = %PhoenixKitAI.Prompt{variables: ["Name", "Age"]}
iex> PhoenixKitAI.Prompt.validate_variables(prompt, %{"Name" => "John"})
{:error, ["Age"]}
"""
def validate_variables(%__MODULE__{variables: variables}, provided) when is_map(provided) do
provided_keys =
provided
|> Map.keys()
|> Enum.map(&to_string/1)
|> MapSet.new()
missing =
variables
|> Enum.reject(fn var -> MapSet.member?(provided_keys, var) end)
if Enum.empty?(missing) do
:ok
else
{:error, missing}
end
end
def validate_variables(%__MODULE__{variables: []}, _), do: :ok
def validate_variables(%__MODULE__{variables: vars}, _), do: {:error, vars}
@doc """
Returns a truncated preview of the content for display.
"""
def content_preview(nil), do: ""
def content_preview(""), do: ""
def content_preview(content) when is_binary(content) do
content
|> String.slice(0, 100)
|> String.replace(~r/\s+/, " ")
|> String.trim()
|> maybe_add_ellipsis(String.length(content))
end
@doc """
Generates a URL-friendly slug from the name.
Uses `PhoenixKit.Utils.Slug.slugify/1` for consistent slug generation.
## Examples
iex> PhoenixKitAI.Prompt.generate_slug("My Cool Prompt!")
"my-cool-prompt"
iex> PhoenixKitAI.Prompt.generate_slug("Translate to French")
"translate-to-french"
"""
def generate_slug(nil), do: ""
def generate_slug(""), do: ""
def generate_slug(name) when is_binary(name) do
Slug.slugify(name)
end
@doc """
Returns the variable regex pattern.
"""
def variable_regex, do: @variable_regex
@doc """
Checks if a prompt has any variables defined.
## Examples
iex> prompt = %PhoenixKitAI.Prompt{variables: ["Name", "Age"]}
iex> PhoenixKitAI.Prompt.has_variables?(prompt)
true
iex> prompt = %PhoenixKitAI.Prompt{variables: []}
iex> PhoenixKitAI.Prompt.has_variables?(prompt)
false
"""
def has_variables?(%__MODULE__{variables: variables}) do
is_list(variables) and not Enum.empty?(variables)
end
def has_variables?(_), do: false
@doc """
Returns the number of variables in a prompt.
## Examples
iex> prompt = %PhoenixKitAI.Prompt{variables: ["Name", "Age"]}
iex> PhoenixKitAI.Prompt.variable_count(prompt)
2
"""
def variable_count(%__MODULE__{variables: variables}) when is_list(variables) do
length(variables)
end
def variable_count(_), do: 0
@doc """
Formats variables for display in the UI.
Returns a string like "{{Name}}, {{Age}}" for easy display.
## Examples
iex> prompt = %PhoenixKitAI.Prompt{variables: ["Name", "Age"]}
iex> PhoenixKitAI.Prompt.format_variables_for_display(prompt)
"{{Name}}, {{Age}}"
iex> prompt = %PhoenixKitAI.Prompt{variables: []}
iex> PhoenixKitAI.Prompt.format_variables_for_display(prompt)
""
"""
def format_variables_for_display(%__MODULE__{variables: variables}) when is_list(variables) do
Enum.map_join(variables, ", ", fn var -> "{{#{var}}}" end)
end
def format_variables_for_display(_), do: ""
# Valid variable name: letter or underscore, followed by letters/digits/underscores.
# Matches common templating conventions and disallows leading digits.
@valid_variable_name ~r/^[a-zA-Z_][a-zA-Z0-9_]*$/
@doc """
Checks if content has valid variable syntax.
A valid variable name starts with a letter or underscore and contains only
letters, digits, and underscores. Leading digits are not allowed.
Returns `true` if all `{{...}}` patterns contain valid variable names,
or if there are no variables.
## Examples
iex> PhoenixKitAI.Prompt.valid_content?("Hello {{Name}}!")
true
iex> PhoenixKitAI.Prompt.valid_content?("No variables here")
true
iex> PhoenixKitAI.Prompt.valid_content?("Hello {{User Name}}!")
false
iex> PhoenixKitAI.Prompt.valid_content?("Hello {{1st_name}}!")
false
"""
def valid_content?(content) when is_binary(content) do
# Find all {{...}} patterns including potentially invalid ones
all_patterns = Regex.scan(~r/\{\{([^}]+)\}\}/, content)
# Check if all captured groups are valid variable names
Enum.all?(all_patterns, fn [_full, inner] ->
Regex.match?(@valid_variable_name, inner)
end)
end
def valid_content?(_), do: false
@doc """
Returns a list of invalid variable patterns in the content.
Useful for showing validation errors in the UI.
## Examples
iex> PhoenixKitAI.Prompt.invalid_variables("Hello {{Name}}!")
[]
iex> PhoenixKitAI.Prompt.invalid_variables("{{User Name}} and {{ok}}")
["User Name"]
"""
def invalid_variables(content) when is_binary(content) do
~r/\{\{([^}]+)\}\}/
|> Regex.scan(content)
|> Enum.map(fn [_full, inner] -> inner end)
|> Enum.reject(fn inner -> Regex.match?(@valid_variable_name, inner) end)
end
def invalid_variables(_), do: []
@doc """
Merges provided variables with defaults for missing ones.
Returns a map with all required variables, using defaults for any not provided.
## Examples
iex> prompt = %PhoenixKitAI.Prompt{variables: ["Name", "Age"]}
iex> PhoenixKitAI.Prompt.merge_with_defaults(prompt, %{"Name" => "John"}, %{"Age" => "Unknown"})
%{"Name" => "John", "Age" => "Unknown"}
"""
def merge_with_defaults(%__MODULE__{variables: variables}, provided, defaults)
when is_map(provided) and is_map(defaults) do
variables
|> Enum.reduce(provided, fn var, acc ->
if Map.has_key?(acc, var) or Map.has_key?(acc, String.to_atom(var)) do
acc
else
Map.put(acc, var, Map.get(defaults, var) || Map.get(defaults, String.to_atom(var)))
end
end)
end
def merge_with_defaults(_, provided, _) when is_map(provided), do: provided
def merge_with_defaults(_, _, _), do: %{}
# Private functions
defp maybe_generate_slug(changeset) do
# Always regenerate slug from name when name changes
# (slug field is readonly in the UI, so users can't manually set it)
case get_change(changeset, :name) do
nil ->
# Name didn't change, keep existing slug
changeset
name when is_binary(name) and name != "" ->
# Name changed, regenerate slug
put_change(changeset, :slug, Slug.slugify(name))
_ ->
# Name cleared, clear slug too
put_change(changeset, :slug, nil)
end
end
defp auto_extract_variables(changeset) do
system_prompt = get_field(changeset, :system_prompt) || ""
content = get_field(changeset, :content) || ""
combined = system_prompt <> "\n" <> content
if String.trim(combined) != "" do
variables = extract_variables(combined)
put_change(changeset, :variables, variables)
else
changeset
end
end
defp get_variable_value(variables, var_name, default) do
# Try string key first, then atom key
case Map.get(variables, var_name) do
nil ->
case Map.get(variables, String.to_atom(var_name)) do
nil -> default
value -> to_string(value)
end
value ->
to_string(value)
end
rescue
# String.to_atom can fail for invalid atoms, fall back to default
ArgumentError -> default
end
defp maybe_add_ellipsis(text, original_length) when original_length > 100, do: text <> "..."
defp maybe_add_ellipsis(text, _), do: text
end