feat: some more work on completing AI translation features

This commit is contained in:
2026-05-03 15:32:54 +02:00
parent 657ed58e80
commit 98243cbd16
5 changed files with 162 additions and 30 deletions

View File

@@ -200,62 +200,76 @@ defmodule BDS.AI.OneShot do
defp build_one_shot_request(operation, payload, model, opts) do defp build_one_shot_request(operation, payload, model, opts) do
language = Keyword.get(opts, :language) language = Keyword.get(opts, :language)
source_language =
case Keyword.get(opts, :source_language) || Map.get(payload, :language) do
value when value in [nil, ""] -> nil
value -> value
end
%{ %{
operation: operation, operation: operation,
model: model, model: model,
max_output_tokens: @default_max_output_tokens, max_output_tokens: @default_max_output_tokens,
messages: [ messages: [
%{"role" => "system", "content" => one_shot_system_prompt(operation, language)}, %{"role" => "system", "content" => one_shot_system_prompt(operation, language, source_language)},
%{"role" => "user", "content" => one_shot_user_content(operation, payload, language)} %{"role" => "user", "content" => one_shot_user_content(operation, payload, language, source_language)}
] ]
} }
end end
defp one_shot_system_prompt(:detect_language, _language) do defp one_shot_system_prompt(:detect_language, _language, _source_language) do
"Return JSON with exactly one key: language_code." "Return JSON with exactly one key: language_code."
end end
defp one_shot_system_prompt(:analyze_taxonomy, _language) do defp one_shot_system_prompt(:analyze_taxonomy, _language, _source_language) do
"Return JSON with keys tags and categories, each an array of short strings." "Return JSON with keys tags and categories, each an array of short strings."
end end
defp one_shot_system_prompt(:import_taxonomy_mapping, _language) do defp one_shot_system_prompt(:import_taxonomy_mapping, _language, _source_language) do
"You are helping import WordPress taxonomy into an existing blog. Return JSON with exactly two keys: categoryMappings and tagMappings. Each value must be an object mapping imported term names to existing project term names. Only map when the imported term should reuse an existing term to avoid duplicates. Do not invent target terms. Leave unmapped items out of the objects." "You are helping import WordPress taxonomy into an existing blog. Return JSON with exactly two keys: categoryMappings and tagMappings. Each value must be an object mapping imported term names to existing project term names. Only map when the imported term should reuse an existing term to avoid duplicates. Do not invent target terms. Leave unmapped items out of the objects."
end end
defp one_shot_system_prompt(:analyze_post, nil) do defp one_shot_system_prompt(:analyze_post, nil, _source_language) do
"Return JSON with keys title, excerpt, and slug. Each value must be a single string (not an array or object)." "Return JSON with keys title, excerpt, and slug. Each value must be a single string (not an array or object)."
end end
defp one_shot_system_prompt(:analyze_post, language) do defp one_shot_system_prompt(:analyze_post, language, _source_language) do
"Return JSON with keys title, excerpt, and slug. Each value must be a single string (not an array or object). Respond in #{language_name(language)}." "Return JSON with keys title, excerpt, and slug. Each value must be a single string (not an array or object). Respond in #{language_name(language)}."
end end
defp one_shot_system_prompt(:translate_post, _language) do defp one_shot_system_prompt(:translate_post, _language, nil) do
"Return JSON with keys title, excerpt, and content. Preserve Markdown structure." "Return JSON with keys title, excerpt, and content. Preserve Markdown structure."
end end
defp one_shot_system_prompt(:analyze_image, nil) do defp one_shot_system_prompt(:translate_post, _language, source_language) do
"Return JSON with keys title, excerpt, and content. Preserve Markdown structure. Translate from #{language_name(source_language)} to the requested language."
end
defp one_shot_system_prompt(:analyze_image, nil, _source_language) do
"Return JSON with keys title, alt, and caption for the provided image." "Return JSON with keys title, alt, and caption for the provided image."
end end
defp one_shot_system_prompt(:analyze_image, language) do defp one_shot_system_prompt(:analyze_image, language, _source_language) do
"Return JSON with keys title, alt, and caption for the provided image. Respond in #{language_name(language)}." "Return JSON with keys title, alt, and caption for the provided image. Respond in #{language_name(language)}."
end end
defp one_shot_system_prompt(:translate_media, _language) do defp one_shot_system_prompt(:translate_media, _language, nil) do
"Return JSON with keys title, alt, and caption translated to the requested language." "Return JSON with keys title, alt, and caption translated to the requested language."
end end
defp one_shot_user_content(:detect_language, %{text: text}, _language) do defp one_shot_system_prompt(:translate_media, _language, source_language) do
"Return JSON with keys title, alt, and caption. Translate from #{language_name(source_language)} to the requested language."
end
defp one_shot_user_content(:detect_language, %{text: text}, _language, _source_language) do
"Detect the language of this text: #{text}" "Detect the language of this text: #{text}"
end end
defp one_shot_user_content(:analyze_taxonomy, post, _language) do defp one_shot_user_content(:analyze_taxonomy, post, _language, _source_language) do
"Suggest categories and tags for the following post.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}" "Suggest categories and tags for the following post.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
end end
defp one_shot_user_content(:import_taxonomy_mapping, payload, _language) do defp one_shot_user_content(:import_taxonomy_mapping, payload, _language, _source_language) do
[ [
"Analyze these imported taxonomy terms and suggest which ones should map to existing project terms.", "Analyze these imported taxonomy terms and suggest which ones should map to existing project terms.",
"", "",
@@ -276,19 +290,23 @@ defmodule BDS.AI.OneShot do
|> Enum.join("\n") |> Enum.join("\n")
end end
defp one_shot_user_content(:analyze_post, post, nil) do defp one_shot_user_content(:analyze_post, post, nil, _source_language) do
"Suggest an improved title, excerpt, and slug.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}" "Suggest an improved title, excerpt, and slug.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
end end
defp one_shot_user_content(:analyze_post, post, language) do defp one_shot_user_content(:analyze_post, post, language, _source_language) do
"Suggest an improved title, excerpt, and slug in #{language_name(language)}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}" "Suggest an improved title, excerpt, and slug in #{language_name(language)}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
end end
defp one_shot_user_content(:translate_post, post, _language) do defp one_shot_user_content(:translate_post, post, _language, nil) do
"Translate this post to #{post.target_language}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{post.content}" "Translate this post to #{language_name(post.target_language)}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{post.content}"
end end
defp one_shot_user_content(:analyze_image, media, nil) do defp one_shot_user_content(:translate_post, post, _language, source_language) do
"Translate this post from #{language_name(source_language)} to #{language_name(post.target_language)}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{post.content}"
end
defp one_shot_user_content(:analyze_image, media, nil, _source_language) do
[ [
%{ %{
"type" => "text", "type" => "text",
@@ -298,7 +316,7 @@ defmodule BDS.AI.OneShot do
] ]
end end
defp one_shot_user_content(:analyze_image, media, language) do defp one_shot_user_content(:analyze_image, media, language, _source_language) do
[ [
%{ %{
"type" => "text", "type" => "text",
@@ -308,8 +326,12 @@ defmodule BDS.AI.OneShot do
] ]
end end
defp one_shot_user_content(:translate_media, media, _language) do defp one_shot_user_content(:translate_media, media, _language, nil) do
"Translate this media metadata to #{media.target_language}.\nTitle: #{media.title}\nAlt: #{media.alt}\nCaption: #{media.caption}" "Translate this media metadata to #{language_name(media.target_language)}.\nTitle: #{media.title}\nAlt: #{media.alt}\nCaption: #{media.caption}"
end
defp one_shot_user_content(:translate_media, media, _language, source_language) do
"Translate this media metadata from #{language_name(source_language)} to #{language_name(media.target_language)}.\nTitle: #{media.title}\nAlt: #{media.alt}\nCaption: #{media.caption}"
end end
defp language_name("de"), do: "German" defp language_name("de"), do: "German"
@@ -344,7 +366,13 @@ defmodule BDS.AI.OneShot do
end end
defp normalize_post_input(%Post{} = post) do defp normalize_post_input(%Post{} = post) do
{:ok, %{title: post.title || "", excerpt: post.excerpt || "", content: Posts.editor_body(post)}} {:ok,
%{
title: post.title || "",
excerpt: post.excerpt || "",
content: Posts.editor_body(post),
language: post.language || ""
}}
end end
defp normalize_post_input(post_id) when is_binary(post_id) do defp normalize_post_input(post_id) when is_binary(post_id) do
@@ -359,7 +387,8 @@ defmodule BDS.AI.OneShot do
%{ %{
title: MapUtils.attr(attrs, :title) || "", title: MapUtils.attr(attrs, :title) || "",
excerpt: MapUtils.attr(attrs, :excerpt) || "", excerpt: MapUtils.attr(attrs, :excerpt) || "",
content: MapUtils.attr(attrs, :content) || "" content: MapUtils.attr(attrs, :content) || "",
language: MapUtils.attr(attrs, :language) || ""
}} }}
end end
@@ -372,7 +401,8 @@ defmodule BDS.AI.OneShot do
caption: media.caption || "", caption: media.caption || "",
image_url: Map.get(media, :image_url), image_url: Map.get(media, :image_url),
file_path: media.file_path, file_path: media.file_path,
project_id: media.project_id project_id: media.project_id,
language: media.language || ""
}} }}
end end
@@ -392,7 +422,8 @@ defmodule BDS.AI.OneShot do
caption: MapUtils.attr(attrs, :caption) || "", caption: MapUtils.attr(attrs, :caption) || "",
image_url: MapUtils.attr(attrs, :image_url), image_url: MapUtils.attr(attrs, :image_url),
file_path: MapUtils.attr(attrs, :file_path), file_path: MapUtils.attr(attrs, :file_path),
project_id: MapUtils.attr(attrs, :project_id) project_id: MapUtils.attr(attrs, :project_id),
language: MapUtils.attr(attrs, :language) || ""
}} }}
end end

