# `PhoenixKitEcommerce.AITranslatable`
[🔗](https://github.com/BeamLabEU/phoenix_kit_ecommerce/blob/0.5.10/lib/phoenix_kit_ecommerce/ai_translatable.ex#L1)

`PhoenixKitAI.Translatable` adapter for shop products.

## Resource identity

`resource_type` is `"shop_product"`; `resource_uuid` is the product uuid.

## Fields

`%{"title", "description", "body", "seo_title", "seo_description"}` from
the source language (`Translations.get/3`), non-empty only. `"body"` maps
to the schema's `body_html` (the shared prompt vocabulary uses `body`).
The slug is NEVER sourced from or trusted to the AI — it is regenerated
locally from the translated title, and only when the target language has
no slug yet, so re-translations can't change published URLs. This is a
write-once rule for the translation pipeline only: `regenerate_slug/2` is
the explicit, one-off repair path for a slug already shaped by an older
version of this adapter, and it always recomputes from the current title
regardless of whether a slug exists. Callers doing a bulk repair are
responsible for their own redirect/history bookkeeping — this module has
none.

## Concurrency

All languages share ONE product row's JSONB maps, so `put_translation/4`
re-reads the row under `FOR UPDATE` and merges against the latest
committed state (the publishing group-adapter pattern) — concurrent
per-language jobs serialize on the row lock and never drop a sibling
language. `update_product/2` / `update_product_translation/3` are
deliberately NOT used here: they write a stale in-memory struct without a
lock — the exact lost-update race this adapter must prevent.

Slug uniqueness within the language is checked app-side (suffix on
collision), not by asking the database to reject a collision: this
comment used to say there was no DB constraint to ask (core migration
v47 dropped it), which is no longer true. V171 added back a real one —
a `phoenix_kit_shop_product_slugs` projection table (trigger-maintained)
whose pkey is `unique_constraint(:slug, name:
"phoenix_kit_shop_product_slugs_pkey")` in `Product.changeset/2`
(mirrored for categories, design §4.2). This adapter still probes
app-side rather than relying on that constraint and catching the error —
changing that is out of scope here, but the probe is correctly described
as best-effort now for a different reason: it checks by full language
code (`"de-DE"`) while the projection buckets by base language (`"de"`),
so it can race a sibling dialect it never queried, not because nothing
in the database would catch the collision.

## Staleness / write-narrowing (design §4.1, §4.4)

The SAME `FOR UPDATE` lock that makes concurrent languages safe is also
what makes per-field write-narrowing correct: `put_translation/4`
decides, field by field, whether a translation is worth writing by
comparing `opts[:source_fields]` (the exact text this job read and
translated — see `PhoenixKitEcommerce.TranslationFingerprint`) against
the CURRENTLY stored translation and fingerprint of the freshly-locked
row, never against the possibly-stale `resource` argument. A field whose
fingerprint still matches is left untouched even though a fresh AI
response for it is sitting right there — that's what stops a routine
re-translation from clobbering a manual edit. Resetting a resource's
fingerprints (`reset_reference/3`) is the only supported way to lift
that protection ("перевести заново", design §4.4).

## Prompt

The seo fields are not in the shared translation prompt's vocabulary, so
this adapter ships its own prompt (`ensure_prompt/0`, slug
`phoenixkit-shop-product-translation`). Host forms must pass its uuid
per job — the global `ai_translation_prompt_uuid` setting stays untouched.

The prompt template lives in `prompt_attrs/0` in code, but the row in
`phoenix_kit_ai_prompts` is what's actually asked — a code change alone
reaches nobody until `ensure_prompt/0` rolls it out. That rollout (create
vs. update-in-place vs. leave-a-hand-edit-alone) is `PromptRollout.ensure/2`
(design §5.2); see that module for the full invariant. The template itself
is built on `{{SourceFields}}` (phoenix_kit_ai §9.1) — one marker section
per field actually passed — rather than one hardcoded `{{fieldname}}` slot
per field, which is what let a "skip literal placeholders" rule upstream
mistake an unbound `{{title}}` for a real placeholder and skip translating
the title outright (design §2). That rule is gone; there is nothing left
for it to misfire on.

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

`opts`:

  * `:statuses` — product-status filter (design §4.3 step 5); `nil`
    (default) applies none.
  * `:limit` — row cap (one row per `{uuid, language}` candidate
    pair, not per product).

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

Beyond the first call this is not a pure no-op read: a code change to
`prompt_attrs/0` reaches the database here, via `PromptRollout.ensure/2`
(design §5.2) — see that module for exactly when it updates a row in
place versus leaves it alone. The returned `sync_status` matters mainly
to callers surfacing rollout state (e.g. a translations management
page); `:diverged` still returns a perfectly usable uuid — an
operator-edited prompt keeps working, it's just no longer code-managed
until someone resolves the divergence by hand.

# `fetch`

# `put_translation`

# `regenerate_slug`

```elixir
@spec regenerate_slug(String.t(), String.t(), keyword()) ::
  {:ok, %{old: String.t() | nil, new: String.t()}} | {:error, term()}
```

Recomputes and stores `lang`'s slug from its CURRENT title, even when a
slug already exists. Explicit, one-off repair path — bypasses the
write-once rule `put_translation/4` enforces for the translation
pipeline. Returns `{:error, :no_title}` when `lang` has no title, and
`{:ok, %{old: slug, new: slug}}` (unchanged) when the recomputed slug
equals the stored one. Broadcasts `Events.broadcast_product_updated/1`
only when the slug actually changes.

`opts[:dry_run]` (default `false`): when `true`, computes and returns the
same `{:ok, %{old: old, new: new}}` result WITHOUT writing anything — no
update, no broadcast. Lets a bulk repair task preview what would change.

# `reset_reference`

```elixir
@spec reset_reference(String.t(), [String.t()], [atom()]) ::
  {:ok, PhoenixKitEcommerce.Product.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, so the next
`put_translation/4` for that pair writes again even if the source
hasn't changed. Until that next call lands, the reset field reads as
`:unknown` (design §4.4: "пара со сброшенным эталоном до завершения
задания числится unknown"), which is also why this never broadcasts a
product-updated event — nothing visible changed, only bookkeeping.

# `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.Product.t()} | {:error, term()}
```

"Проштамповать текущий источник как эталон" (design §4.1, §4.5): for
every `{lang, field}` pair in `target_langs` × `fields` (schema field
atoms; defaults to every fingerprinted field) that currently HAS a
stored translation, writes the CURRENT source text's hash as its
fingerprint — without calling the model and without touching the
translation value. A field with no stored translation is left alone
(nothing to certify — `:missing` stays `:missing`); a field whose
source is blank is left alone too (mirrors `TranslationFingerprint`'s
own "no source, no state" rule). Runs under the same `FOR UPDATE` lock
`put_translation/4` / `reset_reference/3` use, and reads the source
text itself off the freshly-locked row — never off a possibly-stale
caller-supplied struct — so this can't race a concurrent write. Every
stamped field reads as `:fresh` immediately afterward, by construction
(the fingerprint IS `hash(current source)`).

---

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