PhoenixKit.Modules.AI.Translatable behaviour (phoenix_kit v1.7.130)

Copy Markdown View Source

Behaviour a feature module implements to make a resource AI-translatable through core's generic translation pipeline (PhoenixKit.Modules.AI.TranslateWorker + PhoenixKit.Modules.AI.Translations).

An adapter is the only per-module code needed — the load, the field extraction, and the persist. Everything else (enqueue, the Oban worker, the AI call, parsing, broadcasts, the audit log, retry policy) lives in core and is shared across every consumer.

Registration

The feature module exposes its adapters via the optional ai_translatables/0 callback on PhoenixKit.Module, returning [{resource_type, adapter_module}]:

@impl PhoenixKit.Module
def ai_translatables do
  [
    {"catalogue", PhoenixKitCatalogue.AITranslatable},
    {"catalogue_category", PhoenixKitCatalogue.AITranslatable},
    {"catalogue_item", PhoenixKitCatalogue.AITranslatable}
  ]
end

resource_type strings MUST be globally unique across all modules — namespace them ("catalogue_item", not "item"). The same adapter module may serve several resource types; it dispatches on the resource_type argument passed to each callback.

Storage contract

put_translation/4 owns the write and MUST be atomic + merge-safe. enqueue_all_missing/2 dispatches one concurrent job per target language, so several jobs write the same row's translation store at once. The resource struct handed in was loaded BEFORE the (multi-second) AI call, so it is stale by persist time — merging the new language into that in-memory struct and doing a plain update will silently drop sibling languages other jobs committed in the meantime.

Persist against the current row, one of:

  • a single atomic SQL write to the per-language path, e.g. jsonb_set(coalesce(data, '{}'), {translations, <lang>}, <fields>, true) via update_all (different languages touch different paths → no conflict); or
  • a Repo.transaction that re-reads the row lock: "FOR UPDATE", merges, and writes.

Either keeps the multilang form's edit round-trip working unchanged.

Summary

Callbacks

Load a resource by its (type, uuid). {:error, :resource_not_found} when absent.

Optional extra PubSub topics (besides the core translation topic) to fan status events out on — e.g. the module's own resource topic so an already-subscribed LV gets translation lifecycle events too.

Persist fields into resource for target_lang. Must merge (not clobber other languages). opts carries :actor_uuid.

The %{field_name => text} to translate, read in source_lang.

Types

fields()

@type fields() :: %{optional(String.t()) => String.t()}

lang()

@type lang() :: String.t()

resource_type()

@type resource_type() :: String.t()

Callbacks

fetch(resource_type, uuid)

@callback fetch(resource_type(), uuid :: String.t()) :: {:ok, struct()} | {:error, term()}

Load a resource by its (type, uuid). {:error, :resource_not_found} when absent.

pubsub_topics(resource)

(optional)
@callback pubsub_topics(resource :: struct()) :: [binary()]

Optional extra PubSub topics (besides the core translation topic) to fan status events out on — e.g. the module's own resource topic so an already-subscribed LV gets translation lifecycle events too.

put_translation(resource, target_lang, fields, opts)

@callback put_translation(
  resource :: struct(),
  target_lang :: lang(),
  fields :: fields(),
  opts :: keyword()
) :: {:ok, struct()} | {:error, term()}

Persist fields into resource for target_lang. Must merge (not clobber other languages). opts carries :actor_uuid.

source_fields(resource, source_lang)

@callback source_fields(resource :: struct(), source_lang :: lang()) :: fields()

The %{field_name => text} to translate, read in source_lang.

Return only non-empty fields — empty ones waste tokens and confuse the model. Field names become the prompt variables + ---FIELD--- markers.