# `PhoenixKitAI.Translation`
[🔗](https://github.com/BeamLabEU/phoenix_kit_ai/blob/v0.4.0/lib/phoenix_kit_ai/translation.ex#L1)

Generic AI translation helper. Translates a `%{field_name => text}`
map from `source_lang` to `target_lang` using PhoenixKitAI's endpoint
and prompt system, 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, wrapped so provider
  errors and unexpected response shapes normalize to tagged errors.
- 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*
