From 98243cbd16238e61757e08a70d5d7074f93c159d Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 3 May 2026 15:32:54 +0200 Subject: [PATCH] feat: some more work on completing AI translation features --- lib/bds/ai/one_shot.ex | 83 +++++++++++++------- lib/bds/desktop/shell_live/media_editor.ex | 3 +- lib/bds/desktop/shell_live/post_editor.ex | 3 +- lib/bds/posts/auto_translation.ex | 12 ++- test/bds/ai_test.exs | 91 ++++++++++++++++++++++ 5 files changed, 162 insertions(+), 30 deletions(-) diff --git a/lib/bds/ai/one_shot.ex b/lib/bds/ai/one_shot.ex index 650e868..584c714 100644 --- a/lib/bds/ai/one_shot.ex +++ b/lib/bds/ai/one_shot.ex @@ -200,62 +200,76 @@ defmodule BDS.AI.OneShot do defp build_one_shot_request(operation, payload, model, opts) do 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, model: model, max_output_tokens: @default_max_output_tokens, messages: [ - %{"role" => "system", "content" => one_shot_system_prompt(operation, language)}, - %{"role" => "user", "content" => one_shot_user_content(operation, payload, language)} + %{"role" => "system", "content" => one_shot_system_prompt(operation, language, source_language)}, + %{"role" => "user", "content" => one_shot_user_content(operation, payload, language, source_language)} ] } 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." 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." 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." 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)." 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)}." 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." 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." 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)}." 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." 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}" 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)}" 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.", "", @@ -276,19 +290,23 @@ defmodule BDS.AI.OneShot do |> Enum.join("\n") 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)}" 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)}" end - defp one_shot_user_content(:translate_post, post, _language) do - "Translate this post to #{post.target_language}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{post.content}" + defp one_shot_user_content(:translate_post, post, _language, nil) do + "Translate this post 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) 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", @@ -298,7 +316,7 @@ defmodule BDS.AI.OneShot do ] 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", @@ -308,8 +326,12 @@ defmodule BDS.AI.OneShot do ] end - defp one_shot_user_content(:translate_media, media, _language) do - "Translate this media metadata to #{media.target_language}.\nTitle: #{media.title}\nAlt: #{media.alt}\nCaption: #{media.caption}" + defp one_shot_user_content(:translate_media, media, _language, nil) do + "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 defp language_name("de"), do: "German" @@ -344,7 +366,13 @@ defmodule BDS.AI.OneShot do end 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 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) || "", excerpt: MapUtils.attr(attrs, :excerpt) || "", - content: MapUtils.attr(attrs, :content) || "" + content: MapUtils.attr(attrs, :content) || "", + language: MapUtils.attr(attrs, :language) || "" }} end @@ -372,7 +401,8 @@ defmodule BDS.AI.OneShot do caption: media.caption || "", image_url: Map.get(media, :image_url), file_path: media.file_path, - project_id: media.project_id + project_id: media.project_id, + language: media.language || "" }} end @@ -392,7 +422,8 @@ defmodule BDS.AI.OneShot do caption: MapUtils.attr(attrs, :caption) || "", image_url: MapUtils.attr(attrs, :image_url), 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 diff --git a/lib/bds/desktop/shell_live/media_editor.ex b/lib/bds/desktop/shell_live/media_editor.ex index 0dfdc44..5bec407 100644 --- a/lib/bds/desktop/shell_live/media_editor.ex +++ b/lib/bds/desktop/shell_live/media_editor.ex @@ -525,8 +525,9 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do else media = socket.assigns.media 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} -> case Media.upsert_media_translation(media.id, normalized_language, translation) do {:ok, _saved_translation} -> diff --git a/lib/bds/desktop/shell_live/post_editor.ex b/lib/bds/desktop/shell_live/post_editor.ex index 5b27014..19181b9 100644 --- a/lib/bds/desktop/shell_live/post_editor.ex +++ b/lib/bds/desktop/shell_live/post_editor.ex @@ -621,8 +621,9 @@ defmodule BDS.Desktop.ShellLive.PostEditor do else post_id = socket.assigns.post_id 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} -> with {:ok, _saved_translation} <- Posts.upsert_post_translation(post_id, normalized_language, %{ diff --git a/lib/bds/posts/auto_translation.ex b/lib/bds/posts/auto_translation.ex index fc274cd..8b0ff00 100644 --- a/lib/bds/posts/auto_translation.ex +++ b/lib/bds/posts/auto_translation.ex @@ -353,6 +353,7 @@ defmodule BDS.Posts.AutoTranslation do defp translate_post(%Post{} = post, language, opts \\ []) do auto_publish? = Keyword.get(opts, :auto_publish, false) content = Posts.editor_body(post) + source_language = normalize_language(post.language) if String.trim(content) == "" do {:error, :no_content_to_translate} @@ -361,7 +362,7 @@ defmodule BDS.Posts.AutoTranslation do AI.translate_post( %{title: post.title || "", excerpt: post.excerpt || "", content: content}, language, - ai_opts() + Keyword.put(ai_opts(), :source_language, source_language) ), {:ok, saved_translation} <- Posts.upsert_post_translation(post.id, language, %{ @@ -384,7 +385,14 @@ defmodule BDS.Posts.AutoTranslation do do: Posts.publish_post_translation(post_id, language) 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} <- Media.upsert_media_translation(media_id, language, %{ title: translation.title, diff --git a/test/bds/ai_test.exs b/test/bds/ai_test.exs index 8bb0a4a..c6a621e 100644 --- a/test/bds/ai_test.exs +++ b/test/bds/ai_test.exs @@ -191,6 +191,17 @@ defmodule BDS.AITest do 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 -> {:ok, %{ @@ -623,6 +634,86 @@ defmodule BDS.AITest do assert request.model == "gpt-4.1-mini" 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 assert {:ok, _endpoint} = BDS.AI.put_endpoint(