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

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…