fix: provide target language for AI suggestions

This commit is contained in:
2026-05-03 14:50:20 +02:00
parent 556f33711f
commit 657ed58e80
4 changed files with 281 additions and 22 deletions

View File

@@ -172,7 +172,7 @@ defmodule BDS.AI.OneShot do
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts),
:ok <- Runtime.validate_target(operation, model, mode),
{:ok, payload} <- resolve_image_data_url(payload),
request <- build_one_shot_request(operation, payload, model),
request <- build_one_shot_request(operation, payload, model, opts),
{:ok, response} <-
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
{:ok, json} <- extract_json_response(response),
@@ -187,7 +187,7 @@ defmodule BDS.AI.OneShot do
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts),
:ok <- Runtime.validate_target(operation, model, mode),
request <- build_one_shot_request(operation, payload, model),
request <- build_one_shot_request(operation, payload, model, opts),
{:ok, response} <-
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
{:ok, json} <- extract_json_response(response),
@@ -197,55 +197,65 @@ defmodule BDS.AI.OneShot do
end
end
defp build_one_shot_request(operation, payload, model) do
defp build_one_shot_request(operation, payload, model, opts) do
language = Keyword.get(opts, :language)
%{
operation: operation,
model: model,
max_output_tokens: @default_max_output_tokens,
messages: [
%{"role" => "system", "content" => one_shot_system_prompt(operation)},
%{"role" => "user", "content" => one_shot_user_content(operation, payload)}
%{"role" => "system", "content" => one_shot_system_prompt(operation, language)},
%{"role" => "user", "content" => one_shot_user_content(operation, payload, language)}
]
}
end
defp one_shot_system_prompt(:detect_language) do
defp one_shot_system_prompt(:detect_language, _language) do
"Return JSON with exactly one key: language_code."
end
defp one_shot_system_prompt(:analyze_taxonomy) do
defp one_shot_system_prompt(:analyze_taxonomy, _language) do
"Return JSON with keys tags and categories, each an array of short strings."
end
defp one_shot_system_prompt(:import_taxonomy_mapping) do
defp one_shot_system_prompt(:import_taxonomy_mapping, _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) do
defp one_shot_system_prompt(:analyze_post, nil) 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(:translate_post) do
defp one_shot_system_prompt(:analyze_post, 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
"Return JSON with keys title, excerpt, and content. Preserve Markdown structure."
end
defp one_shot_system_prompt(:analyze_image) do
defp one_shot_system_prompt(:analyze_image, nil) do
"Return JSON with keys title, alt, and caption for the provided image."
end
defp one_shot_system_prompt(:translate_media) do
defp one_shot_system_prompt(:analyze_image, 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
"Return JSON with keys title, alt, and caption translated to the requested language."
end
defp one_shot_user_content(:detect_language, %{text: text}) do
defp one_shot_user_content(:detect_language, %{text: text}, _language) do
"Detect the language of this text: #{text}"
end
defp one_shot_user_content(:analyze_taxonomy, post) do
defp one_shot_user_content(:analyze_taxonomy, post, _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) do
defp one_shot_user_content(:import_taxonomy_mapping, payload, _language) do
[
"Analyze these imported taxonomy terms and suggest which ones should map to existing project terms.",
"",
@@ -266,15 +276,19 @@ defmodule BDS.AI.OneShot do
|> Enum.join("\n")
end
defp one_shot_user_content(:analyze_post, post) do
defp one_shot_user_content(:analyze_post, post, nil) 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(:translate_post, post) do
defp one_shot_user_content(:analyze_post, post, 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}"
end
defp one_shot_user_content(:analyze_image, media) do
defp one_shot_user_content(:analyze_image, media, nil) do
[
%{
"type" => "text",
@@ -284,10 +298,27 @@ defmodule BDS.AI.OneShot do
]
end
defp one_shot_user_content(:translate_media, media) do
defp one_shot_user_content(:analyze_image, media, language) do
[
%{
"type" => "text",
"text" => "Analyze this image and return title, alt text, and caption in #{language_name(language)}."
},
%{"type" => "image_url", "image_url" => %{"url" => media.image_url}}
]
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}"
end
defp language_name("de"), do: "German"
defp language_name("en"), do: "English"
defp language_name("fr"), do: "French"
defp language_name("it"), do: "Italian"
defp language_name("es"), do: "Spanish"
defp language_name(language), do: String.capitalize(to_string(language))
defp extract_json_response(%{json: json}) when is_map(json), do: {:ok, json}
defp extract_json_response(%{content: content}) when is_binary(content) do

View File

@@ -7,7 +7,7 @@ defmodule BDS.Desktop.ShellLive do
import Phoenix.HTML
alias BDS.{AI, BoundedAtoms, ImportDefinitions, Media, Posts, Scripts}
alias BDS.{AI, BoundedAtoms, ImportDefinitions, Media, Metadata, Posts, Scripts}
alias BDS.CliSync.Watcher
alias BDS.Desktop.{FolderPicker, Overlay, ShellData, UILocale}
@@ -1565,13 +1565,14 @@ defmodule BDS.Desktop.ShellLive do
defp spawn_ai_suggestions_task(socket) do
current_tab = socket.assigns.current_tab
language = ai_suggestions_language(socket)
case current_tab do
%{type: :post, id: post_id} ->
parent = self()
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
case AI.analyze_post(post_id) do
case AI.analyze_post(post_id, language: language) do
{:ok, result} ->
send(parent, {:ai_suggestions_result, :post, post_id, result})
@@ -1584,7 +1585,7 @@ defmodule BDS.Desktop.ShellLive do
parent = self()
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
case AI.analyze_image(media_id) do
case AI.analyze_image(media_id, language: language) do
{:ok, result} ->
send(parent, {:ai_suggestions_result, :media, media_id, result})
@@ -1600,6 +1601,14 @@ defmodule BDS.Desktop.ShellLive do
socket
end
defp ai_suggestions_language(socket) do
active_project_id = socket.assigns.projects.active_project_id
{:ok, metadata} = Metadata.get_project_metadata(active_project_id)
metadata.main_language || "en"
rescue
_error -> "en"
end
defp mac_ui? do
case Application.get_env(:bds, :shell_platform) do
nil -> match?({:unix, :darwin}, :os.type())

View File

@@ -660,6 +660,44 @@ defmodule BDS.AITest do
assert message =~ "# Draft body"
end
test "analyze_post respects the language option and instructs the model to respond in that language" 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, result} =
BDS.AI.analyze_post(
%{
title: "Draft Post",
excerpt: "Short summary",
content: "# Draft body"
},
language: "de",
runtime: FakeRuntime,
test_pid: self(),
secret_backend: FakeSecretBackend
)
assert result.title =~ "Draft Post"
assert_received {:runtime_request, _endpoint, request}
assert request.operation == :analyze_post
system_message = get_in(request.messages, [Access.at(0), "content"]) || ""
user_message = get_in(request.messages, [Access.at(1), "content"]) || ""
assert system_message =~ "German"
assert user_message =~ "German"
end
test "analyze_import_taxonomy uses the selected model override and returns only valid existing-term mappings" do
assert {:ok, _endpoint} =
BDS.AI.put_endpoint(
@@ -867,6 +905,54 @@ defmodule BDS.AITest do
assert image_content["image_url"]["url"] =~ ~r/^data:image\/png;base64,/
end
test "analyze_image respects the language option and instructs the model to respond in that language" do
assert {:ok, _endpoint} =
BDS.AI.put_endpoint(
:airplane,
%{
url: "http://localhost:11434/v1",
api_key: nil,
model: "llama-default"
},
secret_backend: FakeSecretBackend
)
assert :ok = BDS.AI.set_airplane_mode(true)
assert :ok = BDS.AI.put_model_preference(:airplane_image_analysis, "llama3.2")
assert :ok =
BDS.AI.put_model_capabilities("llama3.2", %{
supports_attachment: true,
supports_tool_calls: false,
disables_reasoning: false
})
assert {:ok, analysis} =
BDS.AI.analyze_image(
%{
mime_type: "image/png",
title: "Source",
alt: nil,
caption: nil,
image_url: "https://example.com/test.png"
},
language: "de",
runtime: FakeRuntime,
test_pid: self(),
secret_backend: FakeSecretBackend
)
assert analysis.title == "Sunset"
assert_received {:runtime_request, _endpoint, request}
assert request.operation == :analyze_image
system_message = get_in(request.messages, [Access.at(0), "content"]) || ""
user_message = Enum.at(request.messages, 1)
text_content = Enum.at(user_message["content"], 0)
assert system_message =~ "German"
assert text_content["text"] =~ "German"
end
test "chat persists user, tool, and assistant messages with usage and blog stats prompt augmentation" do
{:ok, project} = create_project_fixture("AI Chat")
_fixtures = seed_project_content(project.id)

View File

@@ -2415,6 +2415,68 @@ defmodule BDS.Desktop.ShellLiveTest do
refute html =~ "Loading"
end
test "ai suggestions overlay sends project main language for posts", %{project: project} do
Application.put_env(:bds, :test_pid, self())
server =
start_supervised!({Bandit, plug: AiSuggestionsServer, port: 0, startup_log: false})
{:ok, {_address, port}} = ThousandIsland.listener_info(server)
assert :ok = AI.set_airplane_mode(false)
assert {:ok, _endpoint} =
AI.put_endpoint(:online, %{
url: "http://127.0.0.1:#{port}/v1",
api_key: "test-secret",
model: "gpt-test"
})
assert :ok = AI.put_model_preference(:title, "gpt-test")
assert {:ok, _metadata} = Metadata.update_project_metadata(project.id, %{main_language: "de"})
{:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "German Post",
content: "Some content for AI analysis"
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html =
render_click(view, "pin_sidebar_item", %{
"route" => "post",
"id" => post.id,
"title" => post.title,
"subtitle" => "draft"
})
assert html =~ ~s(data-testid="post-editor")
html =
view
|> element("[data-testid='post-editor'] .quick-actions-btn")
|> render_click()
assert html =~ "quick-actions-menu"
html =
view
|> element("[phx-click='open_overlay'][phx-value-kind='ai_suggestions']")
|> render_click()
assert html =~ "ai-suggestions-modal"
assert_receive {:ai_suggestions_request, request}, 2_000
system_message = get_in(request, ["messages", Access.at(0), "content"]) || ""
user_message = get_in(request, ["messages", Access.at(1), "content"]) || ""
assert system_message =~ "German"
assert user_message =~ "German"
end
test "ai suggestions overlay is gated by offline mode for media", %{project: project} do
assert :ok = AI.set_airplane_mode(true)
@@ -2532,6 +2594,77 @@ defmodule BDS.Desktop.ShellLiveTest do
refute html =~ "Loading"
end
test "ai suggestions overlay sends project main language for media", %{project: project} do
Application.put_env(:bds, :test_pid, self())
server =
start_supervised!({Bandit, plug: AiSuggestionsServer, port: 0, startup_log: false})
{:ok, {_address, port}} = ThousandIsland.listener_info(server)
assert :ok = AI.set_airplane_mode(false)
assert {:ok, _endpoint} =
AI.put_endpoint(:online, %{
url: "http://127.0.0.1:#{port}/v1",
api_key: "test-secret",
model: "gpt-test"
})
assert :ok = AI.put_model_preference(:image_analysis, "gpt-test")
assert :ok = AI.put_model_capabilities("gpt-test", %{supports_attachment: true})
assert {:ok, _metadata} = Metadata.update_project_metadata(project.id, %{main_language: "de"})
temp_dir =
Path.join(System.tmp_dir!(), "bds-shell-live-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
media_source_path = Path.join(temp_dir, "german-media.jpg")
File.write!(media_source_path, "fake image body")
{:ok, media} =
Media.import_media(%{
project_id: project.id,
source_path: media_source_path,
title: "German Media"
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html =
render_click(view, "pin_sidebar_item", %{
"route" => "media",
"id" => media.id,
"title" => media.title,
"subtitle" => "draft"
})
assert html =~ ~s(data-testid="media-editor")
html =
view
|> element("[data-testid='media-editor'] .quick-actions-btn")
|> render_click()
assert html =~ "quick-actions-menu"
html =
view
|> element("[phx-click='open_overlay'][phx-value-kind='ai_suggestions']")
|> render_click()
assert html =~ "ai-suggestions-modal"
assert_receive {:ai_suggestions_request, request}, 2_000
system_message = get_in(request, ["messages", Access.at(0), "content"]) || ""
user_message = Enum.at(request["messages"], 1)
text_content = Enum.at(user_message["content"], 0)
assert system_message =~ "German"
assert text_content["text"] =~ "German"
end
test "ai suggestions async error closes overlay and shows toast", %{project: project} do
Application.put_env(:bds, :test_pid, self())