Packages
phoenix_kit
1.7.70
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/publishing/workers/translate_post_worker.ex
defmodule PhoenixKit.Modules.Publishing.Workers.TranslatePostWorker do
@moduledoc """
Oban worker for translating publishing posts to multiple languages using AI.
This worker translates the primary language version of a post to all enabled
languages (or a specified subset). Each language translation is processed
sequentially to avoid overwhelming the AI endpoint.
## Usage
# Translate to all enabled languages
PhoenixKit.Modules.Publishing.translate_post_to_all_languages(
"docs",
"019cce93-ed2e-7e1b-...",
endpoint_uuid: "endpoint-uuid"
)
# Or enqueue directly
%{
"group_slug" => "docs",
"post_uuid" => "019cce93-ed2e-7e1b-...",
"endpoint_uuid" => "endpoint-uuid"
}
|> TranslatePostWorker.new()
|> Oban.insert()
## Job Arguments
- `group_slug` - The publishing group slug
- `post_uuid` - The post UUID
- `endpoint_uuid` - AI endpoint UUID to use for translation
- `source_language` - Source language to translate from (optional, defaults to primary language)
- `target_languages` - List of target languages (optional, defaults to all enabled except source)
- `version` - Version number to translate (optional, defaults to latest/published)
- `user_uuid` - User UUID for audit trail (optional)
## Configuration
Set the default AI endpoint for translations in Settings:
PhoenixKit.Settings.update_setting("publishing_translation_endpoint_uuid", "1")
"""
use Oban.Worker,
queue: :default,
max_attempts: 3,
unique: [
keys: [:group_slug, :post_uuid],
states: [:available, :scheduled, :executing, :retryable]
]
# Suppress dialyzer warnings for pattern matches where dialyzer incorrectly infers
# that {:ok, _} patterns can never match. The Publishing context functions do return
# {:ok, _} on success. This cascades to all downstream helper functions.
@dialyzer {:nowarn_function, do_translate: 8}
@dialyzer {:nowarn_function, translate_to_languages: 6}
@dialyzer {:nowarn_function, translate_single_language: 6}
@dialyzer {:nowarn_function, save_translation: 1}
@dialyzer {:nowarn_function, check_translation_exists: 3}
@dialyzer {:nowarn_function, update_translation: 3}
@dialyzer {:nowarn_function, create_translation: 1}
@dialyzer {:nowarn_function, extract_title: 1}
@dialyzer {:nowarn_function, parse_translated_response: 1}
@dialyzer {:nowarn_function, sanitize_slug: 1}
@dialyzer {:nowarn_function, parse_markdown_response: 1}
@dialyzer {:nowarn_function, build_scope: 1}
require Logger
alias PhoenixKit.Modules.AI
alias PhoenixKit.Modules.Publishing
alias PhoenixKit.Modules.Publishing.LanguageHelpers
alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub
alias PhoenixKit.Settings
alias PhoenixKit.Users.Auth
alias PhoenixKit.Users.Auth.Scope
@impl Oban.Worker
def perform(%Oban.Job{args: args}) do
group_slug = Map.fetch!(args, "group_slug")
post_uuid = Map.fetch!(args, "post_uuid")
version = Map.get(args, "version")
endpoint_uuid = Map.get(args, "endpoint_uuid") || get_default_endpoint_uuid()
prompt_uuid = Map.get(args, "prompt_uuid")
# Use post's stored primary language as default source, not global
source_language =
Map.get(args, "source_language") ||
Publishing.get_post_primary_language(group_slug, post_uuid, version)
target_languages = Map.get(args, "target_languages") || get_target_languages(source_language)
user_uuid = Map.get(args, "user_uuid")
Logger.info(
"[TranslatePostWorker] Starting translation of #{group_slug}/#{post_uuid} " <>
"from #{source_language} to #{length(target_languages)} languages " <>
"(version: #{inspect(version)}, endpoint: #{inspect(endpoint_uuid)}, prompt: #{inspect(prompt_uuid)})"
)
# Validate AI module is enabled and prompt is provided
cond do
not AI.enabled?() ->
Logger.error("[TranslatePostWorker] AI module is not enabled")
{:error, "AI module is not enabled"}
is_nil(prompt_uuid) ->
Logger.error("[TranslatePostWorker] No prompt_uuid provided")
{:error, "No prompt selected"}
true ->
# Validate endpoint exists and is enabled
do_translate(
group_slug,
post_uuid,
endpoint_uuid,
source_language,
target_languages,
version,
user_uuid,
prompt_uuid
)
end
end
defp do_translate(
group_slug,
post_uuid,
endpoint_uuid,
source_language,
target_languages,
version,
user_uuid,
prompt_uuid
) do
case AI.get_endpoint(endpoint_uuid) do
nil ->
Logger.error("[TranslatePostWorker] AI endpoint #{endpoint_uuid} not found")
{:error, "AI endpoint not found"}
%{enabled: false} ->
Logger.error("[TranslatePostWorker] AI endpoint #{endpoint_uuid} is disabled")
{:error, "AI endpoint is disabled"}
endpoint ->
# Read the source post by UUID
case Publishing.read_post_by_uuid(post_uuid, source_language, version) do
{:ok, source_post} ->
translate_to_languages(
source_post,
target_languages,
endpoint,
source_language,
user_uuid,
prompt_uuid
)
{:error, reason} ->
Logger.error(
"[TranslatePostWorker] Failed to read source post: #{inspect(reason)}. " <>
"Details: group=#{group_slug}, post_uuid=#{post_uuid}, " <>
"language=#{source_language}, version=#{inspect(version)}"
)
{:error,
"Failed to read source post (#{group_slug}/#{post_uuid}/#{source_language}): #{inspect(reason)}"}
end
end
end
@impl Oban.Worker
def timeout(_job), do: :timer.minutes(10)
# Translate to all target languages sequentially
defp translate_to_languages(
source_post,
target_languages,
endpoint,
source_language,
user_uuid,
prompt_uuid
) do
group_slug = source_post.group
total = length(target_languages)
broadcast_id = source_post.slug || source_post[:uuid]
# Broadcast that translation has started
PublishingPubSub.broadcast_translation_started(group_slug, broadcast_id, target_languages)
results =
target_languages
|> Enum.with_index(1)
|> Enum.map(fn {target_language, index} ->
result =
case translate_single_language(
source_post,
target_language,
endpoint,
source_language,
user_uuid,
prompt_uuid
) do
:ok ->
Logger.info("[TranslatePostWorker] Successfully translated to #{target_language}")
{:ok, target_language}
{:error, reason} ->
Logger.warning(
"[TranslatePostWorker] Failed to translate to #{target_language}: #{inspect(reason)}"
)
{:error, target_language, reason}
end
# Broadcast progress after each language completes
PublishingPubSub.broadcast_translation_progress(
group_slug,
broadcast_id,
index,
total,
target_language
)
result
end)
# Count successes and failures
{successes, failures} =
Enum.split_with(results, fn
{:ok, _} -> true
_ -> false
end)
success_count = length(successes)
failure_count = length(failures)
# Broadcast completion
PublishingPubSub.broadcast_translation_completed(group_slug, broadcast_id, %{
succeeded: Enum.map(successes, fn {:ok, lang} -> lang end),
failed: Enum.map(failures, fn {:error, lang, _} -> lang end),
success_count: success_count,
failure_count: failure_count
})
Logger.info(
"[TranslatePostWorker] Completed: #{success_count} succeeded, #{failure_count} failed"
)
if failure_count > 0 do
failed_langs = Enum.map(failures, fn {:error, lang, _} -> lang end)
{:error,
"Translation failed for #{failure_count} languages: #{Enum.join(failed_langs, ", ")}"}
else
:ok
end
end
# Translate a single language
defp translate_single_language(
source_post,
target_language,
endpoint,
source_language,
user_uuid,
prompt_uuid
) do
group_slug = source_post.group
post_uuid = source_post.uuid
version = source_post.version
# Get language names for the prompt
source_lang_info = LanguageHelpers.get_language_info(source_language)
target_lang_info = LanguageHelpers.get_language_info(target_language)
source_lang_name = if source_lang_info, do: source_lang_info.name, else: source_language
target_lang_name = if target_lang_info, do: target_lang_info.name, else: target_language
Logger.info(
"[TranslatePostWorker] Translating to #{target_language} (#{target_lang_name})..."
)
# Extract title and content from source post
source_title = extract_title(source_post)
source_content = source_post.content || ""
# Call AI for translation using the prompt template
Logger.debug(
"[TranslatePostWorker] Calling AI endpoint #{endpoint.uuid} with prompt #{prompt_uuid} for #{target_language}..."
)
start_time = System.monotonic_time(:millisecond)
variables = %{
"SourceLanguage" => source_lang_name,
"TargetLanguage" => target_lang_name,
"Title" => source_title,
"Content" => source_content
}
result =
AI.ask_with_prompt(endpoint.uuid, prompt_uuid, variables,
source: "Publishing.TranslatePostWorker"
)
elapsed = System.monotonic_time(:millisecond) - start_time
Logger.info("[TranslatePostWorker] AI call for #{target_language} completed in #{elapsed}ms")
case result do
{:ok, response} ->
case AI.extract_content(response) do
{:ok, translated_text} ->
# Parse the translated title, slug, and content
{translated_title, translated_slug, translated_content} =
parse_translated_response(translated_text)
if translated_slug do
Logger.info(
"[TranslatePostWorker] Got translated slug for #{target_language}: #{translated_slug}"
)
end
# Get source post status to inherit for new translation
source_status = Map.get(source_post.metadata, :status, "draft")
# Create or update the translation
translation_opts = %{
group_slug: group_slug,
post_uuid: post_uuid,
language: target_language,
title: translated_title,
url_slug: translated_slug,
content: translated_content,
version: version,
user_uuid: user_uuid,
source_status: source_status
}
save_translation(translation_opts)
{:error, reason} ->
{:error, "Failed to extract AI response: #{inspect(reason)}"}
end
{:error, reason} ->
{:error, "AI request failed: #{inspect(reason)}"}
end
end
# Extract title from post (first # heading or metadata title)
defp extract_title(post) do
content = post.content || ""
case Regex.run(~r/^#\s+(.+)$/m, content) do
[_, title] -> String.trim(title)
nil -> Map.get(post.metadata || %{}, :title, "Untitled")
end
end
# Parse the translated response to extract title, slug, and content
# Returns {title, slug, content} tuple
defp parse_translated_response(response) do
# Try to parse the structured format with slug
case Regex.run(
~r/---TITLE---\s*\n(.+?)\n---SLUG---\s*\n(.+?)\n---CONTENT---\s*\n(.+)/s,
response
) do
[_, title, slug, content] ->
# Found full structured format with slug
{String.trim(title), sanitize_slug(slug), String.trim(content)}
nil ->
# Try format without slug
case Regex.run(~r/---TITLE---\s*\n(.+?)\n---CONTENT---\s*\n(.+)/s, response) do
[_, title, content] ->
{String.trim(title), nil, String.trim(content)}
nil ->
# No structured format found - try to extract from markdown
{title, content} = parse_markdown_response(response)
{title, nil, content}
end
end
end
# Sanitize and validate the translated slug
defp sanitize_slug(slug) do
sanitized =
slug
|> String.trim()
|> String.downcase()
# Replace invalid chars with hyphens
|> String.replace(~r/[^a-z0-9-]/, "-")
# Collapse multiple hyphens
|> String.replace(~r/-+/, "-")
# Remove leading/trailing hyphens
|> String.replace(~r/^-|-$/, "")
if sanitized == "" or String.length(sanitized) < 2 do
# Invalid slug, don't use it
nil
else
sanitized
end
end
# Parse a response that's just markdown (no markers)
defp parse_markdown_response(response) do
# Clean up the response - remove any stray marker text that might have been partially output
cleaned =
response
|> String.replace(~r/---TITLE---.*$/s, "")
|> String.replace(~r/---SLUG---.*$/s, "")
|> String.replace(~r/---CONTENT---.*$/s, "")
|> String.trim()
# Try to find a markdown heading as the title
case Regex.run(~r/^#\s+(.+)$/m, cleaned) do
[full_heading, title] ->
# Remove the heading from content since we'll add it back
content = String.replace(cleaned, full_heading, "", global: false) |> String.trim()
{String.trim(title), content}
nil ->
# No heading found - treat first line as title
case String.split(cleaned, "\n", parts: 2) do
[first_line, rest] ->
{String.trim(first_line), String.trim(rest)}
[only_line] ->
{String.trim(only_line), ""}
end
end
end
# Save the translation (create or update)
# Accepts a map with: group_slug, post_uuid, language, title, url_slug, content,
# version, user_uuid, source_status
defp save_translation(opts) do
%{
group_slug: group_slug,
post_uuid: post_uuid,
language: language,
version: version
} = opts
Logger.info("[TranslatePostWorker] Saving translation for #{language}...")
# Check if translation already exists for this exact language
# We need to check directly because read_post has fallback behavior
# that returns a different language if the requested one doesn't exist
case check_translation_exists(post_uuid, language, version) do
{:ok, existing_post} ->
# Update existing translation - verify it's actually the right language
if existing_post.language == language do
Logger.info("[TranslatePostWorker] Updating existing #{language} translation")
# Don't override status - translations inherit status from primary via propagation
update_translation(group_slug, existing_post, opts)
else
# Fallback returned wrong language, create new translation instead
Logger.info(
"[TranslatePostWorker] Creating new #{language} translation (fallback detected)"
)
create_translation(opts)
end
{:error, _} ->
# Create new translation
Logger.info("[TranslatePostWorker] Creating new #{language} translation")
create_translation(opts)
end
end
# Check if a translation exists for the exact language (no fallback)
defp check_translation_exists(post_uuid, language, version) do
# Try to read the post and verify the language matches
case Publishing.read_post_by_uuid(post_uuid, language, version) do
{:ok, post} ->
# Verify the returned post is actually for the requested language
# AND that it's not a "new translation" stub (is_new_translation means no record
# exists and read_post returned a fallback with empty content)
is_new_translation = Map.get(post, :is_new_translation, false)
if post.language == language && !is_new_translation do
{:ok, post}
else
{:error, :not_found}
end
error ->
error
end
end
defp update_translation(group_slug, existing_post, opts) do
%{title: title, url_slug: url_slug, content: content, user_uuid: user_uuid} = opts
params = %{
"title" => title,
"content" => content
}
# Add url_slug if provided
params = if url_slug, do: Map.put(params, "url_slug", url_slug), else: params
# Mark as non-primary language for consistency (translations shouldn't trigger propagation)
update_opts = %{scope: build_scope(user_uuid), is_primary_language: false}
case Publishing.update_post(group_slug, existing_post, params, update_opts) do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp create_translation(opts) do
language = opts.language
Logger.debug("[TranslatePostWorker] Calling add_language_to_post for #{language}...")
try do
do_create_translation(opts)
rescue
e ->
Logger.error(
"[TranslatePostWorker] Exception in create_translation for #{language}: #{inspect(e)}\n#{Exception.format_stacktrace(__STACKTRACE__)}"
)
{:error, {:exception, e}}
end
end
defp do_create_translation(opts) do
%{
group_slug: group_slug,
post_uuid: post_uuid,
language: language,
version: version
} = opts
case Publishing.add_language_to_post(group_slug, post_uuid, language, version) do
{:ok, new_post} ->
Logger.debug("[TranslatePostWorker] add_language_to_post succeeded for #{language}")
update_translation_post(new_post, opts)
{:error, :already_exists} ->
handle_existing_translation(opts)
{:error, reason} ->
Logger.error(
"[TranslatePostWorker] Failed to create #{language} translation: #{inspect(reason)}"
)
{:error, reason}
end
end
defp update_translation_post(post, opts) do
%{
group_slug: group_slug,
language: language,
title: title,
url_slug: url_slug,
content: content,
user_uuid: user_uuid,
source_status: source_status
} = opts
params = build_translation_params(title, content, url_slug, source_status)
update_opts = %{scope: build_scope(user_uuid), is_primary_language: false}
Logger.debug("[TranslatePostWorker] Calling update_post for #{language}...")
case Publishing.update_post(group_slug, post, params, update_opts) do
{:ok, _} ->
Logger.info(
"[TranslatePostWorker] Successfully saved #{language} translation with slug: #{url_slug || "(default)"}, status: #{source_status}"
)
:ok
{:error, reason} ->
Logger.error(
"[TranslatePostWorker] Failed to update #{language} translation: #{inspect(reason)}"
)
{:error, reason}
end
end
defp handle_existing_translation(opts) do
%{
post_uuid: post_uuid,
language: language,
version: version
} = opts
Logger.info("[TranslatePostWorker] Translation already exists for #{language}, updating...")
case Publishing.read_post_by_uuid(post_uuid, language, version) do
{:ok, existing_post} ->
update_translation_post(existing_post, opts)
{:error, reason} ->
Logger.error(
"[TranslatePostWorker] Failed to read existing #{language} translation: #{inspect(reason)}"
)
{:error, reason}
end
end
defp build_translation_params(title, content, url_slug, source_status) do
params = %{
"title" => title,
"content" => content,
"status" => source_status
}
if url_slug, do: Map.put(params, "url_slug", url_slug), else: params
end
# Build scope for audit trail
defp build_scope(nil), do: nil
defp build_scope(user_uuid) do
case Auth.get_user(user_uuid) do
nil -> nil
user -> Scope.for_user(user)
end
end
# Get target languages (all enabled except source)
defp get_target_languages(source_language) do
LanguageHelpers.enabled_language_codes()
|> Enum.reject(&(&1 == source_language))
end
# Get default endpoint ID from settings
defp get_default_endpoint_uuid do
case Settings.get_setting("publishing_translation_endpoint_uuid") do
nil -> nil
"" -> nil
id -> id
end
end
@doc """
Creates a new translation job for a post.
## Options
- `:endpoint_uuid` - AI endpoint UUID (required if not set in settings)
- `:source_language` - Source language (defaults to primary language)
- `:target_languages` - List of target languages (defaults to all enabled except source)
- `:version` - Version to translate (defaults to latest)
- `:user_uuid` - User UUID for audit trail
## Examples
TranslatePostWorker.create_job("docs", "019cce93-...", endpoint_uuid: "endpoint-uuid")
TranslatePostWorker.create_job("docs", "019cce93-...",
endpoint_uuid: "endpoint-uuid",
target_languages: ["es", "fr"]
)
"""
def create_job(group_slug, post_uuid, opts \\ []) do
user_uuid = Keyword.get(opts, :user_uuid)
args =
%{
"group_slug" => group_slug,
"post_uuid" => post_uuid
}
|> maybe_put("endpoint_uuid", Keyword.get(opts, :endpoint_uuid))
|> maybe_put("prompt_uuid", Keyword.get(opts, :prompt_uuid))
|> maybe_put("source_language", Keyword.get(opts, :source_language))
|> maybe_put("target_languages", Keyword.get(opts, :target_languages))
|> maybe_put("version", Keyword.get(opts, :version))
|> maybe_put("user_uuid", user_uuid)
new(args)
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
@doc """
Enqueues a translation job for a post.
See `create_job/3` for options.
## Examples
{:ok, job} = TranslatePostWorker.enqueue("docs", "019cce93-...", endpoint_uuid: "endpoint-uuid")
"""
def enqueue(group_slug, post_uuid, opts \\ []) do
group_slug
|> create_job(post_uuid, opts)
|> Oban.insert()
end
@doc """
Checks if there's an active translation job for the given post.
Returns the job if found, nil otherwise.
"""
def active_job(post_uuid) do
import Ecto.Query
PhoenixKit.RepoHelper.repo().one(
from(j in Oban.Job,
where: j.worker == ^to_string(__MODULE__),
where: j.state in ["available", "scheduled", "executing", "retryable"],
where: fragment("?->>'post_uuid' = ?", j.args, ^post_uuid),
order_by: [desc: j.inserted_at],
limit: 1
)
)
end
@doc """
Translates content and returns the result without saving.
Use this when you want to display the translation in the UI first,
allowing the user to review/edit before saving.
## Parameters
- `group_slug` - The publishing group slug
- `post_uuid` - The post UUID
- `target_language` - The target language code (e.g., "es")
- `opts` - Options:
- `:endpoint_uuid` - AI endpoint UUID to use (required)
- `:source_language` - Source language code (defaults to post's primary language)
- `:version` - Version to translate (defaults to latest)
## Returns
- `{:ok, %{title: title, url_slug: slug, content: content}}` on success
- `{:error, reason}` on failure
## Example
{:ok, result} = TranslatePostWorker.translate_content("docs", "019cce93-...", "es", endpoint_uuid: "endpoint-uuid")
# => {:ok, %{title: "Primeros Pasos", url_slug: "primeros-pasos", content: "..."}}
"""
def translate_content(group_slug, post_uuid, target_language, opts \\ []) do
endpoint_uuid = Keyword.get(opts, :endpoint_uuid) || get_default_endpoint_uuid()
prompt_uuid = Keyword.get(opts, :prompt_uuid)
version = Keyword.get(opts, :version)
source_language =
Keyword.get(opts, :source_language) ||
Publishing.get_post_primary_language(group_slug, post_uuid, version)
cond do
not AI.enabled?() ->
{:error, "AI module is not enabled"}
is_nil(prompt_uuid) ->
{:error, "No prompt selected"}
true ->
case AI.get_endpoint(endpoint_uuid) do
nil ->
{:error, "AI endpoint not found: #{endpoint_uuid}"}
%{enabled: false} ->
{:error, "AI endpoint is disabled"}
endpoint ->
case Publishing.read_post_by_uuid(post_uuid, source_language, version) do
{:ok, source_post} ->
do_translate_content(
source_post,
target_language,
endpoint,
source_language,
prompt_uuid
)
{:error, reason} ->
{:error, "Failed to read source post: #{inspect(reason)}"}
end
end
end
end
defp do_translate_content(source_post, target_language, endpoint, source_language, prompt_uuid) do
source_lang_info = LanguageHelpers.get_language_info(source_language)
target_lang_info = LanguageHelpers.get_language_info(target_language)
source_lang_name = if source_lang_info, do: source_lang_info.name, else: source_language
target_lang_name = if target_lang_info, do: target_lang_info.name, else: target_language
source_title = extract_title(source_post)
source_content = source_post.content || ""
variables = %{
"SourceLanguage" => source_lang_name,
"TargetLanguage" => target_lang_name,
"Title" => source_title,
"Content" => source_content
}
case AI.ask_with_prompt(endpoint.uuid, prompt_uuid, variables,
source: "Publishing.TranslatePostWorker"
) do
{:ok, response} ->
case AI.extract_content(response) do
{:ok, translated_text} ->
{title, url_slug, content} = parse_translated_response(translated_text)
{:ok, %{title: title, url_slug: url_slug, content: content}}
{:error, reason} ->
{:error, "Failed to extract AI response: #{inspect(reason)}"}
end
{:error, reason} ->
{:error, "AI request failed: #{inspect(reason)}"}
end
end
@doc """
Translates a post to a single language synchronously (without queuing).
Use this for immediate translation of a single language, e.g., when the user
clicks "Translate to This Language" while viewing a non-primary language.
## Parameters
- `group_slug` - The publishing group slug
- `post_uuid` - The post UUID
- `target_language` - The target language code (e.g., "es")
- `opts` - Options:
- `:endpoint_uuid` - AI endpoint UUID to use (required)
- `:source_language` - Source language code (defaults to post's primary language)
- `:version` - Version to translate (defaults to latest)
- `:user_uuid` - User UUID for audit trail
## Returns
- `:ok` on success
- `{:error, reason}` on failure
## Example
:ok = TranslatePostWorker.translate_now("docs", "019cce93-...", "es", endpoint_uuid: "endpoint-uuid")
"""
def translate_now(group_slug, post_uuid, target_language, opts \\ []) do
endpoint_uuid = Keyword.get(opts, :endpoint_uuid) || get_default_endpoint_uuid()
prompt_uuid = Keyword.get(opts, :prompt_uuid)
version = Keyword.get(opts, :version)
user_uuid = Keyword.get(opts, :user_uuid)
source_language =
Keyword.get(opts, :source_language) ||
Publishing.get_post_primary_language(group_slug, post_uuid, version)
cond do
not AI.enabled?() ->
{:error, "AI module is not enabled"}
is_nil(prompt_uuid) ->
{:error, "No prompt selected"}
true ->
case AI.get_endpoint(endpoint_uuid) do
nil ->
{:error, "AI endpoint not found: #{endpoint_uuid}"}
%{enabled: false} ->
{:error, "AI endpoint is disabled"}
endpoint ->
case Publishing.read_post_by_uuid(post_uuid, source_language, version) do
{:ok, source_post} ->
translate_single_language(
source_post,
target_language,
endpoint,
source_language,
user_uuid,
prompt_uuid
)
{:error, reason} ->
{:error, "Failed to read source post: #{inspect(reason)}"}
end
end
end
end
end