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/4call, 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_contentrows, projects'Project.translationsJSONB, 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}}
Summary
Functions
Parses a structured ---FIELD_NAME--- response into a field map.
Translate fields from source_lang to target_lang.
Types
Functions
@spec parse_response(String.t(), [String.t()]) :: {:ok, field_map()} | {:error, {:parse_error, term()}}
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 translate_fields( String.t(), String.t(), String.t(), String.t(), field_map(), keyword() ) :: translation_result()
Translate fields from source_lang to target_lang.
endpoint_uuid— UUID of a configuredPhoenixKitAIendpoint. Required even when the plugin is installed; the plugin's prompt machinery binds to a specific endpoint at call time.prompt_uuid— UUID of aPhoenixKitAI.Promptwhose template references{{SourceLanguage}},{{TargetLanguage}}, and one{{<FieldName>}}placeholder per key infields.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 thecore.ai_translation.requestedactivity log entry. When omitted the log is still written withactor_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 thePhoenixKitAIrequest log so usage reports break down by caller.