# `PhoenixKitEcommerce.CategoryAITranslatable`
[🔗](https://github.com/BeamLabEU/phoenix_kit_ecommerce/blob/0.5.11/lib/phoenix_kit_ecommerce/category_ai_translatable.ex#L1)

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

# `candidates`

```elixir
@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`

```elixir
@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`

# `put_translation`

# `reset_reference`

```elixir
@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`

# `stamp_reference`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
