Compare commits

..

3 Commits

15 changed files with 913 additions and 39 deletions

View File

@@ -48,6 +48,7 @@ defmodule BDS.BoundedAtoms do
:metadata_diff,
:regenerate_calendar,
:validate_translations,
:fill_missing_translations,
:find_duplicates,
:generate_sitemap,
:validate_site,

View File

@@ -365,6 +365,33 @@ defmodule BDS.Desktop.ShellCommands do
)
end
defp dispatch("fill_missing_translations", project, _params) do
with {:ok, metadata} <- Metadata.get_project_metadata(project.id) do
if translation_fill_enabled?(metadata) do
queue_task(
project,
"fill_missing_translations",
"Fill Missing Translations",
"AI",
fn report ->
{:ok, result} = Posts.fill_missing_translations(project.id, on_progress: report)
Map.put(result, :project_id, project.id)
end
)
else
{:ok,
%{
kind: "output",
action: "fill_missing_translations",
title: "Fill Missing Translations",
message: "All translations are up to date",
project_id: project.id,
level: "info"
}}
end
end
end
defp dispatch("find_duplicates", project, _params) do
queue_task(project, "find_duplicates", "Find Duplicate Posts", "Embeddings", fn report ->
{:ok, pairs} = Embeddings.find_duplicates(project.id, on_progress: report)
@@ -421,6 +448,19 @@ defmodule BDS.Desktop.ShellCommands do
end
end
defp translation_fill_enabled?(metadata) do
([Map.get(metadata, :main_language)] ++ Map.get(metadata, :blog_languages, []))
|> Enum.map(fn language ->
language
|> to_string()
|> String.trim()
|> String.downcase()
end)
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
|> length() > 1
end
defp rebuild_database_steps(project) do
[
%{

View File

@@ -108,7 +108,7 @@ defmodule BDS.Desktop.ShellLive do
|> MapSet.union(MapSet.new([:open_in_browser, :open_data_folder]))
|> MapSet.union(MapSet.new([:preview_post, :rebuild_database, :reindex_text]))
|> MapSet.union(MapSet.new([:rebuild_embedding_index, :metadata_diff, :regenerate_calendar]))
|> MapSet.union(MapSet.new([:validate_translations, :find_duplicates]))
|> MapSet.union(MapSet.new([:validate_translations, :fill_missing_translations, :find_duplicates]))
|> MapSet.union(MapSet.new([:generate_sitemap, :validate_site, :upload_site]))
end

View File

@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
alias BDS.AI
alias BDS.MapUtils
alias BDS.Persistence
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking}
@@ -125,6 +126,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
true ->
live_view_pid = self()
started_at = Persistence.now_ms()
task =
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
@@ -146,6 +148,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
Map.put(socket.assigns.chat_editor_requests, conversation_id, %{
ref: task.ref,
pid: task.pid,
started_at: started_at,
message: message,
content: "",
tool_events: []
@@ -200,6 +203,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
[
%{
type: :call,
id: ToolTracking.tool_call_id(tool_call),
name: ToolTracking.tool_call_name(tool_call),
arguments: ToolTracking.tool_call_arguments(tool_call)
}

View File

@@ -17,6 +17,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
request = Map.get(assigns.chat_editor_requests, conversation.id)
effective_model = AI.effective_chat_model(conversation)
available_models = AI.available_chat_models(effective_model)
streaming_tool_markers = streaming_tool_markers(messages, request)
streaming_content = streaming_content(messages, request)
%{
id: conversation.id,
@@ -31,9 +33,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
messages: build_entries(messages, assigns),
pending_user_message: pending_user_message(messages, request),
is_streaming: not is_nil(request),
streaming_content: streaming_content(request),
streaming_tool_markers: ToolTracking.tool_markers_from_events(request),
streaming_inline_surfaces: streaming_inline_surfaces(conversation.id, request, assigns),
streaming_content: streaming_content,
streaming_tool_markers: streaming_tool_markers,
streaming_inline_surfaces:
streaming_inline_surfaces(conversation.id, streaming_tool_markers, assigns),
offline?: Map.get(assigns, :offline_mode, true),
needs_api_key?: ModelSelection.needs_api_key?(Map.get(assigns, :offline_mode, true)),
action_error: Map.get(assigns.chat_editor_action_errors, conversation.id),
@@ -137,28 +140,135 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
defp pending_user_message(_messages, nil), do: nil
defp pending_user_message(messages, %{message: message}) when is_binary(message) do
defp pending_user_message(messages, %{message: message} = request) when is_binary(message) do
cond do
persisted_user_message_for_request?(messages, request) ->
nil
true ->
legacy_pending_user_message(messages, message)
end
end
defp pending_user_message(_messages, _request), do: nil
defp legacy_pending_user_message(messages, message) do
case messages |> Enum.reverse() |> Enum.find(&(&1.role not in [:system, :tool])) do
%{role: :user, content: ^message} -> nil
_other -> message
end
end
defp pending_user_message(_messages, _request), do: nil
defp streaming_content(nil), do: ""
defp streaming_content(%{content: content}) when is_binary(content), do: content
defp streaming_content(_request), do: ""
defp streaming_inline_surfaces(_conversation_id, nil, _assigns), do: []
defp streaming_content(messages, request) do
content = streaming_content(request)
defp streaming_inline_surfaces(conversation_id, request, assigns) do
if content != "" and persisted_assistant_content_for_request?(messages, request, content) do
""
else
content
end
end
defp streaming_tool_markers(_messages, nil), do: []
defp streaming_tool_markers(messages, request) do
request
|> ToolTracking.tool_markers_from_events()
|> drop_persisted_tool_markers(messages, request)
end
defp streaming_inline_surfaces(_conversation_id, [], _assigns), do: []
defp streaming_inline_surfaces(conversation_id, tool_markers, assigns) do
tool_markers
|> ToolSurfaces.build_render_surfaces("streaming-#{conversation_id}", assigns)
|> mark_surfaces_expanded(assigns)
end
defp persisted_user_message_for_request?(messages, %{message: message} = request)
when is_binary(message) do
messages
|> persisted_messages_for_request(request)
|> Enum.any?(fn persisted_message ->
persisted_message.role == :user and persisted_message.content == message
end)
end
defp persisted_user_message_for_request?(_messages, _request), do: false
defp persisted_assistant_content_for_request?(messages, request, content)
when is_binary(content) and content != "" do
messages
|> persisted_messages_for_request(request)
|> Enum.any?(fn persisted_message ->
persisted_message.role == :assistant and (persisted_message.content || "") == content
end)
end
defp persisted_assistant_content_for_request?(_messages, _request, _content), do: false
defp drop_persisted_tool_markers(tool_markers, messages, request) do
persisted_markers = persisted_tool_markers_for_request(messages, request)
{remaining, _persisted_markers} =
Enum.reduce(tool_markers, {[], persisted_markers}, fn marker, {remaining, persisted_markers} ->
case pop_matching_tool_marker(persisted_markers, marker) do
{nil, persisted_markers} -> {remaining ++ [marker], persisted_markers}
{_matched, persisted_markers} -> {remaining, persisted_markers}
end
end)
remaining
end
defp persisted_tool_markers_for_request(messages, request) do
messages
|> persisted_messages_for_request(request)
|> Enum.flat_map(fn message ->
if message.role == :assistant do
ToolTracking.normalize_tool_calls(message.tool_calls)
else
[]
end
end)
end
defp pop_matching_tool_marker(tool_markers, marker) do
case Enum.find_index(tool_markers, &same_tool_marker?(&1, marker)) do
nil -> {nil, tool_markers}
index -> {Enum.at(tool_markers, index), List.delete_at(tool_markers, index)}
end
end
defp same_tool_marker?(left, right) do
cond do
is_binary(left.id) and is_binary(right.id) ->
left.id == right.id
true ->
left.name == right.name and (left.arguments || %{}) == (right.arguments || %{})
end
end
defp persisted_messages_for_request(messages, request) do
case request_started_at(request) do
started_at when is_integer(started_at) ->
Enum.filter(messages, fn message ->
is_integer(message.created_at) and message.created_at >= started_at
end)
_other ->
[]
end
end
defp request_started_at(%{started_at: started_at}) when is_integer(started_at), do: started_at
defp request_started_at(_request), do: nil
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end

View File

@@ -8,6 +8,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
BDS.MapUtils.attr(tool_call, :name) || "tool"
end
@spec tool_call_id(term()) :: term()
def tool_call_id(tool_call) when is_map(tool_call) do
BDS.MapUtils.attr(tool_call, :id)
end
@spec tool_call_id(term()) :: term()
def tool_call_id(_tool_call), do: nil
@spec tool_call_arguments(term()) :: term()
def tool_call_arguments(tool_call) when is_map(tool_call) do
BDS.MapUtils.attr(tool_call, :arguments) || BDS.MapUtils.attr(tool_call, :args) || %{}
@@ -72,7 +80,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
markers ++
[
%{
id: nil,
id: Map.get(event, :id),
name: event.name,
arguments: event.arguments,
args_preview: tool_arguments_preview(event.arguments || %{}),

View File

@@ -471,6 +471,19 @@ defmodule BDS.Posts do
}}
defdelegate fix_invalid_translations(report), to: TranslationValidation, as: :fix_invalid
@spec fill_missing_translations(String.t(), rebuild_opts()) ::
{:ok,
%{
translated_posts: non_neg_integer(),
translated_media: non_neg_integer(),
failed_count: non_neg_integer(),
warned_count: non_neg_integer(),
nothing_to_do: boolean()
}}
defdelegate fill_missing_translations(project_id, opts \\ []),
to: AutoTranslation,
as: :fill_missing
@spec rewrite_published_post(String.t()) :: :ok
def rewrite_published_post(post_id) do
post = Repo.get!(Post, post_id)

View File

@@ -6,6 +6,7 @@ defmodule BDS.Posts.AutoTranslation do
alias BDS.AI
alias BDS.Media
alias BDS.Metadata
alias BDS.Posts
alias BDS.Posts.Post
alias BDS.Posts.PostMedia
alias BDS.Posts.Translation
@@ -33,25 +34,141 @@ defmodule BDS.Posts.AutoTranslation do
:ok
end
@doc """
Fill missing translations for published posts and their linked media.
This mirrors the legacy batch workflow: only published posts are scanned,
posts marked `do_not_translate` are skipped, generated post translations are
auto-published, and linked media translations are created for any remaining
configured languages.
"""
@spec fill_missing(String.t(), keyword()) ::
{:ok,
%{
translated_posts: non_neg_integer(),
translated_media: non_neg_integer(),
failed_count: non_neg_integer(),
warned_count: non_neg_integer(),
nothing_to_do: boolean()
}}
def fill_missing(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do
on_progress = Keyword.get(opts, :on_progress)
with {:ok, metadata} <- Metadata.get_project_metadata(project_id) do
languages = configured_languages(metadata)
if length(languages) <= 1 do
report_progress(on_progress, 1.0, "All translations are up to date")
{:ok,
%{
translated_posts: 0,
translated_media: 0,
failed_count: 0,
warned_count: 0,
nothing_to_do: true
}}
else
report_progress(on_progress, 0.0, "Scanning published posts")
published_posts =
Repo.all(
from post in Post,
where: post.project_id == ^project_id and post.status == :published,
order_by: [asc: post.created_at, asc: post.slug]
)
post_languages = existing_post_languages(project_id)
post_items =
published_posts
|> Enum.reject(& &1.do_not_translate)
|> Enum.flat_map(fn post ->
post
|> missing_languages(metadata, Map.get(post_languages, post.id, MapSet.new()))
|> Enum.map(&%{post: post, language: &1})
end)
report_progress(on_progress, 0.1, "Scanning linked media")
media_items = collect_missing_media_items(published_posts, metadata, languages)
total_items = length(post_items) + length(media_items)
if total_items == 0 do
report_progress(on_progress, 1.0, "All translations are up to date")
{:ok,
%{
translated_posts: 0,
translated_media: 0,
failed_count: 0,
warned_count: 0,
nothing_to_do: true
}}
else
report_progress(
on_progress,
0.15,
"Found #{length(post_items)} posts and #{length(media_items)} media to translate"
)
{summary, completed} =
Enum.reduce(post_items, {empty_fill_summary(), 0}, fn %{post: post, language: language},
{summary, completed} ->
report_fill_item_progress(
on_progress,
completed,
total_items,
"Translating \"#{post.title}\" to #{language}"
)
next_summary =
case translate_post(post, language, auto_publish: true) do
{:ok, _translation} -> Map.update!(summary, :translated_posts, &(&1 + 1))
{:error, _reason} -> Map.update!(summary, :failed_count, &(&1 + 1))
end
{next_summary, completed + 1}
end)
{summary, _completed} =
Enum.reduce(media_items, {summary, completed}, fn %{media_id: media_id, language: language},
{summary, completed} ->
report_fill_item_progress(
on_progress,
completed,
total_items,
"Translating media #{String.slice(media_id, 0, 8)} to #{language}"
)
next_summary =
case translate_media(media_id, language) do
{:ok, _translation} -> Map.update!(summary, :translated_media, &(&1 + 1))
{:error, _reason} -> Map.update!(summary, :failed_count, &(&1 + 1))
end
{next_summary, completed + 1}
end)
final_summary = Map.put(summary, :nothing_to_do, false)
report_progress(on_progress, 1.0, completion_message(final_summary))
{:ok, final_summary}
end
end
end
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
)
|> MapSet.new()
configured_languages
|> Enum.reject(&(&1 == source_language or &1 in existing_languages))
missing_languages(post, metadata, existing_languages)
end
defp queue_post(%Post{} = post, language) do
@@ -61,14 +178,7 @@ defmodule BDS.Posts.AutoTranslation do
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
with {:ok, saved_translation} <- translate_post(post, language) do
report.(0.85, "Post translation saved")
:ok = queue_media_cascade(post, language)
report.(1.0, "Post translation complete")
@@ -101,13 +211,7 @@ defmodule BDS.Posts.AutoTranslation do
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
with {:ok, saved_translation} <- translate_media(media_id, language) do
report.(1.0, "Media translation complete")
%{media_id: media_id, translation_id: saved_translation.id, language: language}
else
@@ -141,6 +245,156 @@ defmodule BDS.Posts.AutoTranslation do
|> Keyword.get(:auto_translation_ai_opts, [])
end
defp configured_languages(metadata) do
([Map.get(metadata, :main_language)] ++ Map.get(metadata, :blog_languages, []))
|> Enum.map(&normalize_language/1)
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.uniq()
end
defp existing_post_languages(project_id) do
Repo.all(
from translation in Translation,
where: translation.project_id == ^project_id,
select: {translation.translation_for, translation.language}
)
|> Enum.reduce(%{}, fn {post_id, language}, acc ->
Map.update(acc, post_id, MapSet.new([language]), &MapSet.put(&1, language))
end)
end
defp collect_missing_media_items(published_posts, metadata, languages) do
linked_media_ids =
published_posts
|> Enum.reject(& &1.do_not_translate)
|> Enum.flat_map(&linked_media_ids(&1.id))
|> Enum.uniq()
media_by_id =
Repo.all(from media in Media.Media, where: media.id in ^linked_media_ids)
|> Map.new(&{&1.id, &1})
media_languages = existing_media_languages(linked_media_ids)
Enum.flat_map(linked_media_ids, fn media_id ->
case Map.get(media_by_id, media_id) do
nil ->
[]
media ->
source_language = normalize_language(media.language || metadata.main_language)
existing_languages = Map.get(media_languages, media_id, MapSet.new())
languages
|> Enum.reject(&(&1 == source_language or MapSet.member?(existing_languages, &1)))
|> Enum.map(&%{media_id: media_id, language: &1})
end
end)
end
defp existing_media_languages(media_ids) do
Repo.all(
from translation in Media.Translation,
where: translation.translation_for in ^media_ids,
select: {translation.translation_for, translation.language}
)
|> Enum.reduce(%{}, fn {media_id, language}, acc ->
Map.update(acc, media_id, MapSet.new([language]), &MapSet.put(&1, language))
end)
end
defp empty_fill_summary do
%{
translated_posts: 0,
translated_media: 0,
failed_count: 0,
warned_count: 0,
nothing_to_do: false
}
end
defp completion_message(summary) do
extras =
[]
|> maybe_add_completion_detail(summary.failed_count, "failed")
|> maybe_add_completion_detail(summary.warned_count, "warnings")
if extras == [] do
"Done"
else
"Done (#{Enum.join(extras, ", ")})"
end
end
defp maybe_add_completion_detail(details, 0, _label), do: details
defp maybe_add_completion_detail(details, count, label) do
details ++ ["#{count} #{label}"]
end
defp report_fill_item_progress(on_progress, completed, total_items, message) do
progress = 0.15 + completed / total_items * 0.85
report_progress(on_progress, progress, message)
end
defp report_progress(on_progress, value, message) when is_function(on_progress, 2) do
on_progress.(value, message)
end
defp report_progress(_on_progress, _value, _message), do: :ok
defp missing_languages(%Post{} = post, metadata, existing_languages) do
source_language = normalize_language(post.language || metadata.main_language)
configured_languages(metadata)
|> Enum.reject(&(&1 == source_language or MapSet.member?(existing_languages, &1)))
end
defp translate_post(%Post{} = post, language, opts \\ []) do
auto_publish? = Keyword.get(opts, :auto_publish, false)
content = Posts.editor_body(post)
if String.trim(content) == "" do
{:error, :no_content_to_translate}
else
with {:ok, translation} <-
AI.translate_post(
%{title: post.title || "", excerpt: post.excerpt || "", content: content},
language,
ai_opts()
),
{:ok, saved_translation} <-
Posts.upsert_post_translation(post.id, language, %{
title: translation.title,
excerpt: translation.excerpt,
content: translation.content,
auto_generated: true
}),
{:ok, published_translation} <-
maybe_publish_post_translation(post.id, language, saved_translation, auto_publish?) do
{:ok, published_translation}
end
end
end
defp maybe_publish_post_translation(_post_id, _language, saved_translation, false),
do: {:ok, saved_translation}
defp maybe_publish_post_translation(post_id, language, _saved_translation, true),
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()),
{:ok, saved_translation} <-
Media.upsert_media_translation(media_id, language, %{
title: translation.title,
alt: translation.alt,
caption: translation.caption
}) do
{:ok, saved_translation}
end
end
defp configured? do
mode = if AI.airplane_mode?(), do: :airplane, else: :online

View File

@@ -96,7 +96,10 @@ defmodule BDS.Posts.RebuildFromFiles do
Slugs.unique_for_import(project_id, Map.fetch!(rebuild_file.fields, "slug"))
)
{:ok, upsert_post_from_rebuild_file(project_id, %{rebuild_file | fields: fields})}
{:ok,
upsert_post_from_rebuild_file(project_id, %{rebuild_file | fields: fields},
sync_embeddings: false
)}
end
end
else

View File

@@ -8223,7 +8223,7 @@ button.import-taxonomy-pill {
line-height: 1;
padding: 0 6px;
flex-shrink: 0;
opacity: 0;
opacity: 1;
transition: opacity 0.15s, color 0.15s;
}

View File

@@ -27,6 +27,7 @@ defmodule BDS.BoundedAtomsTest do
{"rebuild_embedding_index", :rebuild_embedding_index},
{"metadata_diff", :metadata_diff},
{"validate_translations", :validate_translations},
{"fill_missing_translations", :fill_missing_translations},
{"find_duplicates", :find_duplicates},
{"generate_sitemap", :generate_sitemap},
{"validate_site", :validate_site},

View File

@@ -1,7 +1,53 @@
defmodule BDS.Desktop.ShellCommandsTest do
use ExUnit.Case, async: false
alias BDS.AI
alias BDS.Desktop.ShellCommands
alias BDS.Media
alias BDS.Metadata
alias BDS.Posts
alias BDS.Repo
defmodule FakeRuntime do
def generate(_endpoint, request, opts) do
test_pid = Keyword.fetch!(opts, :test_pid)
send(test_pid, {:runtime_request, request.operation})
case request.operation do
:translate_post ->
{:ok,
%{
json: %{
"title" => "Hallo Welt",
"excerpt" => "Kurze Zusammenfassung",
"content" => "# Hallo Welt\n\nUbersetzter Inhalt"
},
usage: %{
input_tokens: 22,
output_tokens: 14,
cache_read_tokens: 0,
cache_write_tokens: 0
}
}}
:translate_media ->
{:ok,
%{
json: %{
"title" => "Medientitel",
"alt" => "Medien Alt",
"caption" => "Medien Beschriftung"
},
usage: %{
input_tokens: 12,
output_tokens: 10,
cache_read_tokens: 0,
cache_write_tokens: 0
}
}}
end
end
end
defmodule SlowEmbeddingBackend do
@behaviour BDS.Embeddings.Backend
@@ -18,6 +64,28 @@ defmodule BDS.Desktop.ShellCommandsTest do
end
end
defmodule BlockingEmbeddingBackend do
@behaviour BDS.Embeddings.Backend
@impl true
def model_info do
%{model_id: "blocking/test", dimensions: 384}
end
@impl true
def embed(_text, _opts) do
if test_pid = Application.get_env(:bds, :embedding_test_pid) do
send(test_pid, {:embedding_started, self()})
end
receive do
:release_embedding -> {:ok, List.duplicate(0.0, 384)}
after
5_000 -> {:ok, List.duplicate(0.0, 384)}
end
end
end
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
@@ -132,6 +200,137 @@ defmodule BDS.Desktop.ShellCommandsTest do
] = completed.result.payload.invalid_filesystem_files
end
test "fill_missing_translations queues a tracked AI task and publishes missing post and media translations",
%{project: project, temp_dir: temp_dir} do
assert {:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Hello",
excerpt: "English summary",
content: "World body",
language: "en"
})
media_source = Path.join(temp_dir, "source-image.txt")
File.write!(media_source, "image bytes")
assert {:ok, media} =
Media.import_media(%{
project_id: project.id,
source_path: media_source,
title: "Image title",
alt: "Image alt",
caption: "Image caption",
language: "en"
})
assert {:ok, _link} = Media.link_media_to_post(media.id, post.id)
assert {:ok, _published_post} = Posts.publish_post(post.id)
configure_auto_translation_test_runtime()
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
main_language: "en",
blog_languages: ["en", "de"]
})
assert {:ok, result} = ShellCommands.execute("fill_missing_translations")
assert result.kind == "task_queued"
assert result.action == "fill_missing_translations"
assert is_binary(result.task_id)
completed = wait_for_task(result.task_id, &(&1.status == :completed and is_map(&1.result)), 5_000)
assert completed.group_name == "AI"
assert completed.result.project_id == project.id
assert completed.result.translated_posts == 1
assert completed.result.translated_media == 1
assert completed.result.failed_count == 0
translation = Repo.get_by!(BDS.Posts.Translation, translation_for: post.id, language: "de")
assert translation.status == :published
assert translation.content == nil
assert is_binary(translation.file_path)
assert File.exists?(Path.join(temp_dir, translation.file_path))
media_translation =
Repo.get_by!(BDS.Media.Translation, translation_for: media.id, language: "de")
assert media_translation.title == "Medientitel"
assert media_translation.alt == "Medien Alt"
assert media_translation.caption == "Medien Beschriftung"
assert File.exists?(Path.join(temp_dir, media.file_path <> ".de.meta"))
assert_received {:runtime_request, :translate_post}
assert_received {:runtime_request, :translate_media}
end
test "fill_missing_translations returns a no-op output when only one language is configured",
%{project: project} do
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
main_language: "en",
blog_languages: ["en"]
})
assert {:ok, result} = ShellCommands.execute("fill_missing_translations")
assert result.kind == "output"
assert result.action == "fill_missing_translations"
assert result.message == "All translations are up to date"
assert BDS.Tasks.list_tasks() == []
end
test "fill_missing_translations uses the media canonical language when choosing missing media targets",
%{project: project, temp_dir: temp_dir} do
assert {:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Hallo Welt",
excerpt: "Deutsche Zusammenfassung",
content: "Deutscher Inhalt",
language: "de"
})
media_source = Path.join(temp_dir, "english-media.txt")
File.write!(media_source, "image bytes")
assert {:ok, media} =
Media.import_media(%{
project_id: project.id,
source_path: media_source,
title: "English image",
alt: "English alt",
caption: "English caption",
language: "en"
})
assert {:ok, _link} = Media.link_media_to_post(media.id, post.id)
assert {:ok, _published_post} = Posts.publish_post(post.id)
configure_auto_translation_test_runtime()
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
main_language: "de",
blog_languages: ["de", "en"]
})
assert {:ok, result} = ShellCommands.execute("fill_missing_translations")
completed = wait_for_task(result.task_id, &(&1.status == :completed and is_map(&1.result)), 5_000)
assert completed.result.translated_posts == 1
assert completed.result.translated_media == 1
assert Repo.get_by(BDS.Media.Translation, translation_for: media.id, language: "en") == nil
media_translation =
Repo.get_by!(BDS.Media.Translation, translation_for: media.id, language: "de")
assert media_translation.title == "Medientitel"
end
test "validate_site queues a tracked validation task and returns the report as an editor payload" do
assert {:ok, result} = ShellCommands.execute("validate_site")
@@ -241,6 +440,122 @@ defmodule BDS.Desktop.ShellCommandsTest do
:completed
end
test "import_metadata_diff_orphans imports paired post translations before any embedding work can stall the task",
%{project: project, temp_dir: temp_dir} do
original_embeddings = Application.get_env(:bds, :embeddings)
original_tasks = Application.get_env(:bds, :tasks, [])
original_test_pid = Application.get_env(:bds, :embedding_test_pid)
Application.put_env(:bds, :embeddings, backend: BlockingEmbeddingBackend)
Application.put_env(:bds, :embedding_test_pid, self())
Application.put_env(:bds, :tasks, Keyword.put(original_tasks, :progress_throttle_ms, 0))
on_exit(fn ->
release_blocking_embeddings()
Enum.each(BDS.Tasks.list_running_tasks(), &BDS.Tasks.cancel_task(&1.id))
_ = BDS.Tasks.clear_finished()
if original_embeddings == nil do
Application.delete_env(:bds, :embeddings)
else
Application.put_env(:bds, :embeddings, original_embeddings)
end
if original_test_pid == nil do
Application.delete_env(:bds, :embedding_test_pid)
else
Application.put_env(:bds, :embedding_test_pid, original_test_pid)
end
Application.put_env(:bds, :tasks, original_tasks)
end)
assert {:ok, _metadata} =
BDS.Metadata.update_project_metadata(project.id, %{semantic_similarity_enabled: true})
post_orphan_path = "posts/2026/04/shell-orphan-post.md"
post_translation_orphan_path = "posts/2026/04/shell-orphan-post.es.md"
File.mkdir_p!(Path.join([temp_dir, "posts", "2026", "04"]))
File.write!(
Path.join(temp_dir, post_orphan_path),
[
"---",
"id: shell-orphan-post",
"title: Shell Orphan Post",
"slug: shell-orphan-post",
"status: published",
"createdAt: 1",
"updatedAt: 1",
"publishedAt: 1",
"tags:",
"categories:",
"---",
"Orphan shell body",
""
]
|> Enum.join("\n")
)
File.write!(
Path.join(temp_dir, post_translation_orphan_path),
[
"---",
"id: shell-orphan-post-es",
"translationFor: shell-orphan-post",
"language: es",
"title: Traduccion huerfana",
"excerpt: Resumen huerfano",
"status: published",
"createdAt: 1",
"updatedAt: 1",
"publishedAt: 1",
"---",
"Contenido huerfano",
""
]
|> Enum.join("\n")
)
assert {:ok, result} =
ShellCommands.execute("import_metadata_diff_orphans", %{
"orphans" => [
%{"file_path" => post_orphan_path},
%{"file_path" => post_translation_orphan_path}
]
})
progressed =
wait_for_task(
result.task_id,
fn task ->
task.status in [:running, :completed] and is_number(task.progress) and task.progress > 0.2 and
Repo.get_by(BDS.Posts.Post, project_id: project.id, file_path: post_orphan_path) != nil and
Repo.get_by(BDS.Posts.Translation,
project_id: project.id,
file_path: post_translation_orphan_path
) != nil
end,
1_000
)
assert progressed.status in [:running, :completed]
assert is_number(progressed.progress)
assert progressed.progress > 0.2
assert Repo.get_by(BDS.Posts.Post, project_id: project.id, file_path: post_orphan_path)
assert Repo.get_by(BDS.Posts.Translation,
project_id: project.id,
file_path: post_translation_orphan_path
)
release_blocking_embeddings()
assert wait_for_task(result.task_id, &(&1.status == :completed and &1.progress == 1.0), 5_000).status ==
:completed
end
test "find_duplicates queues a tracked embeddings task and returns the report as an editor payload" do
assert {:ok, result} = ShellCommands.execute("find_duplicates")
@@ -643,4 +958,33 @@ defmodule BDS.Desktop.ShellCommandsTest do
wait_for_named_task(name, matcher, timeout - 20)
end
end
defp release_blocking_embeddings do
receive do
{:embedding_started, pid} ->
send(pid, :release_embedding)
release_blocking_embeddings()
after
0 -> :ok
end
end
defp configure_auto_translation_test_runtime do
assert {:ok, _endpoint} =
AI.put_endpoint(:online, %{
url: "https://api.example.test/v1",
api_key: "online-secret",
model: "gpt-4o-mini"
})
assert :ok = AI.set_airplane_mode(false)
assert :ok = AI.put_model_preference(:title, "gpt-4.1-mini")
Application.put_env(:bds, :posts,
auto_translation_ai_opts: [
runtime: FakeRuntime,
test_pid: self()
]
)
end
end

