Packages
phoenix_kit
1.7.130
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
221 Versions
Jump to
Current section
221 Versions
Compare versions
14
files changed
+2117
additions
-5
deletions
unknownCHANGELOG.md
File is too large to be displayed (100 KB limit).
| @@ -1,6 +1,6 @@ | |
| 1 1 | {<<"links">>,[{<<"GitHub">>,<<"https://github.com/BeamLabEU/phoenix_kit">>}]}. |
| 2 2 | {<<"name">>,<<"phoenix_kit">>}. |
| 3 | - {<<"version">>,<<"1.7.128">>}. |
| 3 | + {<<"version">>,<<"1.7.130">>}. |
| 4 4 | {<<"description">>, |
| 5 5 | <<65,32,102,111,117,110,100,97,116,105,111,110,32,102,111,114,32,98,117,105, |
| 6 6 | 108,100,105,110,103,32,69,108,105,120,105,114,32,80,104,111,101,110,105, |
| @@ -76,7 +76,9 @@ | |
| 76 76 | <<"lib/modules/storage/providers/s3.ex">>, |
| 77 77 | <<"lib/modules/storage/providers/provider_registry.ex">>, |
| 78 78 | <<"lib/modules/ai">>,<<"lib/modules/ai/translation.ex">>, |
| 79 | - <<"lib/modules/ai/ai.ex">>,<<"lib/modules/maintenance">>, |
| 79 | + <<"lib/modules/ai/translatable.ex">>, |
| 80 | + <<"lib/modules/ai/translate_worker.ex">>,<<"lib/modules/ai/ai.ex">>, |
| 81 | + <<"lib/modules/ai/translations.ex">>,<<"lib/modules/maintenance">>, |
| 80 82 | <<"lib/modules/maintenance/README.md">>, |
| 81 83 | <<"lib/modules/maintenance/settings.ex">>, |
| 82 84 | <<"lib/modules/maintenance/maintenance.ex">>, |
| @@ -149,6 +151,9 @@ | |
| 149 151 | <<"lib/phoenix_kit_web/users/magic_link_registration.ex">>, |
| 150 152 | <<"lib/phoenix_kit_web/users/confirmation_instructions.ex">>, |
| 151 153 | <<"lib/phoenix_kit_web/endpoint.ex">>,<<"lib/phoenix_kit_web/components">>, |
| 154 | + <<"lib/phoenix_kit_web/components/ai_translate">>, |
| 155 | + <<"lib/phoenix_kit_web/components/ai_translate/form_binding.ex">>, |
| 156 | + <<"lib/phoenix_kit_web/components/ai_translate/form_glue.ex">>, |
| 152 157 | <<"lib/phoenix_kit_web/components/media_gallery.ex">>, |
| 153 158 | <<"lib/phoenix_kit_web/components/media_canvas_viewer.html.heex">>, |
| 154 159 | <<"lib/phoenix_kit_web/components/invitation_banner.ex">>, |
| @@ -244,6 +249,7 @@ | |
| 244 249 | <<"lib/phoenix_kit_web/components/layouts/root.html.heex">>, |
| 245 250 | <<"lib/phoenix_kit_web/components/layouts/dashboard.html.heex">>, |
| 246 251 | <<"lib/phoenix_kit_web/components/auth_page_wrapper.ex">>, |
| 252 | + <<"lib/phoenix_kit_web/components/ai_translate.ex">>, |
| 247 253 | <<"lib/phoenix_kit_web/plugs">>, |
| 248 254 | <<"lib/phoenix_kit_web/plugs/integration.ex">>, |
| 249 255 | <<"lib/phoenix_kit_web/plugs/request_timer.ex">>, |
| @@ -771,7 +777,7 @@ | |
| 771 777 | [{<<"name">>,<<"etcher">>}, |
| 772 778 | {<<"app">>,<<"etcher">>}, |
| 773 779 | {<<"optional">>,false}, |
| 774 | - {<<"requirement">>,<<"~> 0.5">>}, |
| 780 | + {<<"requirement">>,<<"~> 0.5.5">>}, |
| 775 781 | {<<"repository">>,<<"hexpm">>}], |
| 776 782 | [{<<"name">>,<<"aws_regions">>}, |
| 777 783 | {<<"app">>,<<"aws_regions">>}, |
| @@ -0,0 +1,89 @@ | |
| 1 | + defmodule PhoenixKit.Modules.AI.Translatable do |
| 2 | + @moduledoc """ |
| 3 | + Behaviour a feature module implements to make a resource AI-translatable |
| 4 | + through core's generic translation pipeline |
| 5 | + (`PhoenixKit.Modules.AI.TranslateWorker` + |
| 6 | + `PhoenixKit.Modules.AI.Translations`). |
| 7 | + |
| 8 | + An adapter is the *only* per-module code needed — the load, the field |
| 9 | + extraction, and the persist. Everything else (enqueue, the Oban worker, |
| 10 | + the AI call, parsing, broadcasts, the audit log, retry policy) lives in |
| 11 | + core and is shared across every consumer. |
| 12 | + |
| 13 | + ## Registration |
| 14 | + |
| 15 | + The feature module exposes its adapters via the optional |
| 16 | + `ai_translatables/0` callback on `PhoenixKit.Module`, returning |
| 17 | + `[{resource_type, adapter_module}]`: |
| 18 | + |
| 19 | + @impl PhoenixKit.Module |
| 20 | + def ai_translatables do |
| 21 | + [ |
| 22 | + {"catalogue", PhoenixKitCatalogue.AITranslatable}, |
| 23 | + {"catalogue_category", PhoenixKitCatalogue.AITranslatable}, |
| 24 | + {"catalogue_item", PhoenixKitCatalogue.AITranslatable} |
| 25 | + ] |
| 26 | + end |
| 27 | + |
| 28 | + `resource_type` strings MUST be globally unique across all modules — |
| 29 | + namespace them (`"catalogue_item"`, not `"item"`). The same adapter |
| 30 | + module may serve several resource types; it dispatches on the |
| 31 | + `resource_type` argument passed to each callback. |
| 32 | + |
| 33 | + ## Storage contract |
| 34 | + |
| 35 | + `put_translation/4` owns the write and MUST be **atomic + merge-safe**. |
| 36 | + `enqueue_all_missing/2` dispatches one concurrent job per target language, |
| 37 | + so several jobs write the *same row's* translation store at once. The |
| 38 | + `resource` struct handed in was loaded BEFORE the (multi-second) AI call, |
| 39 | + so it is stale by persist time — merging the new language into that |
| 40 | + in-memory struct and doing a plain update will silently drop sibling |
| 41 | + languages other jobs committed in the meantime. |
| 42 | + |
| 43 | + Persist against the **current** row, one of: |
| 44 | + |
| 45 | + - a single atomic SQL write to the per-language path, e.g. |
| 46 | + `jsonb_set(coalesce(data, '{}'), {translations, <lang>}, <fields>, true)` |
| 47 | + via `update_all` (different languages touch different paths → no |
| 48 | + conflict); or |
| 49 | + - a `Repo.transaction` that re-reads the row `lock: "FOR UPDATE"`, merges, |
| 50 | + and writes. |
| 51 | + |
| 52 | + Either keeps the multilang form's edit round-trip working unchanged. |
| 53 | + """ |
| 54 | + |
| 55 | + @type resource_type :: String.t() |
| 56 | + @type lang :: String.t() |
| 57 | + @type fields :: %{optional(String.t()) => String.t()} |
| 58 | + |
| 59 | + @doc "Load a resource by its (type, uuid). `{:error, :resource_not_found}` when absent." |
| 60 | + @callback fetch(resource_type(), uuid :: String.t()) :: {:ok, struct()} | {:error, term()} |
| 61 | + |
| 62 | + @doc """ |
| 63 | + The `%{field_name => text}` to translate, read in `source_lang`. |
| 64 | + |
| 65 | + Return only non-empty fields — empty ones waste tokens and confuse the |
| 66 | + model. Field names become the prompt variables + `---FIELD---` markers. |
| 67 | + """ |
| 68 | + @callback source_fields(resource :: struct(), source_lang :: lang()) :: fields() |
| 69 | + |
| 70 | + @doc """ |
| 71 | + Persist `fields` into `resource` for `target_lang`. Must merge (not |
| 72 | + clobber other languages). `opts` carries `:actor_uuid`. |
| 73 | + """ |
| 74 | + @callback put_translation( |
| 75 | + resource :: struct(), |
| 76 | + target_lang :: lang(), |
| 77 | + fields :: fields(), |
| 78 | + opts :: keyword() |
| 79 | + ) :: {:ok, struct()} | {:error, term()} |
| 80 | + |
| 81 | + @doc """ |
| 82 | + Optional extra PubSub topics (besides the core translation topic) to |
| 83 | + fan status events out on — e.g. the module's own resource topic so an |
| 84 | + already-subscribed LV gets translation lifecycle events too. |
| 85 | + """ |
| 86 | + @callback pubsub_topics(resource :: struct()) :: [binary()] |
| 87 | + |
| 88 | + @optional_callbacks pubsub_topics: 1 |
| 89 | + end |
| @@ -0,0 +1,305 @@ | |
| 1 | + defmodule PhoenixKit.Modules.AI.TranslateWorker do |
| 2 | + @moduledoc """ |
| 3 | + Generic Oban worker that translates one resource's fields into a single |
| 4 | + target language, shared by every consumer module. |
| 5 | + |
| 6 | + Resolves a `PhoenixKit.Modules.AI.Translatable` adapter from the |
| 7 | + job's `resource_type` (registered via `ai_translatables/0` and |
| 8 | + discovered through `PhoenixKit.ModuleRegistry`), then runs: |
| 9 | + |
| 10 | + adapter.fetch/2 → adapter.source_fields/2 → |
| 11 | + Translation.translate_fields/6 → adapter.put_translation/4 |
| 12 | + |
| 13 | + broadcasting `{:ai_translation, event, payload}` at each lifecycle step |
| 14 | + (see `PhoenixKit.Modules.AI.Translations`) and writing one |
| 15 | + `ai.translation_added` activity entry on success. |
| 16 | + |
| 17 | + ## Job args |
| 18 | + |
| 19 | + %{ |
| 20 | + "resource_type" => "catalogue_item", |
| 21 | + "resource_uuid" => uuid, |
| 22 | + "endpoint_uuid" => uuid, |
| 23 | + "prompt_uuid" => uuid, |
| 24 | + "source_lang" => "en", |
| 25 | + "target_lang" => "es", |
| 26 | + "actor_uuid" => uuid_or_nil |
| 27 | + } |
| 28 | + |
| 29 | + ## De-duplication |
| 30 | + |
| 31 | + De-dup is **app-level** in `PhoenixKit.Modules.AI.Translations.enqueue/1` |
| 32 | + (one in-flight job per `(resource_type, resource_uuid, target_lang)`), NOT |
| 33 | + Oban's built-in `unique:`. Oban's uniqueness query references the |
| 34 | + `:suspended` job state, which is absent from the `oban_job_state` enum on |
| 35 | + hosts that upgraded the Oban *lib* ahead of its *migration* — there the |
| 36 | + query raises `22P02` and kills every enqueue. The app guard queries only |
| 37 | + the four always-present states (`available`/`scheduled`/`executing`/ |
| 38 | + `retryable`) and fails open. Same trade-off as the catalogue PDF worker. |
| 39 | + """ |
| 40 | + |
| 41 | + use Oban.Worker, queue: :default, max_attempts: 3 |
| 42 | + |
| 43 | + require Logger |
| 44 | + |
| 45 | + alias PhoenixKit.ModuleRegistry |
| 46 | + alias PhoenixKit.Modules.AI.{Translation, Translations} |
| 47 | + |
| 48 | + @impl Oban.Worker |
| 49 | + def perform(%Oban.Job{args: args} = job) do |
| 50 | + with {:ok, type} <- fetch_arg(args, "resource_type"), |
| 51 | + {:ok, uuid} <- fetch_arg(args, "resource_uuid"), |
| 52 | + {:ok, endpoint} <- fetch_arg(args, "endpoint_uuid"), |
| 53 | + {:ok, prompt} <- fetch_arg(args, "prompt_uuid"), |
| 54 | + {:ok, source} <- fetch_arg(args, "source_lang"), |
| 55 | + {:ok, target} <- fetch_arg(args, "target_lang"), |
| 56 | + {:ok, adapter} <- resolve_adapter(type), |
| 57 | + {:ok, resource} <- load_resource(adapter, type, uuid) do |
| 58 | + do_translate(%{ |
| 59 | + type: type, |
| 60 | + uuid: uuid, |
| 61 | + endpoint: endpoint, |
| 62 | + prompt: prompt, |
| 63 | + source: source, |
| 64 | + target: target, |
| 65 | + actor: Map.get(args, "actor_uuid"), |
| 66 | + adapter: adapter, |
| 67 | + resource: resource, |
| 68 | + attempt: job.attempt, |
| 69 | + max_attempts: job.max_attempts |
| 70 | + }) |
| 71 | + else |
| 72 | + {:error, reason} -> |
| 73 | + # Deterministic setup failure (bad args, unknown adapter, missing |
| 74 | + # row) — not worth retrying. Surface a normalised failure on the |
| 75 | + # global topic (we may lack a per-resource topic), then discard. |
| 76 | + Translations.broadcast(:translation_failed, %{ |
| 77 | + resource_type: Map.get(args, "resource_type"), |
| 78 | + resource_uuid: Map.get(args, "resource_uuid"), |
| 79 | + source_lang: Map.get(args, "source_lang"), |
| 80 | + target_lang: Map.get(args, "target_lang"), |
| 81 | + reason: reason |
| 82 | + }) |
| 83 | + |
| 84 | + {:discard, reason} |
| 85 | + end |
| 86 | + end |
| 87 | + |
| 88 | + defp do_translate(ctx) do |
| 89 | + broadcast(ctx, :translation_started, %{}) |
| 90 | + |
| 91 | + case safe_source_fields(ctx) do |
| 92 | + {:error, reason} -> |
| 93 | + # Adapter misbehaved (crashed or returned a non-`%{String=>String}` |
| 94 | + # map). Deterministic — discard with a clean failure broadcast. |
| 95 | + fail(ctx, {:adapter_error, reason}, retry?: false) |
| 96 | + |
| 97 | + fields when map_size(fields) == 0 -> |
| 98 | + # Nothing to translate — treat as success so the host clears its |
| 99 | + # spinner; the resource just has no source content for these fields. |
| 100 | + broadcast(ctx, :translation_completed, %{fields: %{}, empty: true}) |
| 101 | + :ok |
| 102 | + |
| 103 | + fields -> |
| 104 | + case Translation.translate_fields( |
| 105 | + ctx.endpoint, |
| 106 | + ctx.prompt, |
| 107 | + ctx.source, |
| 108 | + ctx.target, |
| 109 | + fields, |
| 110 | + actor_uuid: ctx.actor, |
| 111 | + resource_type: ctx.type, |
| 112 | + resource_uuid: ctx.uuid, |
| 113 | + source: "PhoenixKit.Modules.AI.TranslateWorker" |
| 114 | + ) do |
| 115 | + {:ok, translated} -> |
| 116 | + persist(ctx, translated) |
| 117 | + |
| 118 | + # Rate-limited: snooze instead of consuming a retry attempt, so a |
| 119 | + # burst of concurrent jobs (enqueue_all_missing) backs off and |
| 120 | + # drains rather than exhausting max_attempts. The language stays |
| 121 | + # in-flight on the UI (no terminal broadcast) until it lands. |
| 122 | + {:error, {:ai_error, :rate_limited}} -> |
| 123 | + {:snooze, 30} |
| 124 | + |
| 125 | + {:error, reason} -> |
| 126 | + fail(ctx, reason, retry?: retryable?(reason)) |
| 127 | + end |
| 128 | + end |
| 129 | + end |
| 130 | + |
| 131 | + defp persist(ctx, translated) do |
| 132 | + case safe_put_translation(ctx, translated) do |
| 133 | + {:ok, _updated} -> |
| 134 | + log_added(ctx, translated) |
| 135 | + broadcast(ctx, :translation_completed, %{fields: translated}) |
| 136 | + :ok |
| 137 | + |
| 138 | + {:error, reason} -> |
| 139 | + Logger.warning( |
| 140 | + "[AI.TranslateWorker] persist failed for #{ctx.type} #{ctx.uuid}: #{inspect(reason)}" |
| 141 | + ) |
| 142 | + |
| 143 | + # Persist failures are deterministic (changeset/constraint) — discard. |
| 144 | + fail(ctx, {:persist_error, reason}, retry?: false) |
| 145 | + end |
| 146 | + end |
| 147 | + |
| 148 | + # Broadcast a terminal failure only when the job won't be retried (either |
| 149 | + # deterministic, or the final attempt). During a pending retry we stay |
| 150 | + # silent so a host UI keeps its spinner rather than flashing a failure the |
| 151 | + # next attempt may clear. |
| 152 | + defp fail(ctx, reason, retry?: retry?) do |
| 153 | + Logger.warning( |
| 154 | + "[AI.TranslateWorker] translation failed for #{ctx.type} #{ctx.uuid} → #{ctx.target} " <> |
| 155 | + "(attempt #{ctx.attempt}/#{ctx.max_attempts}): #{inspect(reason)}" |
| 156 | + ) |
| 157 | + |
| 158 | + final? = ctx.attempt >= ctx.max_attempts |
| 159 | + |
| 160 | + cond do |
| 161 | + retry? and not final? -> |
| 162 | + {:error, reason} |
| 163 | + |
| 164 | + retry? -> |
| 165 | + # Out of attempts — now it's terminal, so surface it. |
| 166 | + broadcast(ctx, :translation_failed, %{reason: reason}) |
| 167 | + {:error, reason} |
| 168 | + |
| 169 | + true -> |
| 170 | + broadcast(ctx, :translation_failed, %{reason: reason}) |
| 171 | + {:discard, reason} |
| 172 | + end |
| 173 | + end |
| 174 | + |
| 175 | + # Adapter callbacks are external module code — normalize crashes and bad |
| 176 | + # return shapes into `{:error, _}` instead of letting them blow up the |
| 177 | + # worker after `:translation_started` (which would retry with no clean |
| 178 | + # failure signal). |
| 179 | + defp safe_source_fields(ctx) do |
| 180 | + case ctx.adapter.source_fields(ctx.resource, ctx.source) do |
| 181 | + map when is_map(map) -> |
| 182 | + if Enum.all?(map, fn {k, v} -> is_binary(k) and is_binary(v) end) do |
| 183 | + map |
| 184 | + else |
| 185 | + {:error, :non_string_fields} |
| 186 | + end |
| 187 | + |
| 188 | + other -> |
| 189 | + {:error, {:bad_source_fields, other}} |
| 190 | + end |
| 191 | + rescue |
| 192 | + e -> {:error, {:exception, Exception.message(e)}} |
| 193 | + end |
| 194 | + |
| 195 | + defp safe_put_translation(ctx, translated) do |
| 196 | + case ctx.adapter.put_translation(ctx.resource, ctx.target, translated, actor_uuid: ctx.actor) do |
| 197 | + {:ok, updated} -> {:ok, updated} |
| 198 | + {:error, reason} -> {:error, reason} |
| 199 | + other -> {:error, {:bad_put_translation, other}} |
| 200 | + end |
| 201 | + rescue |
| 202 | + e -> {:error, {:exception, Exception.message(e)}} |
| 203 | + end |
| 204 | + |
| 205 | + # ── Adapter resolution + loading ───────────────────────────────── |
| 206 | + |
| 207 | + defp resolve_adapter(type) do |
| 208 | + case ModuleRegistry.find_ai_translatable(type) do |
| 209 | + nil -> {:error, {:no_adapter, type}} |
| 210 | + adapter -> {:ok, adapter} |
| 211 | + end |
| 212 | + end |
| 213 | + |
| 214 | + defp load_resource(adapter, type, uuid) do |
| 215 | + case adapter.fetch(type, uuid) do |
| 216 | + {:ok, resource} -> {:ok, resource} |
| 217 | + {:error, reason} -> {:error, reason} |
| 218 | + other -> {:error, {:bad_adapter_fetch, other}} |
| 219 | + end |
| 220 | + end |
| 221 | + |
| 222 | + # ── Broadcast + activity ───────────────────────────────────────── |
| 223 | + |
| 224 | + defp broadcast(ctx, event, extra) do |
| 225 | + payload = |
| 226 | + Map.merge( |
| 227 | + %{ |
| 228 | + resource_type: ctx.type, |
| 229 | + resource_uuid: ctx.uuid, |
| 230 | + source_lang: ctx.source, |
| 231 | + target_lang: ctx.target |
| 232 | + }, |
| 233 | + extra |
| 234 | + ) |
| 235 | + |
| 236 | + Translations.broadcast(event, payload, adapter_topics(ctx)) |
| 237 | + end |
| 238 | + |
| 239 | + defp adapter_topics(%{adapter: adapter, resource: resource}) do |
| 240 | + if function_exported?(adapter, :pubsub_topics, 1) do |
| 241 | + case adapter.pubsub_topics(resource) do |
| 242 | + topics when is_list(topics) -> topics |
| 243 | + _ -> [] |
| 244 | + end |
| 245 | + else |
| 246 | + [] |
| 247 | + end |
| 248 | + rescue |
| 249 | + # A broadcast helper must never crash the worker — drop extra topics. |
| 250 | + _ -> [] |
| 251 | + end |
| 252 | + |
| 253 | + defp log_added(ctx, translated) do |
| 254 | + if Code.ensure_loaded?(PhoenixKit.Activity) and |
| 255 | + function_exported?(PhoenixKit.Activity, :log, 1) do |
| 256 | + PhoenixKit.Activity.log(%{ |
| 257 | + action: "ai.translation_added", |
| 258 | + module: "ai", |
| 259 | + mode: "auto", |
| 260 | + actor_uuid: ctx.actor, |
| 261 | + resource_type: ctx.type, |
| 262 | + resource_uuid: ctx.uuid, |
| 263 | + metadata: %{ |
| 264 | + "source_lang" => ctx.source, |
| 265 | + "target_lang" => ctx.target, |
| 266 | + "fields" => Map.keys(translated) |
| 267 | + } |
| 268 | + }) |
| 269 | + end |
| 270 | + rescue |
| 271 | + # The audit entry is best-effort — a logging failure must not fail an |
| 272 | + # otherwise-successful translation (the row is already persisted). |
| 273 | + error -> |
| 274 | + Logger.warning("[AI.TranslateWorker] activity log failed: #{Exception.message(error)}") |
| 275 | + :ok |
| 276 | + end |
| 277 | + |
| 278 | + # ── Retry classification ───────────────────────────────────────── |
| 279 | + |
| 280 | + @doc false |
| 281 | + @spec retryable?(term()) :: boolean() |
| 282 | + def retryable?({:ai_error, :request_timeout}), do: true |
| 283 | + # PhoenixKit.AI's HTTP client surfaces a request timeout as `:timeout` |
| 284 | + # ({:error, :timeout} → {:ai_error, :timeout} here). A timeout is transient |
| 285 | + # — retry rather than discard, so a one-off slow/hung provider call re-runs. |
| 286 | + def retryable?({:ai_error, :timeout}), do: true |
| 287 | + def retryable?({:ai_error, :rate_limited}), do: true |
| 288 | + def retryable?({:ai_error, {:connection_error, _}}), do: true |
| 289 | + def retryable?({:ai_error, {:exit, _}}), do: true |
| 290 | + |
| 291 | + def retryable?({:ai_error, {:api_error, status}}) |
| 292 | + when status in [500, 502, 503, 504, 522, 524, 529], |
| 293 | + do: true |
| 294 | + |
| 295 | + def retryable?(_), do: false |
| 296 | + |
| 297 | + # ── Args ───────────────────────────────────────────────────────── |
| 298 | + |
| 299 | + defp fetch_arg(args, key) do |
| 300 | + case Map.get(args, key) do |
| 301 | + v when is_binary(v) and v != "" -> {:ok, v} |
| 302 | + _ -> {:error, {:missing_arg, key}} |
| 303 | + end |
| 304 | + end |
| 305 | + end |
| @@ -0,0 +1,482 @@ | |
| 1 | + defmodule PhoenixKit.Modules.AI.Translations do |
| 2 | + @moduledoc """ |
| 3 | + Core orchestration for AI-driven translation — the shared layer every |
| 4 | + feature module enqueues against instead of re-implementing its own |
| 5 | + context + worker. |
| 6 | + |
| 7 | + Pairs with: |
| 8 | + |
| 9 | + - `PhoenixKit.Modules.AI.Translation` — the engine (the AI call + parse). |
| 10 | + - `PhoenixKit.Modules.AI.Translatable` — the per-module adapter behaviour. |
| 11 | + - `PhoenixKit.Modules.AI.TranslateWorker` — the generic Oban worker this |
| 12 | + module enqueues. |
| 13 | + |
| 14 | + ## What a consumer does |
| 15 | + |
| 16 | + 1. Implement a `Translatable` adapter and register it via |
| 17 | + `ai_translatables/0` on its `PhoenixKit.Module`. |
| 18 | + 2. From a form LV: `subscribe/1`, then on a button click call |
| 19 | + `enqueue/1` (or `enqueue_all_missing/2`), and react to the |
| 20 | + `{:ai_translation, event, payload}` messages. |
| 21 | + |
| 22 | + Everything else — the AI call, parsing, persistence (via the adapter), |
| 23 | + retry policy, broadcasts, and the unified audit log — is shared. |
| 24 | + |
| 25 | + ## Status messages |
| 26 | + |
| 27 | + The worker broadcasts `{:ai_translation, event, payload}` where `event` |
| 28 | + is `:translation_started | :translation_completed | :translation_failed`. |
| 29 | + `payload` always has `:resource_type`, `:resource_uuid`, `:source_lang`, |
| 30 | + `:target_lang`. `:translation_completed` adds `:fields` (the translated |
| 31 | + `%{field => text}` map, possibly empty); `:translation_failed` adds |
| 32 | + `:reason`. |
| 33 | + """ |
| 34 | + |
| 35 | + import Ecto.Query |
| 36 | + |
| 37 | + alias PhoenixKit.Modules.AI |
| 38 | + alias PhoenixKit.Modules.AI.TranslateWorker |
| 39 | + alias PhoenixKit.PubSub.Manager, as: PubSubManager |
| 40 | + alias PhoenixKit.Settings |
| 41 | + |
| 42 | + # Job states that mean "already covered" for de-dup. Deliberately excludes |
| 43 | + # `:suspended` — it's missing from the `oban_job_state` enum on some hosts |
| 44 | + # and referencing it in a query raises `22P02` (see TranslateWorker docs). |
| 45 | + @incomplete_states ~w(available scheduled executing retryable) |
| 46 | + |
| 47 | + @endpoint_setting_key "ai_translation_endpoint_uuid" |
| 48 | + @prompt_setting_key "ai_translation_prompt_uuid" |
| 49 | + # `PhoenixKitAI` derives a prompt's slug from its name (`Slug.slugify/1`, |
| 50 | + # overriding any passed `:slug`), so the slug here MUST equal |
| 51 | + # `slugify(@prompt_name)` or the idempotency lookup never matches and every |
| 52 | + # `ensure_default_prompt/0` re-attempts the create. |
| 53 | + @prompt_name "PhoenixKit Translate Content" |
| 54 | + @prompt_slug "phoenixkit-translate-content" |
| 55 | + @topic "phoenix_kit:ai_translation" |
| 56 | + |
| 57 | + # `PhoenixKitAI` is an optional plugin — guard MFAs so core compiles + |
| 58 | + # runs on hosts without it. |
| 59 | + @compile {:no_warn_undefined, |
| 60 | + [ |
| 61 | + {PhoenixKitAI, :enabled?, 0}, |
| 62 | + {PhoenixKitAI, :list_endpoints, 1}, |
| 63 | + {PhoenixKitAI, :list_prompts, 1}, |
| 64 | + {PhoenixKitAI, :get_prompt_by_slug, 1}, |
| 65 | + {PhoenixKitAI, :create_prompt, 1}, |
| 66 | + {PhoenixKitAI, :create_prompt, 2} |
| 67 | + ]} |
| 68 | + |
| 69 | + # ── Availability + configuration ───────────────────────────────── |
| 70 | + |
| 71 | + @doc """ |
| 72 | + Is AI translation usable right now? `PhoenixKitAI` loaded + enabled + |
| 73 | + at least one enabled endpoint configured. Hosts gate the UI on this. |
| 74 | + """ |
| 75 | + @spec available?() :: boolean() |
| 76 | + def available? do |
| 77 | + AI.available?() and |
| 78 | + safe_ai(fn -> PhoenixKitAI.enabled?() end, false) and |
| 79 | + list_endpoints() != [] |
| 80 | + end |
| 81 | + |
| 82 | + @doc "Configured AI endpoints as `[{uuid, name}]`. Empty when unavailable." |
| 83 | + @spec list_endpoints() :: [{String.t(), String.t()}] |
| 84 | + def list_endpoints do |
| 85 | + safe_ai( |
| 86 | + fn -> |
| 87 | + if PhoenixKitAI.enabled?() do |
| 88 | + {endpoints, _} = PhoenixKitAI.list_endpoints(enabled: true) |
| 89 | + Enum.map(endpoints, &{&1.uuid, &1.name}) |
| 90 | + else |
| 91 | + [] |
| 92 | + end |
| 93 | + end, |
| 94 | + [] |
| 95 | + ) |
| 96 | + end |
| 97 | + |
| 98 | + @doc "Configured AI prompts as `[{uuid, name}]`. Empty when unavailable." |
| 99 | + @spec list_prompts() :: [{String.t(), String.t()}] |
| 100 | + def list_prompts do |
| 101 | + safe_ai( |
| 102 | + fn -> |
| 103 | + if PhoenixKitAI.enabled?() do |
| 104 | + case PhoenixKitAI.list_prompts(enabled: true) do |
| 105 | + {prompts, _} -> Enum.map(prompts, &{&1.uuid, &1.name}) |
| 106 | + prompts when is_list(prompts) -> Enum.map(prompts, &{&1.uuid, &1.name}) |
| 107 | + end |
| 108 | + else |
| 109 | + [] |
| 110 | + end |
| 111 | + end, |
| 112 | + [] |
| 113 | + ) |
| 114 | + end |
| 115 | + |
| 116 | + @doc "Default endpoint UUID: the `#{@endpoint_setting_key}` setting, else the first enabled endpoint, else nil." |
| 117 | + @spec default_endpoint_uuid() :: String.t() | nil |
| 118 | + def default_endpoint_uuid do |
| 119 | + case blank_to_nil(Settings.get_setting(@endpoint_setting_key)) do |
| 120 | + nil -> |
| 121 | + case list_endpoints() do |
| 122 | + [{uuid, _name} | _] -> uuid |
| 123 | + [] -> nil |
| 124 | + end |
| 125 | + |
| 126 | + uuid -> |
| 127 | + uuid |
| 128 | + end |
| 129 | + end |
| 130 | + |
| 131 | + @doc "Default prompt UUID: the `#{@prompt_setting_key}` setting, else the shared `#{@prompt_slug}` prompt, else nil." |
| 132 | + @spec default_prompt_uuid() :: String.t() | nil |
| 133 | + def default_prompt_uuid do |
| 134 | + case blank_to_nil(Settings.get_setting(@prompt_setting_key)) do |
| 135 | + nil -> shared_prompt_uuid() |
| 136 | + uuid -> uuid |
| 137 | + end |
| 138 | + end |
| 139 | + |
| 140 | + defp shared_prompt_uuid do |
| 141 | + safe_ai( |
| 142 | + fn -> |
| 143 | + case PhoenixKitAI.get_prompt_by_slug(@prompt_slug) do |
| 144 | + nil -> nil |
| 145 | + prompt -> prompt.uuid |
| 146 | + end |
| 147 | + end, |
| 148 | + nil |
| 149 | + ) |
| 150 | + end |
| 151 | + |
| 152 | + @doc "Is the shared default translation prompt already provisioned?" |
| 153 | + @spec default_prompt_exists?() :: boolean() |
| 154 | + def default_prompt_exists? do |
| 155 | + safe_ai(fn -> PhoenixKitAI.get_prompt_by_slug(@prompt_slug) != nil end, false) |
| 156 | + end |
| 157 | + |
| 158 | + @doc """ |
| 159 | + Idempotently provision the shared translation prompt. Returns |
| 160 | + `{:ok, prompt}` (existing or freshly created) or `{:error, reason}`. |
| 161 | + `{:error, :ai_not_installed}` when the plugin is unavailable. |
| 162 | + """ |
| 163 | + @spec ensure_default_prompt() :: {:ok, struct()} | {:error, term()} |
| 164 | + def ensure_default_prompt do |
| 165 | + if AI.available?() do |
| 166 | + safe_ai(fn -> do_ensure_prompt() end, {:error, :ai_not_installed}) |
| 167 | + else |
| 168 | + {:error, :ai_not_installed} |
| 169 | + end |
| 170 | + end |
| 171 | + |
| 172 | + defp do_ensure_prompt do |
| 173 | + case PhoenixKitAI.get_prompt_by_slug(@prompt_slug) do |
| 174 | + nil -> |
| 175 | + case PhoenixKitAI.create_prompt(default_prompt_attrs()) do |
| 176 | + {:ok, prompt} -> |
| 177 | + {:ok, prompt} |
| 178 | + |
| 179 | + # Lost a create race (or the slug/name was taken concurrently) — |
| 180 | + # re-read by slug. Since @prompt_slug == slugify(@prompt_name), the |
| 181 | + # row the racing caller inserted is now findable. |
| 182 | + {:error, %Ecto.Changeset{}} -> |
| 183 | + case PhoenixKitAI.get_prompt_by_slug(@prompt_slug) do |
| 184 | + nil -> {:error, :prompt_unavailable} |
| 185 | + prompt -> {:ok, prompt} |
| 186 | + end |
| 187 | + end |
| 188 | + |
| 189 | + prompt -> |
| 190 | + {:ok, prompt} |
| 191 | + end |
| 192 | + end |
| 193 | + |
| 194 | + # The SOURCE block enumerates the common translatable field names across |
| 195 | + # PhoenixKit modules (name/title/description/summary/body/content). The |
| 196 | + # engine binds only the fields an adapter actually provides; any unbound |
| 197 | + # `{{placeholder}}` stays literal in the rendered prompt and the RULES tell |
| 198 | + # the model to skip it, and only requested fields are parsed back. An |
| 199 | + # adapter whose `source_fields/2` returns a field name NOT listed here must |
| 200 | + # supply its own prompt (pass `prompt_uuid`) — its value would otherwise |
| 201 | + # never reach the model and the parse would report a missing field. |
| 202 | + defp default_prompt_attrs do |
| 203 | + %{ |
| 204 | + slug: @prompt_slug, |
| 205 | + name: @prompt_name, |
| 206 | + description: "Shared PhoenixKit prompt for translating resource fields between languages.", |
| 207 | + content: """ |
| 208 | + You are translating fields of a content resource from {{SourceLanguage}} to {{TargetLanguage}}. |
| 209 | + |
| 210 | + RULES: |
| 211 | + - Preserve formatting exactly (line breaks, spacing, Markdown if present). |
| 212 | + - Do NOT translate text inside code blocks, inline code, or URLs. |
| 213 | + - Translate naturally and idiomatically — match the tone of the source. |
| 214 | + - Keep any HTML tags and special syntax unchanged. |
| 215 | + - Output ONLY the structured markers below — no commentary, no preface, no closing remarks. |
| 216 | + |
| 217 | + OUTPUT FORMAT — for each non-empty field in the SOURCE section below, |
| 218 | + emit ONE marker named after the field (uppercased), followed by the |
| 219 | + translation: |
| 220 | + |
| 221 | + ---<FIELD_NAME_UPPERCASE>--- |
| 222 | + [translated value] |
| 223 | + |
| 224 | + Example: |
| 225 | + |
| 226 | + ---NAME--- |
| 227 | + <translated name> |
| 228 | + |
| 229 | + ---DESCRIPTION--- |
| 230 | + <translated description> |
| 231 | + |
| 232 | + Skip any field that is missing, blank, or still a literal placeholder |
| 233 | + (e.g. a value that looks like `{{title}}` means the caller did not bind |
| 234 | + it) — do NOT emit a marker for it, and do NOT translate the placeholder |
| 235 | + text itself. |
| 236 | + |
| 237 | + === SOURCE === |
| 238 | + |
| 239 | + Name: {{name}} |
| 240 | + |
| 241 | + Title: {{title}} |
| 242 | + |
| 243 | + Summary: {{summary}} |
| 244 | + |
| 245 | + Description: {{description}} |
| 246 | + |
| 247 | + Body: {{body}} |
| 248 | + |
| 249 | + Content: {{content}} |
| 250 | + """ |
| 251 | + } |
| 252 | + end |
| 253 | + |
| 254 | + # ── PubSub ─────────────────────────────────────────────────────── |
| 255 | + |
| 256 | + @doc "The global AI-translation status topic." |
| 257 | + @spec topic() :: String.t() |
| 258 | + def topic, do: @topic |
| 259 | + |
| 260 | + @doc "Per-resource AI-translation status topic." |
| 261 | + @spec topic(String.t(), String.t()) :: String.t() |
| 262 | + def topic(resource_type, resource_uuid) |
| 263 | + when is_binary(resource_type) and is_binary(resource_uuid), |
| 264 | + do: "#{@topic}:#{resource_type}:#{resource_uuid}" |
| 265 | + |
| 266 | + @doc "Subscribe to the global translation topic." |
| 267 | + @spec subscribe() :: :ok | {:error, term()} |
| 268 | + def subscribe, do: PubSubManager.subscribe(@topic) |
| 269 | + |
| 270 | + @doc "Subscribe to a single resource's translation topic." |
| 271 | + @spec subscribe(String.t(), String.t()) :: :ok | {:error, term()} |
| 272 | + def subscribe(resource_type, resource_uuid), |
| 273 | + do: PubSubManager.subscribe(topic(resource_type, resource_uuid)) |
| 274 | + |
| 275 | + @doc false |
| 276 | + # Called by `TranslateWorker` to fan an event out. The FULL payload (incl. |
| 277 | + # the translated `fields`) goes ONLY to the per-resource topic — that's the |
| 278 | + # one the form LV subscribes to and patches its changeset from. The global |
| 279 | + # topic + any adapter-supplied topics get a content-free SUMMARY (everything |
| 280 | + # but `:fields`), so translated resource text is never fanned out to broad |
| 281 | + # topics a monitor/dashboard might subscribe to (payload-minimal rule). |
| 282 | + def broadcast(event, payload, extra_topics \\ []) do |
| 283 | + summary = {:ai_translation, event, Map.drop(payload, [:fields])} |
| 284 | + |
| 285 | + case payload do |
| 286 | + %{resource_type: t, resource_uuid: u} when is_binary(t) and is_binary(u) -> |
| 287 | + PubSubManager.broadcast(topic(t, u), {:ai_translation, event, payload}) |
| 288 | + |
| 289 | + _ -> |
| 290 | + :ok |
| 291 | + end |
| 292 | + |
| 293 | + PubSubManager.broadcast(@topic, summary) |
| 294 | + Enum.each(extra_topics, &PubSubManager.broadcast(&1, summary)) |
| 295 | + :ok |
| 296 | + end |
| 297 | + |
| 298 | + # ── Missing-language helper ────────────────────────────────────── |
| 299 | + |
| 300 | + @doc """ |
| 301 | + Given the enabled base language codes, the primary code, and the langs |
| 302 | + that already have a translation, return the still-missing base codes |
| 303 | + (primary excluded — it's the source, never a target). |
| 304 | + """ |
| 305 | + @spec missing_languages([String.t()], String.t(), [String.t()]) :: [String.t()] |
| 306 | + def missing_languages(enabled_codes, primary_code, existing_langs) |
| 307 | + when is_list(enabled_codes) and is_list(existing_langs) do |
| 308 | + existing = MapSet.new(existing_langs) |
| 309 | + |
| 310 | + Enum.filter(enabled_codes, fn code -> |
| 311 | + code != primary_code and not MapSet.member?(existing, code) |
| 312 | + end) |
| 313 | + end |
| 314 | + |
| 315 | + # ── Enqueue ────────────────────────────────────────────────────── |
| 316 | + |
| 317 | + @type enqueue_params :: %{ |
| 318 | + required(:resource_type) => String.t(), |
| 319 | + required(:resource_uuid) => String.t(), |
| 320 | + required(:endpoint_uuid) => String.t(), |
| 321 | + required(:prompt_uuid) => String.t(), |
| 322 | + required(:source_lang) => String.t(), |
| 323 | + required(:target_lang) => String.t(), |
| 324 | + optional(:actor_uuid) => String.t() | nil |
| 325 | + } |
| 326 | + |
| 327 | + @doc """ |
| 328 | + Enqueue one translation job for a single `(resource, target_lang)`. |
| 329 | + |
| 330 | + Returns `{:ok, %{conflict?: boolean}}` (`conflict?: true` when an |
| 331 | + identical job is already in flight) or `{:error, reason}` on a malformed |
| 332 | + input map. |
| 333 | + """ |
| 334 | + @spec enqueue(map()) :: {:ok, %{conflict?: boolean()}} | {:error, term()} |
| 335 | + def enqueue(%{} = params) do |
| 336 | + with :ok <- validate(params, full_required()) do |
| 337 | + if job_in_flight?(params) do |
| 338 | + {:ok, %{conflict?: true}} |
| 339 | + else |
| 340 | + case params |> to_args() |> TranslateWorker.new() |> Oban.insert() do |
| 341 | + {:ok, _job} -> {:ok, %{conflict?: false}} |
| 342 | + {:error, reason} -> {:error, reason} |
| 343 | + end |
| 344 | + end |
| 345 | + end |
| 346 | + end |
| 347 | + |
| 348 | + def enqueue(_other), do: {:error, {:invalid, :not_a_map}} |
| 349 | + |
| 350 | + # App-level uniqueness: is there already a non-terminal TranslateWorker job |
| 351 | + # for this (resource_type, resource_uuid, target_lang)? Fails open (returns |
| 352 | + # false → proceed with insert) on any query error. |
| 353 | + defp job_in_flight?(params) do |
| 354 | + repo = PhoenixKit.RepoHelper.repo() |
| 355 | + type = value_for(params, :resource_type) |
| 356 | + uuid = value_for(params, :resource_uuid) |
| 357 | + target = value_for(params, :target_lang) |
| 358 | + |
| 359 | + query = |
| 360 | + from(j in "oban_jobs", |
| 361 | + where: j.worker == "PhoenixKit.Modules.AI.TranslateWorker", |
| 362 | + where: j.state in ^@incomplete_states, |
| 363 | + where: fragment("?->>'resource_type' = ?", j.args, ^type), |
| 364 | + where: fragment("?->>'resource_uuid' = ?", j.args, ^uuid), |
| 365 | + where: fragment("?->>'target_lang' = ?", j.args, ^target) |
| 366 | + ) |
| 367 | + |
| 368 | + repo.exists?(query) |
| 369 | + rescue |
| 370 | + _ -> false |
| 371 | + end |
| 372 | + |
| 373 | + @doc """ |
| 374 | + Enqueue one job per missing target language. `base_params` is |
| 375 | + `enqueue_params` minus `:target_lang`. Returns |
| 376 | + `{:ok, %{enqueued, conflicts, errors, in_flight}}` — `in_flight` is the |
| 377 | + langs a host should mark spinning (newly enqueued + conflicts; never the |
| 378 | + errored ones, since no broadcast will arrive to clear them). |
| 379 | + """ |
| 380 | + @spec enqueue_all_missing(map(), [String.t()]) :: |
| 381 | + {:ok, |
| 382 | + %{ |
| 383 | + enqueued: non_neg_integer(), |
| 384 | + conflicts: non_neg_integer(), |
| 385 | + errors: [{String.t(), term()}], |
| 386 | + in_flight: [String.t()] |
| 387 | + }} |
| 388 | + | {:error, term()} |
| 389 | + def enqueue_all_missing(%{} = base_params, missing_langs) when is_list(missing_langs) do |
| 390 | + case validate(Map.drop(base_params, [:target_lang]), partial_required()) do |
| 391 | + :ok -> |
| 392 | + results = |
| 393 | + Enum.map(missing_langs, fn lang -> |
| 394 | + {lang, base_params |> Map.put(:target_lang, lang) |> enqueue()} |
| 395 | + end) |
| 396 | + |
| 397 | + enqueued = for {lang, {:ok, %{conflict?: false}}} <- results, do: lang |
| 398 | + conflicts = for {lang, {:ok, %{conflict?: true}}} <- results, do: lang |
| 399 | + errors = for {lang, {:error, reason}} <- results, do: {lang, reason} |
| 400 | + |
| 401 | + {:ok, |
| 402 | + %{ |
| 403 | + enqueued: length(enqueued), |
| 404 | + conflicts: length(conflicts), |
| 405 | + errors: errors, |
| 406 | + in_flight: enqueued ++ conflicts |
| 407 | + }} |
| 408 | + |
| 409 | + {:error, _} = err -> |
| 410 | + err |
| 411 | + end |
| 412 | + end |
| 413 | + |
| 414 | + def enqueue_all_missing(_base, _langs), do: {:error, {:invalid, :bad_arguments}} |
| 415 | + |
| 416 | + defp full_required, |
| 417 | + do: [:resource_type, :resource_uuid, :endpoint_uuid, :prompt_uuid, :source_lang, :target_lang] |
| 418 | + |
| 419 | + defp partial_required, |
| 420 | + do: [:resource_type, :resource_uuid, :endpoint_uuid, :prompt_uuid, :source_lang] |
| 421 | + |
| 422 | + defp validate(params, required) do |
| 423 | + missing = for key <- required, blank?(value_for(params, key)), do: key |
| 424 | + |
| 425 | + bad_uuids = |
| 426 | + for key <- [:resource_uuid, :endpoint_uuid, :prompt_uuid], |
| 427 | + value = value_for(params, key), |
| 428 | + is_binary(value), |
| 429 | + not blank?(value), |
| 430 | + Ecto.UUID.cast(value) == :error, |
| 431 | + do: key |
| 432 | + |
| 433 | + cond do |
| 434 | + missing != [] -> {:error, {:invalid, missing}} |
| 435 | + bad_uuids != [] -> {:error, {:invalid_uuids, bad_uuids}} |
| 436 | + true -> :ok |
| 437 | + end |
| 438 | + end |
| 439 | + |
| 440 | + defp value_for(params, key) when is_atom(key) do |
| 441 | + case Map.fetch(params, key) do |
| 442 | + {:ok, value} -> value |
| 443 | + :error -> Map.get(params, Atom.to_string(key)) |
| 444 | + end |
| 445 | + end |
| 446 | + |
| 447 | + defp blank?(nil), do: true |
| 448 | + defp blank?(""), do: true |
| 449 | + defp blank?(value) when is_binary(value), do: String.trim(value) == "" |
| 450 | + defp blank?(_), do: false |
| 451 | + |
| 452 | + defp blank_to_nil(value) when is_binary(value), |
| 453 | + do: if(String.trim(value) == "", do: nil, else: value) |
| 454 | + |
| 455 | + defp blank_to_nil(_), do: nil |
| 456 | + |
| 457 | + defp to_args(params) do |
| 458 | + %{ |
| 459 | + "resource_type" => value_for(params, :resource_type), |
| 460 | + "resource_uuid" => value_for(params, :resource_uuid), |
| 461 | + "endpoint_uuid" => value_for(params, :endpoint_uuid), |
| 462 | + "prompt_uuid" => value_for(params, :prompt_uuid), |
| 463 | + "source_lang" => value_for(params, :source_lang), |
| 464 | + "target_lang" => value_for(params, :target_lang), |
| 465 | + "actor_uuid" => value_for(params, :actor_uuid) |
| 466 | + } |
| 467 | + end |
| 468 | + |
| 469 | + # Plugin-boundary fuse: an absent/broken optional plugin must not crash |
| 470 | + # the caller. Narrow rescue for the shapes a missing/incompatible plugin |
| 471 | + # raises; broad catch for the arbitrary GenServer tree underneath. |
| 472 | + defp safe_ai(fun, default) do |
| 473 | + fun.() |
| 474 | + rescue |
| 475 | + UndefinedFunctionError -> default |
| 476 | + FunctionClauseError -> default |
| 477 | + ArgumentError -> default |
| 478 | + catch |
| 479 | + :exit, _ -> default |
| 480 | + :throw, _ -> default |
| 481 | + end |
| 482 | + end |
Loading more files…