PhoenixKitCatalogue.TranslationStatus (PhoenixKitCatalogue v0.44.2)

Copy Markdown View Source

Freshness of catalogue AI translations: a per-(resource, language, FIELD) fingerprint of the source text, and the state it implies.

Fingerprints

field_fingerprint/1 sha256-hexes a single trimmed source value. field_fingerprints/1 applies it to every entry of a source_fields/2- shaped map, producing one hash per field. This is the per-field narrowing decision from the design source (§4.4, §12.2): staleness, and the write path's decision to touch a field at all, are computed FIELD BY FIELD, not over the whole resource collapsed into one hash — so a hand- corrected translation of one field survives a re-translate that only changed a sibling field.

(fingerprint/1 remains: a single order-independent hash over an entire source_fields/2 map. It predates the per-field model and is kept for callers that still want a whole-resource digest — nothing in this module writes it to storage any more.)

The fingerprint must reflect the source text as read at translation time, not whatever it looks like when the translation is written back — a sync can land on the row in between (design source doc §4.1). So capture_fingerprint/3 stashes the PER-FIELD hashes in the calling process's dictionary when source_fields/2 runs, keyed by {resource_type, uuid}; put_translation/4 in each adapter reads them back via captured_fingerprint/2 and writes the touched ones under the target language's key — falling back to hashing the freshly-locked row's OWN current source only when nothing was captured (a direct call bypassing source_fields/2).

