fix: D1-9 implement ExecuteTransform pipeline with ordering and toast budget
This commit is contained in:
133
lib/bds/scripts/transforms.ex
Normal file
133
lib/bds/scripts/transforms.ex
Normal file
@@ -0,0 +1,133 @@
|
||||
defmodule BDS.Scripts.Transforms do
|
||||
@moduledoc """
|
||||
Runs the blogmark transform pipeline (spec: script.allium `ExecuteTransform`).
|
||||
|
||||
Enabled `transform` scripts for a project are applied sequentially to a post
|
||||
candidate produced by a `bds://new-post` blogmark deep link. Each transform
|
||||
receives the current candidate plus a context describing the blogmark source
|
||||
and origin URL, and returns the modified candidate.
|
||||
|
||||
Guarantees enforced here:
|
||||
|
||||
* `TransformTrigger` — each script receives the candidate plus
|
||||
`{source = "blogmark", url = ...}` context.
|
||||
* `TransformPipelineContinuation` — a transform error is captured per script
|
||||
and does not roll back the last valid candidate; the pipeline continues.
|
||||
* `TransformToastBudget` — at most `transform_max_toasts_per_script` toasts
|
||||
are accepted from any one transform, with a total budget of
|
||||
`transform_max_toasts_total`, each truncated to
|
||||
`transform_max_toast_length` characters.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias BDS.Scripts
|
||||
alias BDS.Scripts.Script
|
||||
alias BDS.Scripting
|
||||
alias BDS.Scripting.Capabilities.Util
|
||||
|
||||
@type data :: %{optional(String.t()) => term()}
|
||||
@type result :: %{
|
||||
data: data(),
|
||||
toasts: [String.t()],
|
||||
errors: [%{slug: String.t() | nil, reason: term()}]
|
||||
}
|
||||
|
||||
@doc """
|
||||
Applies every enabled transform script for `project_id` to `data` in order.
|
||||
|
||||
Returns `{:ok, %{data:, toasts:, errors:}}` where `data` is the final
|
||||
candidate, `toasts` are the budget-enforced messages accepted across the
|
||||
pipeline, and `errors` records any transforms that failed.
|
||||
"""
|
||||
@spec run(String.t(), data(), keyword()) :: {:ok, result()}
|
||||
def run(project_id, data, opts \\ [])
|
||||
when is_binary(project_id) and is_map(data) and is_list(opts) do
|
||||
context = %{"source" => "blogmark", "url" => Map.get(data, "url")}
|
||||
transforms = Scripts.list_transform_scripts(project_id)
|
||||
|
||||
initial = %{data: data, toasts: [], errors: [], toast_total: 0}
|
||||
|
||||
final =
|
||||
Enum.reduce(transforms, initial, fn script, acc ->
|
||||
apply_transform(project_id, script, context, acc, opts)
|
||||
end)
|
||||
|
||||
{:ok,
|
||||
%{data: final.data, toasts: Enum.reverse(final.toasts), errors: Enum.reverse(final.errors)}}
|
||||
end
|
||||
|
||||
defp apply_transform(_project_id, %Script{entrypoint: entry}, _context, acc, _opts)
|
||||
when entry in [nil, ""] do
|
||||
acc
|
||||
end
|
||||
|
||||
defp apply_transform(project_id, %Script{} = script, context, acc, opts) do
|
||||
source = Scripts.resolved_content(script)
|
||||
|
||||
case Scripting.execute_project_script(
|
||||
project_id,
|
||||
source,
|
||||
script.entrypoint,
|
||||
[acc.data, context],
|
||||
opts
|
||||
) do
|
||||
{:ok, returned} ->
|
||||
{next_data, raw_toasts} = split_return(Util.normalize_input(returned), acc.data)
|
||||
accept_toasts(%{acc | data: next_data}, raw_toasts)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("transform #{script.slug} failed: #{inspect(reason)}")
|
||||
%{acc | errors: [%{slug: script.slug, reason: reason} | acc.errors]}
|
||||
end
|
||||
end
|
||||
|
||||
# A transform may return either the candidate map directly, or a wrapper
|
||||
# `{ data = <candidate>, toasts = [...] }`. Blogmark candidates never carry a
|
||||
# nested "data" map, so the wrapper shape is unambiguous.
|
||||
defp split_return(%{"data" => %{} = inner} = wrapper, _previous) do
|
||||
{inner, toast_list(Map.get(wrapper, "toasts"))}
|
||||
end
|
||||
|
||||
defp split_return(returned, _previous) when is_map(returned), do: {returned, []}
|
||||
defp split_return(_returned, previous), do: {previous, []}
|
||||
|
||||
defp toast_list(list) when is_list(list), do: Enum.filter(list, &is_binary/1)
|
||||
defp toast_list(_other), do: []
|
||||
|
||||
defp accept_toasts(acc, raw_toasts) do
|
||||
per_script_max = config(:transform_max_toasts_per_script, 5)
|
||||
total_max = config(:transform_max_toasts_total, 20)
|
||||
max_length = config(:transform_max_toast_length, 300)
|
||||
|
||||
raw_toasts
|
||||
|> Enum.take(per_script_max)
|
||||
|> Enum.reduce(acc, fn message, inner_acc ->
|
||||
if inner_acc.toast_total >= total_max do
|
||||
inner_acc
|
||||
else
|
||||
truncated = truncate(message, max_length)
|
||||
|
||||
%{
|
||||
inner_acc
|
||||
| toasts: [truncated | inner_acc.toasts],
|
||||
toast_total: inner_acc.toast_total + 1
|
||||
}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp truncate(message, max_length) do
|
||||
if String.length(message) > max_length do
|
||||
String.slice(message, 0, max_length)
|
||||
else
|
||||
message
|
||||
end
|
||||
end
|
||||
|
||||
defp config(key, default) do
|
||||
:bds
|
||||
|> Application.fetch_env!(:scripting)
|
||||
|> Keyword.get(key, default)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user