Compare commits
3 Commits
24f114c24e
...
a4ea24faa2
| Author | SHA1 | Date | |
|---|---|---|---|
| a4ea24faa2 | |||
| 45040f9f66 | |||
| 4cf0f5281b |
@@ -48,6 +48,7 @@ defmodule BDS.BoundedAtoms do
|
|||||||
:metadata_diff,
|
:metadata_diff,
|
||||||
:regenerate_calendar,
|
:regenerate_calendar,
|
||||||
:validate_translations,
|
:validate_translations,
|
||||||
|
:fill_missing_translations,
|
||||||
:find_duplicates,
|
:find_duplicates,
|
||||||
:generate_sitemap,
|
:generate_sitemap,
|
||||||
:validate_site,
|
:validate_site,
|
||||||
|
|||||||
@@ -365,6 +365,33 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
)
|
)
|
||||||
end
|
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
|
defp dispatch("find_duplicates", project, _params) do
|
||||||
queue_task(project, "find_duplicates", "Find Duplicate Posts", "Embeddings", fn report ->
|
queue_task(project, "find_duplicates", "Find Duplicate Posts", "Embeddings", fn report ->
|
||||||
{:ok, pairs} = Embeddings.find_duplicates(project.id, on_progress: report)
|
{:ok, pairs} = Embeddings.find_duplicates(project.id, on_progress: report)
|
||||||
@@ -421,6 +448,19 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end
|
end
|
||||||
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
|
defp rebuild_database_steps(project) do
|
||||||
[
|
[
|
||||||
%{
|
%{
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|> MapSet.union(MapSet.new([:open_in_browser, :open_data_folder]))
|
|> MapSet.union(MapSet.new([:open_in_browser, :open_data_folder]))
|
||||||
|> MapSet.union(MapSet.new([:preview_post, :rebuild_database, :reindex_text]))
|
|> 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([: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]))
|
|> MapSet.union(MapSet.new([:generate_sitemap, :validate_site, :upload_site]))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
|
|
||||||
alias BDS.AI
|
alias BDS.AI
|
||||||
alias BDS.MapUtils
|
alias BDS.MapUtils
|
||||||
|
alias BDS.Persistence
|
||||||
alias BDS.Desktop.ShellData
|
alias BDS.Desktop.ShellData
|
||||||
alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking}
|
alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking}
|
||||||
|
|
||||||
@@ -125,6 +126,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
|
|
||||||
true ->
|
true ->
|
||||||
live_view_pid = self()
|
live_view_pid = self()
|
||||||
|
started_at = Persistence.now_ms()
|
||||||
|
|
||||||
task =
|
task =
|
||||||
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
|
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, %{
|
Map.put(socket.assigns.chat_editor_requests, conversation_id, %{
|
||||||
ref: task.ref,
|
ref: task.ref,
|
||||||
pid: task.pid,
|
pid: task.pid,
|
||||||
|
started_at: started_at,
|
||||||
message: message,
|
message: message,
|
||||||
content: "",
|
content: "",
|
||||||
tool_events: []
|
tool_events: []
|
||||||
@@ -200,6 +203,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
[
|
[
|
||||||
%{
|
%{
|
||||||
type: :call,
|
type: :call,
|
||||||
|
id: ToolTracking.tool_call_id(tool_call),
|
||||||
name: ToolTracking.tool_call_name(tool_call),
|
name: ToolTracking.tool_call_name(tool_call),
|
||||||
arguments: ToolTracking.tool_call_arguments(tool_call)
|
arguments: ToolTracking.tool_call_arguments(tool_call)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|
|||||||
request = Map.get(assigns.chat_editor_requests, conversation.id)
|
request = Map.get(assigns.chat_editor_requests, conversation.id)
|
||||||
effective_model = AI.effective_chat_model(conversation)
|
effective_model = AI.effective_chat_model(conversation)
|
||||||
available_models = AI.available_chat_models(effective_model)
|
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,
|
id: conversation.id,
|
||||||
@@ -31,9 +33,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|
|||||||
messages: build_entries(messages, assigns),
|
messages: build_entries(messages, assigns),
|
||||||
pending_user_message: pending_user_message(messages, request),
|
pending_user_message: pending_user_message(messages, request),
|
||||||
is_streaming: not is_nil(request),
|
is_streaming: not is_nil(request),
|
||||||
streaming_content: streaming_content(request),
|
streaming_content: streaming_content,
|
||||||
streaming_tool_markers: ToolTracking.tool_markers_from_events(request),
|
streaming_tool_markers: streaming_tool_markers,
|
||||||
streaming_inline_surfaces: streaming_inline_surfaces(conversation.id, request, assigns),
|
streaming_inline_surfaces:
|
||||||
|
streaming_inline_surfaces(conversation.id, streaming_tool_markers, assigns),
|
||||||
offline?: Map.get(assigns, :offline_mode, true),
|
offline?: Map.get(assigns, :offline_mode, true),
|
||||||
needs_api_key?: ModelSelection.needs_api_key?(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),
|
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, 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
|
case messages |> Enum.reverse() |> Enum.find(&(&1.role not in [:system, :tool])) do
|
||||||
%{role: :user, content: ^message} -> nil
|
%{role: :user, content: ^message} -> nil
|
||||||
_other -> message
|
_other -> message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp pending_user_message(_messages, _request), do: nil
|
|
||||||
|
|
||||||
defp streaming_content(nil), do: ""
|
defp streaming_content(nil), do: ""
|
||||||
defp streaming_content(%{content: content}) when is_binary(content), do: content
|
defp streaming_content(%{content: content}) when is_binary(content), do: content
|
||||||
defp streaming_content(_request), do: ""
|
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
|
request
|
||||||
|> ToolTracking.tool_markers_from_events()
|
|> 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)
|
|> ToolSurfaces.build_render_surfaces("streaming-#{conversation_id}", assigns)
|
||||||
|> mark_surfaces_expanded(assigns)
|
|> mark_surfaces_expanded(assigns)
|
||||||
end
|
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 \\ %{}),
|
defp translated(text, bindings \\ %{}),
|
||||||
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
|
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
|
|||||||
BDS.MapUtils.attr(tool_call, :name) || "tool"
|
BDS.MapUtils.attr(tool_call, :name) || "tool"
|
||||||
end
|
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()
|
@spec tool_call_arguments(term()) :: term()
|
||||||
def tool_call_arguments(tool_call) when is_map(tool_call) do
|
def tool_call_arguments(tool_call) when is_map(tool_call) do
|
||||||
BDS.MapUtils.attr(tool_call, :arguments) || BDS.MapUtils.attr(tool_call, :args) || %{}
|
BDS.MapUtils.attr(tool_call, :arguments) || BDS.MapUtils.attr(tool_call, :args) || %{}
|
||||||
@@ -72,7 +80,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
|
|||||||
markers ++
|
markers ++
|
||||||
[
|
[
|
||||||
%{
|
%{
|
||||||
id: nil,
|
id: Map.get(event, :id),
|
||||||
name: event.name,
|
name: event.name,
|
||||||
arguments: event.arguments,
|
arguments: event.arguments,
|
||||||
args_preview: tool_arguments_preview(event.arguments || %{}),
|
args_preview: tool_arguments_preview(event.arguments || %{}),
|
||||||
|
|||||||
@@ -471,6 +471,19 @@ defmodule BDS.Posts do
|
|||||||
}}
|
}}
|
||||||
defdelegate fix_invalid_translations(report), to: TranslationValidation, as: :fix_invalid
|
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
|
@spec rewrite_published_post(String.t()) :: :ok
|
||||||
def rewrite_published_post(post_id) do
|
def rewrite_published_post(post_id) do
|
||||||
post = Repo.get!(Post, post_id)
|
post = Repo.get!(Post, post_id)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ defmodule BDS.Posts.AutoTranslation do
|
|||||||
alias BDS.AI
|
alias BDS.AI
|
||||||
alias BDS.Media
|
alias BDS.Media
|
||||||
alias BDS.Metadata
|
alias BDS.Metadata
|
||||||
|
alias BDS.Posts
|
||||||
alias BDS.Posts.Post
|
alias BDS.Posts.Post
|
||||||
alias BDS.Posts.PostMedia
|
alias BDS.Posts.PostMedia
|
||||||
alias BDS.Posts.Translation
|
alias BDS.Posts.Translation
|
||||||
@@ -33,25 +34,141 @@ defmodule BDS.Posts.AutoTranslation do
|
|||||||
:ok
|
:ok
|
||||||
end
|
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
|
@doc false
|
||||||
def missing_languages(%Post{} = post, metadata) do
|
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 =
|
existing_languages =
|
||||||
Repo.all(
|
Repo.all(
|
||||||
from translation in Translation,
|
from translation in Translation,
|
||||||
where: translation.translation_for == ^post.id,
|
where: translation.translation_for == ^post.id,
|
||||||
select: translation.language
|
select: translation.language
|
||||||
)
|
)
|
||||||
|
|> MapSet.new()
|
||||||
|
|
||||||
configured_languages
|
missing_languages(post, metadata, existing_languages)
|
||||||
|> Enum.reject(&(&1 == source_language or &1 in existing_languages))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp queue_post(%Post{} = post, language) do
|
defp queue_post(%Post{} = post, language) do
|
||||||
@@ -61,14 +178,7 @@ defmodule BDS.Posts.AutoTranslation do
|
|||||||
fn report ->
|
fn report ->
|
||||||
report.(0.05, "Translating post to #{language}")
|
report.(0.05, "Translating post to #{language}")
|
||||||
|
|
||||||
with {:ok, translation} <- AI.translate_post(post.id, language, ai_opts()),
|
with {:ok, saved_translation} <- translate_post(post, language) do
|
||||||
{: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")
|
report.(0.85, "Post translation saved")
|
||||||
:ok = queue_media_cascade(post, language)
|
:ok = queue_media_cascade(post, language)
|
||||||
report.(1.0, "Post translation complete")
|
report.(1.0, "Post translation complete")
|
||||||
@@ -101,13 +211,7 @@ defmodule BDS.Posts.AutoTranslation do
|
|||||||
fn report ->
|
fn report ->
|
||||||
report.(0.05, "Translating media to #{language}")
|
report.(0.05, "Translating media to #{language}")
|
||||||
|
|
||||||
with {:ok, translation} <- AI.translate_media(media_id, language, ai_opts()),
|
with {:ok, saved_translation} <- translate_media(media_id, language) do
|
||||||
{: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")
|
report.(1.0, "Media translation complete")
|
||||||
%{media_id: media_id, translation_id: saved_translation.id, language: language}
|
%{media_id: media_id, translation_id: saved_translation.id, language: language}
|
||||||
else
|
else
|
||||||
@@ -141,6 +245,156 @@ defmodule BDS.Posts.AutoTranslation do
|
|||||||
|> Keyword.get(:auto_translation_ai_opts, [])
|
|> Keyword.get(:auto_translation_ai_opts, [])
|
||||||
end
|
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
|
defp configured? do
|
||||||
mode = if AI.airplane_mode?(), do: :airplane, else: :online
|
mode = if AI.airplane_mode?(), do: :airplane, else: :online
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,10 @@ defmodule BDS.Posts.RebuildFromFiles do
|
|||||||
Slugs.unique_for_import(project_id, Map.fetch!(rebuild_file.fields, "slug"))
|
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
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -8223,7 +8223,7 @@ button.import-taxonomy-pill {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
opacity: 0;
|
opacity: 1;
|
||||||
transition: opacity 0.15s, color 0.15s;
|
transition: opacity 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ defmodule BDS.BoundedAtomsTest do
|
|||||||
{"rebuild_embedding_index", :rebuild_embedding_index},
|
{"rebuild_embedding_index", :rebuild_embedding_index},
|
||||||
{"metadata_diff", :metadata_diff},
|
{"metadata_diff", :metadata_diff},
|
||||||
{"validate_translations", :validate_translations},
|
{"validate_translations", :validate_translations},
|
||||||
|
{"fill_missing_translations", :fill_missing_translations},
|
||||||
{"find_duplicates", :find_duplicates},
|
{"find_duplicates", :find_duplicates},
|
||||||
{"generate_sitemap", :generate_sitemap},
|
{"generate_sitemap", :generate_sitemap},
|
||||||
{"validate_site", :validate_site},
|
{"validate_site", :validate_site},
|
||||||
|
|||||||
@@ -1,7 +1,53 @@
|
|||||||
defmodule BDS.Desktop.ShellCommandsTest do
|
defmodule BDS.Desktop.ShellCommandsTest do
|
||||||
use ExUnit.Case, async: false
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
alias BDS.AI
|
||||||
alias BDS.Desktop.ShellCommands
|
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
|
defmodule SlowEmbeddingBackend do
|
||||||
@behaviour BDS.Embeddings.Backend
|
@behaviour BDS.Embeddings.Backend
|
||||||
@@ -18,6 +64,28 @@ defmodule BDS.Desktop.ShellCommandsTest do
|
|||||||
end
|
end
|
||||||
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
|
setup do
|
||||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||||
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
|
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
|
||||||
@@ -132,6 +200,137 @@ defmodule BDS.Desktop.ShellCommandsTest do
|
|||||||
] = completed.result.payload.invalid_filesystem_files
|
] = completed.result.payload.invalid_filesystem_files
|
||||||
end
|
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
|
test "validate_site queues a tracked validation task and returns the report as an editor payload" do
|
||||||
assert {:ok, result} = ShellCommands.execute("validate_site")
|
assert {:ok, result} = ShellCommands.execute("validate_site")
|
||||||
|
|
||||||
@@ -241,6 +440,122 @@ defmodule BDS.Desktop.ShellCommandsTest do
|
|||||||
:completed
|
:completed
|
||||||
end
|
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
|
test "find_duplicates queues a tracked embeddings task and returns the report as an editor payload" do
|
||||||
assert {:ok, result} = ShellCommands.execute("find_duplicates")
|
assert {:ok, result} = ShellCommands.execute("find_duplicates")
|
||||||
|
|
||||||
@@ -643,4 +958,33 @@ defmodule BDS.Desktop.ShellCommandsTest do
|
|||||||
wait_for_named_task(name, matcher, timeout - 20)
|
wait_for_named_task(name, matcher, timeout - 20)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@@ -3331,6 +3331,95 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
refute render(view) =~ "Delayed response"
|
refute render(view) =~ "Delayed response"
|
||||||
end
|
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", %{
|
test "translation validation route renders dedicated cards and fix controls", %{
|
||||||
project: project,
|
project: project,
|
||||||
temp_dir: temp_dir
|
temp_dir: temp_dir
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ defmodule BDS.DesktopTest do
|
|||||||
assert menu_item(groups, :metadata_diff).shortcut == nil
|
assert menu_item(groups, :metadata_diff).shortcut == nil
|
||||||
end
|
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 =
|
forwarded_actions =
|
||||||
BDS.Desktop.MenuBar.groups(dev_mode?: false)
|
BDS.Desktop.MenuBar.groups(dev_mode?: false)
|
||||||
|> Enum.flat_map(fn group ->
|
|> Enum.flat_map(fn group ->
|
||||||
@@ -146,7 +146,7 @@ defmodule BDS.DesktopTest do
|
|||||||
|> MapSet.difference(BDS.Desktop.ShellLive.supported_menu_actions())
|
|> MapSet.difference(BDS.Desktop.ShellLive.supported_menu_actions())
|
||||||
|> Enum.sort()
|
|> Enum.sort()
|
||||||
|
|
||||||
assert unsupported_actions == [:fill_missing_translations]
|
assert unsupported_actions == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "native menu quit requests app-owned shutdown" do
|
test "native menu quit requests app-owned shutdown" do
|
||||||
|
|||||||
@@ -193,6 +193,13 @@ defmodule BDS.UI.ShellTest do
|
|||||||
assert template =~ "tab-dirty-indicator"
|
assert template =~ "tab-dirty-indicator"
|
||||||
end
|
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
|
test "desktop shell css keeps the old activity bar active marker contrast" do
|
||||||
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
|
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user