Packages
phoenix_kit
1.7.131
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
Current section
Files
lib/modules/ai/translate_worker.ex
defmodule PhoenixKit.Modules.AI.TranslateWorker do
@moduledoc """
Generic Oban worker that translates one resource's fields into a single
target language, shared by every consumer module.
Resolves a `PhoenixKit.Modules.AI.Translatable` adapter from the
job's `resource_type` (registered via `ai_translatables/0` and
discovered through `PhoenixKit.ModuleRegistry`), then runs:
adapter.fetch/2 → adapter.source_fields/2 →
Translation.translate_fields/6 → adapter.put_translation/4
broadcasting `{:ai_translation, event, payload}` at each lifecycle step
(see `PhoenixKit.Modules.AI.Translations`) and writing one
`ai.translation_added` activity entry on success.
## Job args
%{
"resource_type" => "catalogue_item",
"resource_uuid" => uuid,
"endpoint_uuid" => uuid,
"prompt_uuid" => uuid,
"source_lang" => "en",
"target_lang" => "es",
"actor_uuid" => uuid_or_nil
}
## De-duplication
De-dup is **app-level** in `PhoenixKit.Modules.AI.Translations.enqueue/1`
(one in-flight job per `(resource_type, resource_uuid, target_lang)`), NOT
Oban's built-in `unique:`. Oban's uniqueness query references the
`:suspended` job state, which is absent from the `oban_job_state` enum on
hosts that upgraded the Oban *lib* ahead of its *migration* — there the
query raises `22P02` and kills every enqueue. The app guard queries only
the four always-present states (`available`/`scheduled`/`executing`/
`retryable`) and fails open. Same trade-off as the catalogue PDF worker.
"""
use Oban.Worker, queue: :default, max_attempts: 3
require Logger
alias PhoenixKit.ModuleRegistry
alias PhoenixKit.Modules.AI.{Translation, Translations}
@impl Oban.Worker
def perform(%Oban.Job{args: args} = job) do
with {:ok, type} <- fetch_arg(args, "resource_type"),
{:ok, uuid} <- fetch_arg(args, "resource_uuid"),
{:ok, endpoint} <- fetch_arg(args, "endpoint_uuid"),
{:ok, prompt} <- fetch_arg(args, "prompt_uuid"),
{:ok, source} <- fetch_arg(args, "source_lang"),
{:ok, target} <- fetch_arg(args, "target_lang"),
{:ok, adapter} <- resolve_adapter(type),
{:ok, resource} <- load_resource(adapter, type, uuid) do
do_translate(%{
type: type,
uuid: uuid,
endpoint: endpoint,
prompt: prompt,
source: source,
target: target,
actor: Map.get(args, "actor_uuid"),
adapter: adapter,
resource: resource,
attempt: job.attempt,
max_attempts: job.max_attempts
})
else
{:error, reason} ->
# Deterministic setup failure (bad args, unknown adapter, missing
# row) — not worth retrying. Surface a normalised failure on the
# global topic (we may lack a per-resource topic), then discard.
Translations.broadcast(:translation_failed, %{
resource_type: Map.get(args, "resource_type"),
resource_uuid: Map.get(args, "resource_uuid"),
source_lang: Map.get(args, "source_lang"),
target_lang: Map.get(args, "target_lang"),
reason: reason
})
{:discard, reason}
end
end
defp do_translate(ctx) do
broadcast(ctx, :translation_started, %{})
case safe_source_fields(ctx) do
{:error, reason} ->
# Adapter misbehaved (crashed or returned a non-`%{String=>String}`
# map). Deterministic — discard with a clean failure broadcast.
fail(ctx, {:adapter_error, reason}, retry?: false)
fields when map_size(fields) == 0 ->
# Nothing to translate — treat as success so the host clears its
# spinner; the resource just has no source content for these fields.
broadcast(ctx, :translation_completed, %{fields: %{}, empty: true})
:ok
fields ->
case Translation.translate_fields(
ctx.endpoint,
ctx.prompt,
ctx.source,
ctx.target,
fields,
actor_uuid: ctx.actor,
resource_type: ctx.type,
resource_uuid: ctx.uuid,
source: "PhoenixKit.Modules.AI.TranslateWorker"
) do
{:ok, translated} ->
persist(ctx, translated)
# Rate-limited: snooze instead of consuming a retry attempt, so a
# burst of concurrent jobs (enqueue_all_missing) backs off and
# drains rather than exhausting max_attempts. The language stays
# in-flight on the UI (no terminal broadcast) until it lands.
{:error, {:ai_error, :rate_limited}} ->
{:snooze, 30}
{:error, reason} ->
fail(ctx, reason, retry?: retryable?(reason))
end
end
end
defp persist(ctx, translated) do
case safe_put_translation(ctx, translated) do
{:ok, _updated} ->
log_added(ctx, translated)
broadcast(ctx, :translation_completed, %{fields: translated})
:ok
{:error, reason} ->
Logger.warning(
"[AI.TranslateWorker] persist failed for #{ctx.type} #{ctx.uuid}: #{inspect(reason)}"
)
# Persist failures are deterministic (changeset/constraint) — discard.
fail(ctx, {:persist_error, reason}, retry?: false)
end
end
# Broadcast a terminal failure only when the job won't be retried (either
# deterministic, or the final attempt). During a pending retry we stay
# silent so a host UI keeps its spinner rather than flashing a failure the
# next attempt may clear.
defp fail(ctx, reason, retry?: retry?) do
Logger.warning(
"[AI.TranslateWorker] translation failed for #{ctx.type} #{ctx.uuid} → #{ctx.target} " <>
"(attempt #{ctx.attempt}/#{ctx.max_attempts}): #{inspect(reason)}"
)
final? = ctx.attempt >= ctx.max_attempts
cond do
retry? and not final? ->
{:error, reason}
retry? ->
# Out of attempts — now it's terminal, so surface it.
broadcast(ctx, :translation_failed, %{reason: reason})
{:error, reason}
true ->
broadcast(ctx, :translation_failed, %{reason: reason})
{:discard, reason}
end
end
# Adapter callbacks are external module code — normalize crashes and bad
# return shapes into `{:error, _}` instead of letting them blow up the
# worker after `:translation_started` (which would retry with no clean
# failure signal).
defp safe_source_fields(ctx) do
case ctx.adapter.source_fields(ctx.resource, ctx.source) do
map when is_map(map) ->
if Enum.all?(map, fn {k, v} -> is_binary(k) and is_binary(v) end) do
map
else
{:error, :non_string_fields}
end
other ->
{:error, {:bad_source_fields, other}}
end
rescue
e -> {:error, {:exception, Exception.message(e)}}
end
defp safe_put_translation(ctx, translated) do
case ctx.adapter.put_translation(ctx.resource, ctx.target, translated, actor_uuid: ctx.actor) do
{:ok, updated} -> {:ok, updated}
{:error, reason} -> {:error, reason}
other -> {:error, {:bad_put_translation, other}}
end
rescue
e -> {:error, {:exception, Exception.message(e)}}
end
# ── Adapter resolution + loading ─────────────────────────────────
defp resolve_adapter(type) do
case ModuleRegistry.find_ai_translatable(type) do
nil -> {:error, {:no_adapter, type}}
adapter -> {:ok, adapter}
end
end
defp load_resource(adapter, type, uuid) do
case adapter.fetch(type, uuid) do
{:ok, resource} -> {:ok, resource}
{:error, reason} -> {:error, reason}
other -> {:error, {:bad_adapter_fetch, other}}
end
end
# ── Broadcast + activity ─────────────────────────────────────────
defp broadcast(ctx, event, extra) do
payload =
Map.merge(
%{
resource_type: ctx.type,
resource_uuid: ctx.uuid,
source_lang: ctx.source,
target_lang: ctx.target
},
extra
)
Translations.broadcast(event, payload, adapter_topics(ctx))
end
defp adapter_topics(%{adapter: adapter, resource: resource}) do
if function_exported?(adapter, :pubsub_topics, 1) do
case adapter.pubsub_topics(resource) do
topics when is_list(topics) -> topics
_ -> []
end
else
[]
end
rescue
# A broadcast helper must never crash the worker — drop extra topics.
_ -> []
end
defp log_added(ctx, translated) do
if Code.ensure_loaded?(PhoenixKit.Activity) and
function_exported?(PhoenixKit.Activity, :log, 1) do
PhoenixKit.Activity.log(%{
action: "ai.translation_added",
module: "ai",
mode: "auto",
actor_uuid: ctx.actor,
resource_type: ctx.type,
resource_uuid: ctx.uuid,
metadata: %{
"source_lang" => ctx.source,
"target_lang" => ctx.target,
"fields" => Map.keys(translated)
}
})
end
rescue
# The audit entry is best-effort — a logging failure must not fail an
# otherwise-successful translation (the row is already persisted).
error ->
Logger.warning("[AI.TranslateWorker] activity log failed: #{Exception.message(error)}")
:ok
end
# ── Retry classification ─────────────────────────────────────────
@doc false
@spec retryable?(term()) :: boolean()
def retryable?({:ai_error, :request_timeout}), do: true
# `PhoenixKitAI.Completion` normalizes a transport timeout to `:request_timeout`
# (above) before it reaches this worker. The bare `:timeout` below is a
# defensive fallback for any provider/path that surfaces the raw atom instead —
# a timeout is transient, so retry rather than discard.
def retryable?({:ai_error, :timeout}), do: true
def retryable?({:ai_error, :rate_limited}), do: true
def retryable?({:ai_error, {:connection_error, _}}), do: true
def retryable?({:ai_error, {:exit, _}}), do: true
# Defense-in-depth: the built-in OpenRouter client maps HTTP 429 to the
# `:rate_limited` atom (handled as `{:snooze, 30}` in `do_translate/1`, before
# this is consulted). A custom/future provider that instead surfaces a bare
# `{:api_error, 429}` should still retry — 429 is the canonical retry-after.
def retryable?({:ai_error, {:api_error, 429}}), do: true
def retryable?({:ai_error, {:api_error, status}})
when status in [500, 502, 503, 504, 522, 524, 529],
do: true
def retryable?(_), do: false
# ── Args ─────────────────────────────────────────────────────────
defp fetch_arg(args, key) do
case Map.get(args, key) do
v when is_binary(v) and v != "" -> {:ok, v}
_ -> {:error, {:missing_arg, key}}
end
end
end