# `PhoenixKit.Modules.AI.Translation`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v1.7.117/lib/modules/ai/translation.ex#L1)

Generic AI translation helper. Translates a `%{field_name => text}`
map from `source_lang` to `target_lang` using the optional
`PhoenixKitAI` plugin, and parses the structured response back into
the same shape.

Designed to be the single orchestration layer shared by every
feature module that wants AI translation — `phoenix_kit_publishing`,
`phoenix_kit_projects`, future consumers. Each module wraps this
helper in its own Oban worker (or controller action) and owns the
per-module storage write, broadcasts, caching, and per-resource
activity log entry.

## What lives here

- Prompt rendering with `{{SourceLanguage}}` / `{{TargetLanguage}}`
  / arbitrary field-name variables.
- The `PhoenixKitAI.ask_with_prompt/4` call, guarded so absence of
  the plugin returns `{:error, :ai_not_installed}` instead of
  raising.
- A structured-response parser for the `---FIELD_NAME---` shape
  that publishing's prompt template ships with. Generalised — any
  list of field names is accepted, ordering follows the input.
- Error normalisation: every failure path returns
  `{:error, atom_or_tuple}` so callers can pattern-match without
  knowing whether the failure came from the plugin, the parser, or
  a network blip.
- A single core activity-log entry (`core.ai_translation.requested`)
  on every dispatched request — gives Max a unified audit trail of
  AI token spend regardless of which feature module triggered it.
  Modules still log their own per-resource action (e.g.
  `publishing.translation.added`, `projects.translation.added`).

## What does NOT live here

- Storage of the translation result — each consumer owns its own
  write path (publishing's `publishing_content` rows, projects'
  `Project.translations` JSONB, etc.).
- Broadcasts — each consumer has its own PubSub topic shape.
- Per-language status (in-flight, completed, errored) — that's the
  consumer's worker and the host UI.
- Retry policy / queue choice — each consumer's Oban worker picks
  its own queue, max-attempts, and uniqueness constraints. This
  module is a synchronous function-call shape; consumers wrap it.

## Usage

    Translation.translate_fields(
      endpoint_uuid,
      prompt_uuid,
      "en",
      "es",
      %{"title" => "Hello", "body" => "World"}
    )
    # => {:ok, %{"title" => "Hola", "body" => "Mundo"}}
    # |  {:error, :ai_not_installed}
    # |  {:error, :no_endpoint}
    # |  {:error, :missing_prompt}
    # |  {:error, {:ai_error, reason}}
    # |  {:error, {:parse_error, reason}}

# `field_map`

```elixir
@type field_map() :: %{required(String.t()) =&gt; String.t()}
```

# `translation_result`

```elixir
@type translation_result() ::
  {:ok, field_map()}
  | {:error,
     :ai_not_installed
     | :no_endpoint
     | :missing_prompt
     | {:ai_error, term()}
     | {:parse_error, term()}}
```

# `parse_response`

```elixir
@spec parse_response(String.t(), [String.t()]) ::
  {:ok, field_map()} | {:error, {:parse_error, term()}}
```

Parses a structured `---FIELD_NAME---` response into a field map.

Public for testing — consumers normally hit `translate_fields/6`,
which calls this internally. Useful when a caller already has the
raw AI response (e.g. a previously cached completion) and just
needs to extract field values.

Field names are matched case-insensitively against the markers but
the returned map preserves the input casing of `fields` so callers
can round-trip with their original field-name strings.

**All requested fields must be present** in the response. When the
model returns a partial response (e.g. it forgot the `---SLUG---`
marker), this function returns
`{:error, {:parse_error, {:missing_fields, [...]}}}` so the caller
can decide whether to retry, fall back to the source value, or
surface an error to the user — rather than silently persisting a
half-translated row.

    iex> Translation.parse_response(
    ...>   "---TITLE---\nHola\n---BODY---\nMundo",
    ...>   ["title", "body"]
    ...> )
    {:ok, %{"title" => "Hola", "body" => "Mundo"}}

    iex> Translation.parse_response("---TITLE---\nonly", ["title", "body"])
    {:error, {:parse_error, {:missing_fields, ["body"]}}}

    iex> Translation.parse_response(":shrug:", ["title"])
    {:error, {:parse_error, :no_markers}}

# `translate_fields`

```elixir
@spec translate_fields(
  String.t(),
  String.t(),
  String.t(),
  String.t(),
  field_map(),
  keyword()
) :: translation_result()
```

Translate `fields` from `source_lang` to `target_lang`.

- `endpoint_uuid` — UUID of a configured `PhoenixKitAI` endpoint.
  Required even when the plugin is installed; the plugin's prompt
  machinery binds to a specific endpoint at call time.
- `prompt_uuid` — UUID of a `PhoenixKitAI.Prompt` whose template
  references `{{SourceLanguage}}`, `{{TargetLanguage}}`, and one
  `{{<FieldName>}}` placeholder per key in `fields`.
- `source_lang` / `target_lang` — language codes (base or dialect;
  the helper passes them through unchanged).
- `fields` — `%{field_name => text}` map. Field names become the
  structured-response markers (`---<FIELD_NAME>---`, uppercased).

## Options

- `:actor_uuid` — included in the `core.ai_translation.requested`
  activity log entry. When omitted the log is still written with
  `actor_uuid: nil`.
- `:resource_type` / `:resource_uuid` — let the audit log point at
  the row being translated.
- `:source` — string identifier for the calling module (e.g.
  `"Publishing.TranslatePostWorker"`); included in the
  `PhoenixKitAI` request log so usage reports break down by caller.

---

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