Storage (catalogue-owned keys, additive JSONB — see the block-6 plan's amendment vs the original design source), now a MAP of field name to hash per language rather than a single hash:

  • %Item{} / %Category{}data["_translation_fingerprints"][lang][field]
  • %PhoenixKitEntities{} (a catalogue set's blueprint) → settings["translation_fingerprints"][lang][field] (single field, "label")
  • %PhoenixKitEntities.EntityData{} (a set's value) → metadata["translation_fingerprints"][lang][field] (single field, "title")

Legacy rows (pre-per-field rollout)

Resources translated before this model shipped store a single hex string at [...]["_translation_fingerprints"][lang] instead of a map (the whole-resource fingerprint/1 digest from the original rollout). Every reader here (stored_fingerprint_map/2, field_state/3, …) treats a non-map value at that key as absent — the field falls back to :unknown (given an existing translation) rather than :stale.

This is a deliberate choice over the alternative (comparing the legacy whole-resource hash against each field's fresh hash, which would virtually never match since the two hash different inputs, and so would report :stale for nearly every field). :stale feeds the sweep worker's automatic candidate list — turning every legacy row :stale on deploy would silently enqueue a re-translation storm across every legacy resource the moment this ships, spending AI tokens nobody authorized (design source §13: "a mass run happens only by separate owner decision"). :unknown is the model's existing "we don't know, ask a human" bucket, is never auto-swept, and matches what the write path already does for these rows regardless of which interpretation state/2 reports: since neither interpretation ever finds a matching per-field hash for a legacy row, the write-narrowing table's "no stored fingerprint" and "hash mismatch" branches agree — both write + stamp. So :unknown costs nothing at write time and avoids an unauthorized automatic AI bill at read time.

States

state/2 folds a (resource, language) pair into one of four states — the WORST across every field that currently has non-empty source text (a field whose source is empty right now is excluded from the fold entirely, per design source §4.1; its translation, if any, is left alone). field_state/3 answers the same question for one field alone (or nil when that field currently has no source).

  • :missing — the source is non-empty but there is no translation
  • :unknown — a translation exists but has no recorded fingerprint (pre-existing translations from before this model shipped, one written outside put_translation/4, or a legacy whole-resource fingerprint — see above)
  • :stale — the translation's fingerprint no longer matches the current source
  • :fresh — the translation's fingerprint matches the current source

Fold order (worst wins): missing > stale > unknown > fresh.

unknown is deliberately never auto-swept (see the sweep worker, Task 4) — an operator decides its fate via stamp_fresh/2/3 ("the current source is the reference") or an explicit retranslate.

Summary

Functions

Records the PER-FIELD fingerprints of source_fields for {resource_type, uuid} in the CALLING PROCESS's dictionary. Meant to be called from inside a source_fields/2 implementation, right before the value is handed to the AI engine — see the moduledoc.

Reads back the per-field fingerprints captured earlier in THIS process, or nil.

sha256 hex digest of a single trimmed field value.

Applies field_fingerprint/1 to every entry of a source_fields/2-shaped map.

The freshness state of a single FIELD of resource's translation into lang. nil when field currently has no non-empty source text — such a field is excluded from state/2's fold and its translation, if any, is left untouched by the write path.

sha256 hex digest of source_fields, order-independent: fields are sorted by key, each rendered as "field=trimmed value", joined by "\n".

Lists (resource, language) rows for type, one per language in opts[:langs].

"Reset baseline" (design source §4.4): deletes the stored fingerprints of fields (a field name or a list of them) for lang, WITHOUT touching the translation itself or calling the AI. A field with no stored fingerprint is left alone (no-op for that field). The pair drops to :unknown for each reset field until the next successful write — the sweep never auto-picks up :unknown, so a forced re-translate stays a deliberate, visible operator action rather than a sweep-driven loop.

Stamps every language that currently holds a translation of resource as :fresh against the CURRENT source — the create-time baseline. A resource saved from the form with translations already in hand (a value-mode AI translate on a not-yet-saved item, a secondary-language name typed by hand) was translated against the source it is created with, so it must not be born :unknown — a state the sweep never picks up and an operator would otherwise clear by hand, language by language (week review, 2026-09-13). Item and category only; anything else returns unchanged. Never raises: a stamp that fails leaves that pair :unknown, which is what it was.

Operator action: "the current source is canonical" — writes the CURRENT source's fingerprint under lang for every field that is both currently sourced and translated, without calling the AI. Flips the resource to :fresh for lang (or leaves it alone — see the guard below: a resource with no translation for lang has nothing to stamp).

Field-narrowed stamp_fresh/2: stamps only fields (a field name or a list of them), leaving every other field's stored fingerprint as-is. A field is skipped (no-op) unless it is both currently sourced AND translated; the call as a whole fails with {:error, :no_translation} only when NONE of the requested fields qualify.

Sync/operator action: "the value I'm about to overwrite is the reference" — writes fingerprints computed from previous_source_fields (a map of ENGINE field name => the value being REPLACED, not the resource's current value) under lang, for exactly the fields named there. Every other field's stored fingerprint is left untouched, same as stamp_fresh/3's narrowing.

The freshness state of resource's translation into lang, folded (worst wins) across every field that currently has non-empty source text. :missing when no field currently has source text at all but something is translated for lang would be surprising — that resource simply reports :missing (nothing eligible to translate) unless it is itself translated, in which case :unknown (see moduledoc).

The WHOLE per-field fingerprint map stored for resource/lang (%{field => hash}), or %{} if there is none. A legacy single-hash string at that key (see moduledoc) is treated as %{} — absent, not a match for any field.

Types

state()

@type state() :: :missing | :stale | :unknown | :fresh

Functions

capture_fingerprint(resource_type, uuid, source_fields)

@spec capture_fingerprint(String.t(), Ecto.UUID.t(), map()) :: :ok

Records the PER-FIELD fingerprints of source_fields for {resource_type, uuid} in the CALLING PROCESS's dictionary. Meant to be called from inside a source_fields/2 implementation, right before the value is handed to the AI engine — see the moduledoc.

captured_fingerprint(resource_type, uuid)

@spec captured_fingerprint(String.t(), Ecto.UUID.t()) ::
  %{required(String.t()) => String.t()} | nil

Reads back the per-field fingerprints captured earlier in THIS process, or nil.

field_fingerprint(value)

@spec field_fingerprint(String.t()) :: String.t()

sha256 hex digest of a single trimmed field value.

field_fingerprints(source_fields)

@spec field_fingerprints(map()) :: %{required(String.t()) => String.t()}

Applies field_fingerprint/1 to every entry of a source_fields/2-shaped map.

field_state(resource, lang, field)

@spec field_state(struct(), String.t(), String.t()) :: state() | nil

The freshness state of a single FIELD of resource's translation into lang. nil when field currently has no non-empty source text — such a field is excluded from state/2's fold and its translation, if any, is left untouched by the write path.

fingerprint(source_fields)

@spec fingerprint(map()) :: String.t()

sha256 hex digest of source_fields, order-independent: fields are sorted by key, each rendered as "field=trimmed value", joined by "\n".

Kept for callers that want a single whole-resource digest (and for the legacy-format discussion in the moduledoc); the per-field model below (field_fingerprint/1 / field_fingerprints/1) is what gets written to storage now.

list(type, opts \\ [])

@spec list(:item | :category | :set_label | :set_value, keyword()) :: [map()]

Lists (resource, language) rows for type, one per language in opts[:langs].

Options

  • :langs — target languages to report on (default [] — an empty list yields no rows; callers pick the languages that matter to them, e.g. the enabled non-default set or a single language filter)
  • :state — one state atom or a list of them; unfiltered when absent
  • :catalogue_uuid — scope :item/:category rows to one catalogue (ignored for :set_label/:set_value, which are catalogue-wide)
  • :page / :per_page — 1-indexed pagination (defaults 1 / 50)

reset_baseline(resource, lang, fields)

@spec reset_baseline(struct(), String.t(), String.t() | [String.t()]) ::
  {:ok, struct()} | {:error, term()}

"Reset baseline" (design source §4.4): deletes the stored fingerprints of fields (a field name or a list of them) for lang, WITHOUT touching the translation itself or calling the AI. A field with no stored fingerprint is left alone (no-op for that field). The pair drops to :unknown for each reset field until the next successful write — the sweep never auto-picks up :unknown, so a forced re-translate stays a deliberate, visible operator action rather than a sweep-driven loop.

stamp_all_translated(resource)

@spec stamp_all_translated(struct()) :: struct()

Stamps every language that currently holds a translation of resource as :fresh against the CURRENT source — the create-time baseline. A resource saved from the form with translations already in hand (a value-mode AI translate on a not-yet-saved item, a secondary-language name typed by hand) was translated against the source it is created with, so it must not be born :unknown — a state the sweep never picks up and an operator would otherwise clear by hand, language by language (week review, 2026-09-13). Item and category only; anything else returns unchanged. Never raises: a stamp that fails leaves that pair :unknown, which is what it was.

stamp_fresh(resource, lang)

@spec stamp_fresh(struct(), String.t()) :: {:ok, struct()} | {:error, term()}

Operator action: "the current source is canonical" — writes the CURRENT source's fingerprint under lang for every field that is both currently sourced and translated, without calling the AI. Flips the resource to :fresh for lang (or leaves it alone — see the guard below: a resource with no translation for lang has nothing to stamp).

stamp_fresh(resource, lang, fields)

@spec stamp_fresh(struct(), String.t(), String.t() | [String.t()]) ::
  {:ok, struct()} | {:error, term()}

Field-narrowed stamp_fresh/2: stamps only fields (a field name or a list of them), leaving every other field's stored fingerprint as-is. A field is skipped (no-op) unless it is both currently sourced AND translated; the call as a whole fails with {:error, :no_translation} only when NONE of the requested fields qualify.

stamp_preimage(resource, lang, previous_source_fields)

@spec stamp_preimage(struct(), String.t(), %{required(String.t()) => String.t()}) ::
  {:ok, struct()} | {:error, term()}

Sync/operator action: "the value I'm about to overwrite is the reference" — writes fingerprints computed from previous_source_fields (a map of ENGINE field name => the value being REPLACED, not the resource's current value) under lang, for exactly the fields named there. Every other field's stored fingerprint is left untouched, same as stamp_fresh/3's narrowing.

For a caller (a Shopify sync, say) that is about to overwrite the PRIMARY-language source of one or more fields and already knows the value it's replacing: stamping that value FIRST turns the (resource, lang) pair :stale once the sync's own write lands — "this translation was made against real prior text" — instead of :unknown, which the sweep worker never auto-picks up (see the moduledoc).

Refuses exactly like stamp_fresh/2: {:error, :no_translation} when lang has no translation at all. Locked FOR UPDATE, same as stamp_fresh/2 and /3.

state(resource, lang)

@spec state(struct(), String.t()) :: state()

The freshness state of resource's translation into lang, folded (worst wins) across every field that currently has non-empty source text. :missing when no field currently has source text at all but something is translated for lang would be surprising — that resource simply reports :missing (nothing eligible to translate) unless it is itself translated, in which case :unknown (see moduledoc).

stored_fingerprint_map(resource, lang)

@spec stored_fingerprint_map(struct(), String.t()) :: %{
  required(String.t()) => String.t()
}

The WHOLE per-field fingerprint map stored for resource/lang (%{field => hash}), or %{} if there is none. A legacy single-hash string at that key (see moduledoc) is treated as %{} — absent, not a match for any field.