Files
bDS2/lib/bds/posts/auto_translation.ex
2026-05-01 09:52:11 +02:00

177 lines
5.0 KiB
Elixir

defmodule BDS.Posts.AutoTranslation do
@moduledoc false
import Ecto.Query
alias BDS.AI
alias BDS.Media
alias BDS.Metadata
alias BDS.Posts.Post
alias BDS.Posts.PostMedia
alias BDS.Posts.Translation
alias BDS.Repo
alias BDS.Tasks
@doc """
Schedule background auto-translation tasks for any missing target languages.
Returns `:ok` even when nothing is scheduled (offline mode, no metadata, etc.).
"""
@spec maybe_schedule(Post.t()) :: :ok
def maybe_schedule(%Post{do_not_translate: true}), do: :ok
def maybe_schedule(%Post{} = post) do
with true <- configured?(),
{:ok, metadata} <- Metadata.get_project_metadata(post.project_id) do
post
|> missing_languages(metadata)
|> Enum.each(&queue_post(post, &1))
else
_other -> :ok
end
:ok
end
@doc false
def missing_languages(%Post{} = post, metadata) do
source_language = normalize_language(post.language || metadata.main_language)
configured_languages =
([metadata.main_language] ++ (metadata.blog_languages || []))
|> Enum.map(&normalize_language/1)
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.uniq()
existing_languages =
Repo.all(
from translation in Translation,
where: translation.translation_for == ^post.id,
select: translation.language
)
configured_languages
|> Enum.reject(&(&1 == source_language or &1 in existing_languages))
end
defp queue_post(%Post{} = post, language) do
_ =
Tasks.submit_task(
"Auto-translate Post to #{language}",
fn report ->
report.(0.05, "Translating post to #{language}")
with {:ok, translation} <- AI.translate_post(post.id, language, ai_opts()),
{:ok, saved_translation} <-
BDS.Posts.upsert_post_translation(post.id, language, %{
title: translation.title,
excerpt: translation.excerpt,
content: translation.content,
auto_generated: true
}) do
report.(0.85, "Post translation saved")
:ok = queue_media_cascade(post, language)
report.(1.0, "Post translation complete")
%{post_id: post.id, translation_id: saved_translation.id, language: language}
else
{:error, reason} -> {:error, reason}
end
end,
task_attrs(post)
)
:ok
end
defp queue_media_cascade(%Post{} = post, language) do
linked_media_ids(post.id)
|> Enum.each(fn media_id ->
if media_needed?(media_id, language) do
queue_media(post, media_id, language)
end
end)
:ok
end
defp queue_media(%Post{} = post, media_id, language) do
_ =
Tasks.submit_task(
"Auto-translate Media to #{language}",
fn report ->
report.(0.05, "Translating media to #{language}")
with {:ok, translation} <- AI.translate_media(media_id, language, ai_opts()),
{:ok, saved_translation} <-
Media.upsert_media_translation(media_id, language, %{
title: translation.title,
alt: translation.alt,
caption: translation.caption
}) do
report.(1.0, "Media translation complete")
%{media_id: media_id, translation_id: saved_translation.id, language: language}
else
{:error, reason} -> {:error, reason}
end
end,
task_attrs(post)
)
:ok
end
defp media_needed?(media_id, language) do
case Repo.get(Media.Media, media_id) do
%Media.Media{language: source_language} when source_language not in [nil, ""] and source_language != language ->
not Repo.exists?(
from translation in Media.Translation,
where: translation.translation_for == ^media_id and translation.language == ^language
)
_other ->
false
end
end
defp task_attrs(%Post{} = post), do: %{group_id: post.project_id, group_name: "AI"}
defp ai_opts do
Application.get_env(:bds, :posts, [])
|> Keyword.get(:auto_translation_ai_opts, [])
end
defp configured? do
mode = if AI.airplane_mode?(), do: :airplane, else: :online
case AI.get_endpoint(mode) do
{:ok, %{url: url, model: model} = endpoint}
when is_binary(url) and url != "" and is_binary(model) and model != "" ->
mode == :airplane or present?(Map.get(endpoint, :api_key))
_other ->
false
end
end
defp linked_media_ids(post_id) do
Repo.all(
from pm in PostMedia,
where: pm.post_id == ^post_id,
order_by: [asc: pm.sort_order, asc: pm.media_id],
select: pm.media_id
)
end
defp normalize_language(nil), do: ""
defp normalize_language(language) do
language
|> to_string()
|> String.trim()
|> String.downcase()
end
defp present?(value) when is_binary(value), do: String.trim(value) != ""
defp present?(value), do: not is_nil(value)
end