Skip to content

0.23.0 - 2026-09-15

Choose a tag to compare

@ddon ddon released this 15 Sep 15:34
· 61 commits to main since this release

Added

  • {{SourceFields}} prompt variable. Translation.build_variables/3
    (used by the translation pipeline's do_translate/6) now also binds a
    computed {{SourceFields}} block — one ---MARKER--- section per field
    actually passed — alongside the existing per-field variables. A prompt
    template can be written against this single variable instead of one
    hardcoded slot per field, so it never has an unbound {{fieldname}}
    placeholder for a field the caller didn't supply. Additive: an existing
    prompt built on named slots ({{title}}, {{Title}}, …) keeps
    rendering exactly as before.
  • Prompt.unbound_placeholders/1 — returns the literal {{...}}
    sequences left in already-rendered text. PhoenixKitAI.ask_with_prompt/4
    and complete_with_system_prompt/5 now call it after rendering: a
    leftover placeholder logs a Logger.warning and is recorded as
    unbound_placeholders in the request's metadata. Non-fatal — a prompt
    may legitimately contain a literal {{...}} unrelated to this engine —
    and metadata-neutral when nothing is unbound (no [] added to every
    request row).
  • put_translation/4's opts now carries :source_fields.
    TranslateWorker threads the %{field_name => text} it read via
    source_fields/2 — captured before the AI call, not the resource's
    state at persist time, which can be seconds to tens of seconds later —
    through to the Translatable adapter. An adapter that doesn't need it
    (most don't) ignores the key; one that wants to fingerprint what was
    actually translated (e.g. to detect a source that changed again while
    the call was in flight) no longer has to reconstruct it from a
    possibly-stale resource.
  • TranslateWorker.retryable?/1 now also treats
    {:parse_error, {:missing_fields, _}} as retryable, within the existing
    max_attempts: 3. Insurance against ordinary model non-determinism on
    an otherwise well-formed prompt, not a substitute for one.

Fixed

  • A failed AI request no longer drops its :attribution.
    log_failed_request/7 merges attribution into the request's metadata
    the same way the success path already did, so a timed-out or errored
    call stays traceable to the resource that triggered it instead of only
    the calls that happened to succeed.
  • A provider error delivered inside a 200 response body is now
    classified instead of discarded.
    Translation.handle_ai_response/2
    normalizes %{"error" => %{"code" => code}} to the same
    {:api_error, code} shape the transport-error path already produces,
    so the existing retry classification picks up a transient failure
    (observed: a 504 reported in the body by OpenRouter) instead of
    discarding it on the first attempt.
  • PK_AI_SKIP_DB=1 is reachable through mix test again. The test
    alias ran ecto.create --quiet unconditionally, so on a machine with no
    reachable Postgres ecto.create itself exited nonzero before
    test/test_helper.exs ever loaded — the soft-skip documented there
    could never actually trigger via mix test. The alias is now a
    function that checks the same env var before deciding whether to
    provision the database at all.

Fixed (post-merge review of #27)

  • A provider error in a 200 body is classified where the request is
    logged, not only in Translation.
    Completion.chat_completion/3 maps
    {"error": {"code": N}} through the same handle_error_status/2 a non-2xx
    status uses, so the usage row is a failure (not a zero-token success), a
    429 comes back :rate_limited (snoozed by TranslateWorker, not retried
    against its attempts), 401/402 keep their own atoms, and the request cache
    never stores the error for a retry to replay.
  • Translation retries reach the model again with the request cache on.
    Translation.translate_fields/6 now passes :cache through, and
    TranslateWorker sends cache: :refresh from attempt 2 when the host's
    cache is enabled. Before, a missing_fields answer was stored on attempt 1
    and every retry replayed it.
  • The unbound-placeholder guard scans the template, not the rendered
    text.
    New Prompt.unbound_placeholders/2 (template, variables). A {{...}}
    inside source content is no longer reported as an unbound slot, logged, or
    written to request metadata outside the capture_request_content gate. A
    cache hit's row now carries unbound_placeholders like the fresh row, and
    the key is kept out of the cache key.
  • mix test no longer aborts when the database role cannot connect to
    postgres.
    The alias's ecto.create is best-effort; test_helper.exs
    still aborts the run on a test database it cannot use.

No action is required from an existing consumer, phoenix_kit_publishing
included — every change above is additive: a new template variable, a
new metadata field, a new opts key adapters may ignore, and wider
retry/attribution coverage on paths that previously under-reported. A
Translatable adapter that wants the staleness-tracking use case this
was built for reads opts[:source_fields] in its own put_translation/4;
everything else keeps working unchanged.