View File

@@ -525,8 +525,9 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
else else
media = socket.assigns.media media = socket.assigns.media
normalized_language = normalize_language(language) normalized_language = normalize_language(language)
source_language = normalize_language(media.language)
case AI.translate_media(media.id, normalized_language) do case AI.translate_media(media.id, normalized_language, source_language: source_language) do
{:ok, translation} -> {:ok, translation} ->
case Media.upsert_media_translation(media.id, normalized_language, translation) do case Media.upsert_media_translation(media.id, normalized_language, translation) do
{:ok, _saved_translation} -> {:ok, _saved_translation} ->

View File

@@ -621,8 +621,9 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
else else
post_id = socket.assigns.post_id post_id = socket.assigns.post_id
normalized_language = normalize_language(language, "") normalized_language = normalize_language(language, "")
source_language = socket.assigns.canonical_language
case AI.translate_post(post_id, normalized_language) do case AI.translate_post(post_id, normalized_language, source_language: source_language) do
{:ok, translation} -> {:ok, translation} ->
with {:ok, _saved_translation} <- with {:ok, _saved_translation} <-
Posts.upsert_post_translation(post_id, normalized_language, %{ Posts.upsert_post_translation(post_id, normalized_language, %{

View File

@@ -353,6 +353,7 @@ defmodule BDS.Posts.AutoTranslation do
defp translate_post(%Post{} = post, language, opts \\ []) do defp translate_post(%Post{} = post, language, opts \\ []) do
auto_publish? = Keyword.get(opts, :auto_publish, false) auto_publish? = Keyword.get(opts, :auto_publish, false)
content = Posts.editor_body(post) content = Posts.editor_body(post)
source_language = normalize_language(post.language)
if String.trim(content) == "" do if String.trim(content) == "" do
{:error, :no_content_to_translate} {:error, :no_content_to_translate}
@@ -361,7 +362,7 @@ defmodule BDS.Posts.AutoTranslation do
AI.translate_post( AI.translate_post(
%{title: post.title || "", excerpt: post.excerpt || "", content: content}, %{title: post.title || "", excerpt: post.excerpt || "", content: content},
language, language,
ai_opts() Keyword.put(ai_opts(), :source_language, source_language)
), ),
{:ok, saved_translation} <- {:ok, saved_translation} <-
Posts.upsert_post_translation(post.id, language, %{ Posts.upsert_post_translation(post.id, language, %{
@@ -384,7 +385,14 @@ defmodule BDS.Posts.AutoTranslation do
do: Posts.publish_post_translation(post_id, language) do: Posts.publish_post_translation(post_id, language)
defp translate_media(media_id, language) do defp translate_media(media_id, language) do
with {:ok, translation} <- AI.translate_media(media_id, language, ai_opts()), source_language =
case Repo.get(Media.Media, media_id) do
nil -> ""
media -> normalize_language(media.language)
end
with {:ok, translation} <-
AI.translate_media(media_id, language, Keyword.put(ai_opts(), :source_language, source_language)),
{:ok, saved_translation} <- {:ok, saved_translation} <-
Media.upsert_media_translation(media_id, language, %{ Media.upsert_media_translation(media_id, language, %{
title: translation.title, title: translation.title,

View File

@@ -191,6 +191,17 @@ defmodule BDS.AITest do
usage: usage(22, 14, 0, 0) usage: usage(22, 14, 0, 0)
}} }}
:translate_media ->
{:ok,
%{
json: %{
"title" => "Medientitel",
"alt" => "Medien Alt",
"caption" => "Medien Beschriftung"
},
usage: usage(12, 10, 0, 0)
}}
:analyze_post -> :analyze_post ->
{:ok, {:ok,
%{ %{
@@ -623,6 +634,86 @@ defmodule BDS.AITest do
assert request.model == "gpt-4.1-mini" assert request.model == "gpt-4.1-mini"
end end
test "translate_post includes source language in prompt when provided via opts" do
assert {:ok, _endpoint} =
BDS.AI.put_endpoint(
:online,
%{
url: "https://api.example.test/v1",
api_key: "online-secret",
model: "gpt-4o-mini"
},
secret_backend: FakeSecretBackend
)
assert :ok = BDS.AI.set_airplane_mode(false)
assert :ok = BDS.AI.put_model_preference(:title, "gpt-4.1-mini")
assert {:ok, translation} =
BDS.AI.translate_post(
%{
title: "Hello World",
excerpt: "Short summary",
content: "# Hello\n\nSource body"
},
"de",
runtime: FakeRuntime,
test_pid: self(),
secret_backend: FakeSecretBackend,
source_language: "en"
)
assert translation.title == "Hallo Welt"
assert_received {:runtime_request, _endpoint, request}
assert request.operation == :translate_post
system_message = get_in(request.messages, [Access.at(0), "content"]) || ""
user_message = get_in(request.messages, [Access.at(1), "content"]) || ""
assert system_message =~ "English"
assert user_message =~ "English"
assert user_message =~ "German"
end
test "translate_media includes source language in prompt when provided via opts" do
assert {:ok, _endpoint} =
BDS.AI.put_endpoint(
:online,
%{
url: "https://api.example.test/v1",
api_key: "online-secret",
model: "gpt-4o-mini"
},
secret_backend: FakeSecretBackend
)
assert :ok = BDS.AI.set_airplane_mode(false)
assert :ok = BDS.AI.put_model_preference(:title, "gpt-4.1-mini")
assert {:ok, translation} =
BDS.AI.translate_media(
%{
title: "Image Title",
alt: "Image Alt",
caption: "Image Caption"
},
"de",
runtime: FakeRuntime,
test_pid: self(),
secret_backend: FakeSecretBackend,
source_language: "en"
)
assert translation.title == "Medientitel"
assert_received {:runtime_request, _endpoint, request}
assert request.operation == :translate_media
system_message = get_in(request.messages, [Access.at(0), "content"]) || ""
user_message = get_in(request.messages, [Access.at(1), "content"]) || ""
assert system_message =~ "English"
assert user_message =~ "English"
assert user_message =~ "German"
end
test "analyze_post uses editor_body so published posts include filesystem content" do test "analyze_post uses editor_body so published posts include filesystem content" do
assert {:ok, _endpoint} = assert {:ok, _endpoint} =
BDS.AI.put_endpoint( BDS.AI.put_endpoint(