PhoenixKitAI.Translatable adapter for shop categories (design §4.2).
Resource identity
resource_type is "shop_category"; resource_uuid is the category
uuid.
Fields
%{"name", "description"} from the source language
(Translations.get/3 is deliberately NOT used — see source_fields/2),
non-empty only. There is no slug in this map: like products, the slug
is NEVER sourced from or trusted to the AI. Unlike products it isn't
generated by hand here either — Category.changeset/2 already builds
per-language slugs via LocalizedSlug.maybe_generate/2 on create, and
this adapter calls that SAME shared function on its own changeset after
merging (see "Slug" below), rather than reimplementing product's
slug_base/unique_slug machinery.
Concurrency
All languages share ONE category row's JSONB maps, so put_translation/4
re-reads the row under FOR UPDATE and merges against the latest
committed state — the exact same locked-merge pattern
PhoenixKitEcommerce.AITranslatable uses for products (the publishing
group-adapter pattern), concurrent per-language jobs serialize on the
row lock and never drop a sibling language.
update_category/2 / update_category_translation/3 are deliberately
NOT used here, and this is not a stylistic choice: update_category/2
(phoenix_kit_ecommerce.ex:4619's update_category_translation/3 calls
straight through to it) builds a changeset off the in-memory category
argument and does a plain repo().update/1 — no lock at all. Two
concurrent language jobs (de and fr) on one category would each load the
row, translate for ~45s, then write their own stale copy — the second
write clobbers the first's language entirely. merge_translation/4
below is this adapter's own Repo.transaction + lock("FOR UPDATE") +
merge, built the same way AITranslatable.merge_translation/4 is.
Slug
Category.changeset/2 runs LocalizedSlug.maybe_generate(:name)
(category.ex:96), but that changeset also runs full validation
(validate_localized_required, validate_no_circular_parent, ...) that
has no business running on a locked single-field merge. So this adapter
builds its changeset with a bare Ecto.Changeset.change/2 — same as the
product adapter's write path — and then explicitly pipes it through
LocalizedSlug.maybe_generate(:name) itself, since a bare change/2
does not run that step on its own. LocalizedSlug.maybe_generate/2
already only fills a BLANK per-language slug from that language's
(possibly just-merged) name text and leaves an existing one untouched
(write-once) — so unlike the product adapter there is no separate
"which title text is true after this write" fallback to compute by
hand: reading get_field(changeset, :name) after the merge already
gives the right answer for both "title written this round" and "title
write-narrowed away but a stored translation already exists".
Slug uniqueness is enforced by the DATABASE, not app-side: V171's
phoenix_kit_shop_category_slugs projection table (trigger
trg_shop_category_slugs) carries a real pkey on (base language, slug
value), which Category.changeset/2 guards with
unique_constraint(:slug, name: "phoenix_kit_shop_category_slugs_pkey")
(category.ex:99). A bare Ecto.Changeset.change/2 does not carry that
declaration either, so this adapter attaches it itself before updating —
without it, a same-language name collision would raise
Ecto.ConstraintError out of repo().update/1 instead of returning a
changeset error the worker can discard as persist_error.
Staleness / write-narrowing (design §4.1, §4.4)
Identical model to products, built on the SAME shared
PhoenixKitEcommerce.TranslationFingerprint module: put_translation/4
decides, field by field, whether a translation is worth writing by
comparing opts[:source_fields] against the CURRENTLY stored
translation and fingerprint of the freshly-locked row, never against the
possibly-stale resource argument. Resetting a category's fingerprints
(reset_reference/3) is the only supported way to lift that protection
("перевести заново", design §4.4).
Prompt
Categories get their OWN prompt (ensure_prompt/0, slug
phoenixkit-shop-category-translation) rather than sharing
phoenixkit-translate-content (that slug belongs to
phoenix_kit_publishing; repurposing it would mean changing another
module's prompt format to save one database row). Built on
{{SourceFields}} from day one — no literal-placeholder predecessor
exists for this slug, so ensure_prompt/0 ships with an empty
known_previous_shas list; there is nothing to adopt.
Requires the optional phoenix_kit_ai plugin: ensure_prompt/0 returns
{:error, :ai_not_installed} when it is absent, and the whole adapter is
only reached through duck-typed discovery, which never runs without it.
Summary
Functions
Design §4.3's candidate query for categories: categories with at least
one field :missing or :stale (design §4.1) for a target language,
hashed entirely in the database — see
PhoenixKitEcommerce.TranslationFingerprint.select_candidates/2.
Idempotently rolls out this adapter's translation prompt and returns its
uuid — host forms pass it per job instead of the shared default prompt.
See AITranslatable.ensure_prompt/0 for the full rollout contract
(PhoenixKitEcommerce.PromptRollout, design §5.2); this prompt has no
predecessor to adopt, so known_previous_shas is empty.
"Перевести заново" (design §4.4): erases the stored fingerprints for
target_langs × fields (schema field atoms; defaults to every
fingerprinted field) under the same FOR UPDATE lock
put_translation/4 uses. The translated content itself is untouched —
this only lifts write-narrowing's protection. Mirrors
AITranslatable.reset_reference/3; see there for the full rationale.
The resource-type key this adapter registers under.
"Проштамповать текущий источник как эталон" (design §4.1, §4.5). Mirrors
AITranslatable.stamp_reference/4 — see there for the full rationale:
for every {lang, field} pair in target_langs × fields that
currently HAS a stored translation, writes the current source's hash as
its fingerprint, without calling the model and without touching the
translation value. Runs under the same FOR UPDATE lock
put_translation/4 / reset_reference/3 use.
Functions
@spec candidates(String.t(), [String.t()], keyword()) :: [ %{uuid: String.t(), languages: [String.t()]} ]
Design §4.3's candidate query for categories: categories with at least
one field :missing or :stale (design §4.1) for a target language,
hashed entirely in the database — see
PhoenixKitEcommerce.TranslationFingerprint.select_candidates/2.
Unlike AITranslatable.candidates/3, there is no status filter: design
§4.3 explicitly excludes categories from shop_translation_statuses — a
hidden category would otherwise ship translated navigation before it's
visible.
opts:
:limit— row cap (one row per{uuid, language}candidate pair, not per category).
@spec ensure_prompt() :: {:ok, String.t(), PhoenixKitEcommerce.PromptRollout.sync_status()} | {:error, term()}
Idempotently rolls out this adapter's translation prompt and returns its
uuid — host forms pass it per job instead of the shared default prompt.
See AITranslatable.ensure_prompt/0 for the full rollout contract
(PhoenixKitEcommerce.PromptRollout, design §5.2); this prompt has no
predecessor to adopt, so known_previous_shas is empty.
@spec reset_reference(String.t(), [String.t()], [atom()]) :: {:ok, PhoenixKitEcommerce.Category.t()} | {:error, term()}
"Перевести заново" (design §4.4): erases the stored fingerprints for
target_langs × fields (schema field atoms; defaults to every
fingerprinted field) under the same FOR UPDATE lock
put_translation/4 uses. The translated content itself is untouched —
this only lifts write-narrowing's protection. Mirrors
AITranslatable.reset_reference/3; see there for the full rationale.
The resource-type key this adapter registers under.
@spec stamp_reference(String.t(), String.t(), [String.t()], [atom()]) :: {:ok, PhoenixKitEcommerce.Category.t()} | {:error, term()}
"Проштамповать текущий источник как эталон" (design §4.1, §4.5). Mirrors
AITranslatable.stamp_reference/4 — see there for the full rationale:
for every {lang, field} pair in target_langs × fields that
currently HAS a stored translation, writes the current source's hash as
its fingerprint, without calling the model and without touching the
translation value. Runs under the same FOR UPDATE lock
put_translation/4 / reset_reference/3 use.