Packages

phoenix_kit

1.7.118
1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules ai translation.ex
Raw

lib/modules/ai/translation.ex

defmodule PhoenixKit.Modules.AI.Translation do
@moduledoc """
Generic AI translation helper. Translates a `%{field_name => text}`
map from `source_lang` to `target_lang` using the optional
`PhoenixKitAI` plugin, and parses the structured response back into
the same shape.
Designed to be the single orchestration layer shared by every
feature module that wants AI translation — `phoenix_kit_publishing`,
`phoenix_kit_projects`, future consumers. Each module wraps this
helper in its own Oban worker (or controller action) and owns the
per-module storage write, broadcasts, caching, and per-resource
activity log entry.
## What lives here
- Prompt rendering with `{{SourceLanguage}}` / `{{TargetLanguage}}`
/ arbitrary field-name variables.
- The `PhoenixKitAI.ask_with_prompt/4` call, guarded so absence of
the plugin returns `{:error, :ai_not_installed}` instead of
raising.
- A structured-response parser for the `---FIELD_NAME---` shape
that publishing's prompt template ships with. Generalised — any
list of field names is accepted, ordering follows the input.
- Error normalisation: every failure path returns
`{:error, atom_or_tuple}` so callers can pattern-match without
knowing whether the failure came from the plugin, the parser, or
a network blip.
- A single core activity-log entry (`core.ai_translation.requested`)
on every dispatched request — gives Max a unified audit trail of
AI token spend regardless of which feature module triggered it.
Modules still log their own per-resource action (e.g.
`publishing.translation.added`, `projects.translation.added`).
## What does NOT live here
- Storage of the translation result — each consumer owns its own
write path (publishing's `publishing_content` rows, projects'
`Project.translations` JSONB, etc.).
- Broadcasts — each consumer has its own PubSub topic shape.
- Per-language status (in-flight, completed, errored) — that's the
consumer's worker and the host UI.
- Retry policy / queue choice — each consumer's Oban worker picks
its own queue, max-attempts, and uniqueness constraints. This
module is a synchronous function-call shape; consumers wrap it.
## Usage
Translation.translate_fields(
endpoint_uuid,
prompt_uuid,
"en",
"es",
%{"title" => "Hello", "body" => "World"}
)
# => {:ok, %{"title" => "Hola", "body" => "Mundo"}}
# | {:error, :ai_not_installed}
# | {:error, :no_endpoint}
# | {:error, :missing_prompt}
# | {:error, {:ai_error, reason}}
# | {:error, {:parse_error, reason}}
"""
alias PhoenixKit.Modules.AI
# `PhoenixKitAI` is an optional plugin — present at compile time only
# when the host depends on `:phoenix_kit_ai`. Silence the unknown-MFA
# warning for the specific call below so core compiles cleanly on hosts
# that don't have it. Targeting the 3-tuple (instead of the whole
# module) keeps real typos visible — a misspelled `PhoenixKitAI.asx_with_prompt`
# still warns.
@compile {:no_warn_undefined, [{PhoenixKitAI, :ask_with_prompt, 4}]}
@core_activity_action "core.ai_translation.requested"
@type field_map :: %{required(String.t()) => String.t()}
@type translation_result ::
{:ok, field_map()}
| {:error,
:ai_not_installed
| :no_endpoint
| :missing_prompt
| {:ai_error, term()}
| {:parse_error, term()}}
@doc """
Translate `fields` from `source_lang` to `target_lang`.
- `endpoint_uuid` — UUID of a configured `PhoenixKitAI` endpoint.
Required even when the plugin is installed; the plugin's prompt
machinery binds to a specific endpoint at call time.
- `prompt_uuid` — UUID of a `PhoenixKitAI.Prompt` whose template
references `{{SourceLanguage}}`, `{{TargetLanguage}}`, and one
`{{<FieldName>}}` placeholder per key in `fields`.
- `source_lang` / `target_lang` — language codes (base or dialect;
the helper passes them through unchanged).
- `fields``%{field_name => text}` map. Field names become the
structured-response markers (`---<FIELD_NAME>---`, uppercased).
## Options
- `:actor_uuid` — included in the `core.ai_translation.requested`
activity log entry. When omitted the log is still written with
`actor_uuid: nil`.
- `:resource_type` / `:resource_uuid` — let the audit log point at
the row being translated.
- `:source` — string identifier for the calling module (e.g.
`"Publishing.TranslatePostWorker"`); included in the
`PhoenixKitAI` request log so usage reports break down by caller.
"""
@spec translate_fields(
String.t(),
String.t(),
String.t(),
String.t(),
field_map(),
keyword()
) :: translation_result()
def translate_fields(endpoint_uuid, prompt_uuid, source_lang, target_lang, fields, opts \\ [])
when is_map(fields) and is_binary(source_lang) and is_binary(target_lang) do
# Order matters: validate inputs first so callers can unit-test the
# argument-validation contract without needing the optional plugin
# loaded. The plugin-availability check is a system-state question,
# not an input-shape question — fail fast on bad args either way.
with :ok <- validate_uuid(endpoint_uuid, :no_endpoint),
:ok <- validate_uuid(prompt_uuid, :missing_prompt),
:ok <- validate_non_empty(fields),
:ok <- validate_unique_markers(Map.keys(fields)),
:ok <- ensure_available() do
do_translate(endpoint_uuid, prompt_uuid, source_lang, target_lang, fields, opts)
end
end
# Short-circuit before dispatching to the AI plugin — an empty
# `fields` map would render a prompt with no field variables, spend
# real tokens on a `PhoenixKitAI.ask_with_prompt/4` call, and then
# fail downstream in `parse_response/2` with `:no_markers`. Cheap to
# reject up front. The error sentinel mirrors `parse_response/2`'s
# shape so callers don't have to branch on a new error class.
defp validate_non_empty(fields) when map_size(fields) == 0,
do: {:error, {:parse_error, :no_markers}}
defp validate_non_empty(_fields), do: :ok
defp validate_uuid(value, error) when is_binary(value) do
if String.trim(value) == "", do: {:error, error}, else: :ok
end
defp validate_uuid(_value, error), do: {:error, error}
# Two field names like `"foo-bar"` and `"foo_bar"` both normalise to
# the marker `FOO_BAR`. Reject duplicates at entry — silently
# overwriting one field with another's translation is worse than
# crashing with a clear error.
defp validate_unique_markers(field_names) do
normalised = Enum.map(field_names, &marker/1)
if length(Enum.uniq(normalised)) == length(normalised) do
:ok
else
duplicates =
normalised
|> Enum.frequencies()
|> Enum.filter(fn {_, n} -> n > 1 end)
|> Enum.map(fn {marker, _} -> marker end)
{:error, {:parse_error, {:duplicate_markers, duplicates}}}
end
end
defp ensure_available do
if AI.available?(), do: :ok, else: {:error, :ai_not_installed}
end
defp do_translate(endpoint_uuid, prompt_uuid, source_lang, target_lang, fields, opts) do
# Field names are used verbatim as prompt-variable keys (unlike markers,
# which are upcased) so existing TitleCase/original-casing prompts keep
# working. `fields` keys are strings per the contract, so they merge in
# directly alongside the language slots.
variables =
Map.merge(fields, %{
"SourceLanguage" => source_lang,
"TargetLanguage" => target_lang
})
ai_opts =
opts
|> Keyword.take([:source])
|> Keyword.put_new(:source, "PhoenixKit.Modules.AI.Translation")
log_request(source_lang, target_lang, Map.keys(fields), opts)
# Wrap the plugin call so unexpected return shapes, raised
# exceptions, GenServer.call exits, and throws all come out as
# `{:error, {:ai_error, _}}` — matching the documented contract.
# The plugin uses `GenServer.call/3` internally for streaming
# endpoints; that exits the caller process by default on timeout,
# which `rescue` alone wouldn't catch.
try do
case PhoenixKitAI.ask_with_prompt(endpoint_uuid, prompt_uuid, variables, ai_opts) do
{:ok, response} ->
handle_ai_response(response, fields)
{:error, reason} ->
{:error, {:ai_error, reason}}
other ->
{:error, {:ai_error, {:unexpected_return, other}}}
end
rescue
e -> {:error, {:ai_error, {:exception, Exception.message(e)}}}
catch
:exit, reason -> {:error, {:ai_error, {:exit, reason}}}
:throw, value -> {:error, {:ai_error, {:throw, value}}}
end
end
# `PhoenixKitAI.ask_with_prompt/4` returns the full OpenAI-shaped
# response map (`%{"choices" => [%{"message" => %{"content" => "..."}}]}`),
# not a raw string. Reach for `PhoenixKitAI.Completion.extract_content/1`
# to pull the assistant's content out — same helper publishing's
# `TranslatePostWorker` already uses for its post-translate AI call.
# Older versions of the plugin (or test stubs) may still return a
# plain binary; the second clause keeps that working.
@compile {:no_warn_undefined, [{PhoenixKitAI.Completion, :extract_content, 1}]}
defp handle_ai_response(response, fields) when is_map(response) do
case PhoenixKitAI.Completion.extract_content(response) do
{:ok, content} when is_binary(content) ->
parse_response(content, Map.keys(fields))
{:ok, _other} ->
{:error, {:ai_error, {:unexpected_response, response}}}
{:error, reason} ->
{:error, {:ai_error, reason}}
end
end
defp handle_ai_response(response, fields) when is_binary(response) do
parse_response(response, Map.keys(fields))
end
defp handle_ai_response(other, _fields) do
{:error, {:ai_error, {:unexpected_response, other}}}
end
@doc """
Parses a structured `---FIELD_NAME---` response into a field map.
Public for testing — consumers normally hit `translate_fields/6`,
which calls this internally. Useful when a caller already has the
raw AI response (e.g. a previously cached completion) and just
needs to extract field values.
Field names are matched case-insensitively against the markers but
the returned map preserves the input casing of `fields` so callers
can round-trip with their original field-name strings.
**All requested fields must be present** in the response. When the
model returns a partial response (e.g. it forgot the `---SLUG---`
marker), this function returns
`{:error, {:parse_error, {:missing_fields, [...]}}}` so the caller
can decide whether to retry, fall back to the source value, or
surface an error to the user — rather than silently persisting a
half-translated row.
iex> Translation.parse_response(
...> "---TITLE---\\nHola\\n---BODY---\\nMundo",
...> ["title", "body"]
...> )
{:ok, %{"title" => "Hola", "body" => "Mundo"}}
iex> Translation.parse_response("---TITLE---\\nonly", ["title", "body"])
{:error, {:parse_error, {:missing_fields, ["body"]}}}
iex> Translation.parse_response(":shrug:", ["title"])
{:error, {:parse_error, :no_markers}}
"""
@spec parse_response(String.t(), [String.t()]) ::
{:ok, field_map()} | {:error, {:parse_error, term()}}
def parse_response(response, field_names) when is_binary(response) and is_list(field_names) do
upcased = Enum.map(field_names, &{&1, marker(&1)})
body = String.trim(response)
parsed =
Enum.reduce(upcased, %{}, fn {name, marker}, acc ->
case extract_section(body, marker, upcased) do
nil -> acc
value -> Map.put(acc, name, value)
end
end)
missing = for name <- field_names, not Map.has_key?(parsed, name), do: name
cond do
map_size(parsed) == 0 -> {:error, {:parse_error, :no_markers}}
missing != [] -> {:error, {:parse_error, {:missing_fields, missing}}}
true -> {:ok, parsed}
end
end
defp marker(field) when is_binary(field) do
field |> String.upcase() |> String.replace(~r/[^A-Z0-9]+/, "_")
end
# Pulls the text between `---MARKER---` and the next `---OTHER---`
# marker (or end-of-string). Returns nil when the marker isn't
# present.
defp extract_section(body, marker, all_markers) do
others = for {_, m} <- all_markers, m != marker, do: Regex.escape(m)
boundary = if others == [], do: ~s|\\z|, else: "---(?:#{Enum.join(others, "|")})---"
# `i` flag: markers are normalised to uppercase by `marker/1`, but a model
# may emit them lowercased (`---title---`). Case-insensitive matching keeps
# the documented "case-insensitive marker matching" contract honest.
pattern = ~r/---#{Regex.escape(marker)}---\s*\n?(.+?)(?=#{boundary}|\z)/si
case Regex.run(pattern, body) do
[_, value] -> String.trim(value)
_ -> nil
end
end
# Writes one `core.ai_translation.requested` audit entry per dispatched
# request. The host's worker also logs its own per-resource action
# (e.g. `publishing.translation.added`); this entry is the unified
# token-spend audit trail.
#
# If `PhoenixKit.Activity` isn't loaded (host has the module disabled)
# the log is skipped silently. Beyond that we let `Activity.log/1`
# failures propagate — a DB-level problem here is the same bug we'd
# want to see in any other log site, and the caller (an Oban worker
# in every documented consumer) owns retry policy.
defp log_request(source_lang, target_lang, field_names, opts) do
if Code.ensure_loaded?(PhoenixKit.Activity) and
function_exported?(PhoenixKit.Activity, :log, 1) do
PhoenixKit.Activity.log(%{
action: @core_activity_action,
module: "ai",
mode: "auto",
actor_uuid: Keyword.get(opts, :actor_uuid),
resource_type: Keyword.get(opts, :resource_type, "ai_translation"),
resource_uuid: Keyword.get(opts, :resource_uuid),
metadata: %{
"source_lang" => source_lang,
"target_lang" => target_lang,
"field_count" => length(field_names),
"fields" => field_names
}
})
end
end
end