PhoenixKitEcommerce.AITranslatable (PhoenixKitEcommerce v0.5.6)

Copy Markdown View Source

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.

Summary

Functions

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.

Idempotently rolls out this adapter's translation prompt and returns its uuid — host forms pass it per job instead of the shared default prompt.

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.

"Перевести заново" (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.

The resource-type key this adapter registers under.

"Проштамповать текущий источник как эталон" (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)).

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: 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()

@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(arg1, product_uuid)

put_translation(product, target_lang, fields, opts)

regenerate_slug(product_uuid, lang, opts \\ [])

@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(uuid, target_langs, fields \\ Map.values(%{"body" => :body_html, "description" => :description, "seo_description" => :seo_description, "seo_title" => :seo_title, "title" => :title}))

@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(product, source_lang)

stamp_reference(uuid, source_lang, target_langs, fields \\ Map.values(%{"body" => :body_html, "description" => :description, "seo_description" => :seo_description, "seo_title" => :seo_title, "title" => :title}))

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