View File

@@ -3331,6 +3331,95 @@ defmodule BDS.Desktop.ShellLiveTest do
refute render(view) =~ "Delayed response"
end
test "chat editor does not duplicate persisted turn artifacts while the request is still active" do
assert :ok = AI.set_airplane_mode(false)
server =
start_supervised!({Bandit, plug: DelayedChatServer, port: 0, startup_log: false})
{:ok, {_address, port}} = ThousandIsland.listener_info(server)
assert {:ok, _endpoint} =
AI.put_endpoint(:online, %{
url: "http://127.0.0.1:#{port}/v1",
api_key: "online-secret",
model: "gpt-4.1"
})
assert {:ok, conversation} = AI.start_chat(%{title: "Streaming Dedupe", model: "gpt-4.1"})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html =
render_click(view, "pin_sidebar_item", %{
"route" => "chat",
"id" => conversation.id,
"title" => conversation.title,
"subtitle" => conversation.model || "chat"
})
_html = render_change(view, "change_chat_editor_input", %{"message" => "Newest question"})
_html =
view
|> element("[data-testid='chat-send-button']")
|> render_click()
assert Enum.count(AI.list_chat_messages(conversation.id), fn message ->
message.role == :user and message.content == "Newest question"
end) == 1
now = Persistence.now_ms()
Repo.insert!(
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
conversation_id: conversation.id,
role: :assistant,
content: "",
tool_calls:
Jason.encode!([
%{
"id" => "call-card-new",
"name" => "render_card",
"arguments" => %{
"title" => "Latest Missing Data",
"body" => "The second data request needs review."
}
}
]),
created_at: now + 1
})
)
send(view.pid, {
:chat_tool_call,
conversation.id,
%{
id: "call-card-new",
name: "render_card",
arguments: %{
"title" => "Latest Missing Data",
"body" => "The second data request needs review."
}
}
})
html = render(view)
refute html =~ ~s(data-testid="chat-pending-user-message")
assert length(:binary.matches(html, ~s(data-testid="chat-user-message-text"))) == 1
assert length(:binary.matches(html, ~s(data-testid="chat-inline-surface"))) == 1
refute html =~ ~s(data-testid="chat-streaming-message")
assert html =~ ~s(data-testid="chat-streaming-thinking")
_html =
view
|> element("[data-testid='chat-abort-button']")
|> render_click()
Process.sleep(350)
end
test "translation validation route renders dedicated cards and fix controls", %{
project: project,
temp_dir: temp_dir

View File

@@ -131,7 +131,7 @@ defmodule BDS.DesktopTest do
assert menu_item(groups, :metadata_diff).shortcut == nil
end
test "prod forwarded menu surface is covered by the shell dispatcher except unresolved filler action" do
test "prod forwarded menu surface is covered by the shell dispatcher" do
forwarded_actions =
BDS.Desktop.MenuBar.groups(dev_mode?: false)
|> Enum.flat_map(fn group ->
@@ -146,7 +146,7 @@ defmodule BDS.DesktopTest do
|> MapSet.difference(BDS.Desktop.ShellLive.supported_menu_actions())
|> Enum.sort()
assert unsupported_actions == [:fill_missing_translations]
assert unsupported_actions == []
end
test "native menu quit requests app-owned shutdown" do

View File

@@ -193,6 +193,13 @@ defmodule BDS.UI.ShellTest do
assert template =~ "tab-dirty-indicator"
end
test "desktop shell keeps sidebar delete buttons visible in the default state" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
assert Regex.match?(~r/\.sidebar-delete-button\s*\{[^}]*opacity:\s*1;/s, css)
refute Regex.match?(~r/\.sidebar-delete-button\s*\{[^}]*opacity:\s*0;/s, css)
end
test "desktop shell css keeps the old activity bar active marker contrast" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")