PhoenixKitEcommerce.CategoryAITranslatable (PhoenixKitEcommerce v0.5.11)

Copy Markdown View Source

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

candidates(source_lang, target_langs, opts \\ [])

@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).

ensure_prompt()

@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.

fetch(arg1, category_uuid)

put_translation(category, target_lang, fields, opts)

reset_reference(uuid, target_langs, fields \\ Map.values(%{"description" => :description, "name" => :name}))

@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.

resource_type()

The resource-type key this adapter registers under.

source_fields(category, source_lang)

stamp_reference(uuid, source_lang, target_langs, fields \\ Map.values(%{"description" => :description, "name" => :name}))

@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.