Uh oh!
There was an error while loading. Please reload this page.
Add PhoenixKit.Modules.AI.Translation + language-switcher ai_translate attr - #557
Merged
Conversation
…e attr
First of three PRs for AI-driven translation across PhoenixKit modules.
This core PR adds the shared orchestration layer and the language-switcher
UI affordance. Sibling PRs follow:
- phoenix_kit_projects #TBD — TranslateProjectWorker using this helper,
wires the switcher's ai_translate attr into the project/template/task
forms.
- phoenix_kit_publishing #TBD (cleanup, separate) — collapse the 939-line
TranslatePostWorker by delegating its AI-call + structured-parse halves
to this helper; publishing keeps its own per-language storage,
broadcasts, ListingCache invalidation, and publishing.translation.added
activity action.
## What's new
### `PhoenixKit.Modules.AI` (`lib/modules/ai/ai.ex`)
Tiny utility namespace. `available?/0` returns true when the optional
`PhoenixKitAI` plugin is installed and exports `ask_with_prompt/4`.
Host call sites gate AI-driven UI on this so apps without the plugin
get the regular non-AI behavior automatically.
### `PhoenixKit.Modules.AI.Translation` (`lib/modules/ai/translation.ex`)
The shared orchestration:
- `translate_fields/6` — `%{field_name => text}` in, same shape out
or `{:error, :ai_not_installed | :no_endpoint | :missing_prompt |
{:ai_error, _} | {:parse_error, _}}`.
- `parse_response/2` — generic `---FIELD_NAME---` parser. Public for
testing; arbitrary field names + case-insensitive marker matching.
- Activity log: writes one `core.ai_translation.requested` row per
dispatched request — unified audit trail of AI token spend across
consumers. Each consumer still logs its own per-resource action.
- Compile-time `@compile {:no_warn_undefined, [{PhoenixKitAI,
:ask_with_prompt, 4}]}` — narrow MFA target so typos in any other
PhoenixKitAI call still warn.
- Runtime guard: every entry checks `AI.available?/0` first; absent
plugin returns `{:error, :ai_not_installed}` without raising.
### `ai_translate` attr on `language_switcher_dropdown`
New optional map attr (`default: nil` → today's behavior unchanged):
%{
enabled: true,
event: "translate_lang", # phx-click target on the host LV
missing: ["es", "de"], # base codes lacking a translation
in_flight: ["es"] # show spinner, disable click
}
Rendering:
- **Per-missing-language sparkle button** — `<button phx-click=...
phx-value-lang={base}>✨</button>` next to each `<a>` link for
languages in `:missing`. Click fires the host's event; the host
enqueues its own translation worker.
- **In-flight indicator** — sparkle swapped for `loading-spinner`,
click disabled.
- **Bulk CTA** — "Translate all missing" footer button when ≥2
languages are missing. Same event with `phx-value-lang="*"` sentinel;
host handlers branch on the value.
Component is pure event-emit. Zero `PhoenixKitAI` references. Buttons
and inline variants don't get the AI affordance in v1 (the dropdown
is the canonical interactive surface; the others are compact / mobile
shapes where per-language actions would crowd the layout).
## Tests
- `test/phoenix_kit/modules/ai/translation_test.exs` — 11 tests:
argument validation, structured-response parser (single field,
multi-field, missing markers, whitespace normalisation, punctuation
handling), marker normalisation, no-markers error.
- `test/phoenix_kit_web/components/core/language_switcher_test.exs` —
7 new tests covering nil/disabled/single-missing/multiple-missing/
in_flight/string-keyed/completed states.
`mix test`: 1358 tests, 0 failures.
`mix compile --warnings-as-errors`, `mix credo --strict`,
`mix format --check-formatted`, `mix deps.unlock --check-unused` clean.
## Phase 2 triage applied
Three audit dimensions (security + cleanliness + host-integration)
ran on the diff. Two real fixes applied:
- Narrowed `@compile {:no_warn_undefined}` from whole-module to the
specific `{PhoenixKitAI, :ask_with_prompt, 4}` MFA so typos in
other (theoretical) PhoenixKitAI calls still surface as warnings.
- Removed the broad try/rescue around `Activity.log/1` — match the
pattern used in core's other log sites; a DB-level failure here
is a real bug, not noise to swallow. Caller (host's Oban worker)
owns retry policy.
Plus a docstring improvement on the `ai_translate` attr explaining the
`phx-value-lang="*"` bulk-dispatch sentinel.
NOT-A-FIX items (recorded for traceability): regex-backtracking risk
on huge AI responses (acceptable for documented use), forward-compat
`:completed` key (intentional), AI UI on dropdown only (intentional
v1 scope).Two rounds of Codex review on the initial commit surfaced eight real
issues. All applied with tests.
## Round 1 (5 fixes + 3 concerns → 3 docstring softening)
### Translation helper
- **Whitespace-only UUID validation** — `endpoint_uuid == ""` missed
`" "`. Now `String.trim/1` + reject if trimmed-empty. Same for
`prompt_uuid`. Tests cover both via dedicated assertions.
- **Validation order rearranged** — endpoint/prompt/marker-uniqueness
validation now runs **before** the plugin-availability check. Lets
callers unit-test the input contract without a configured plugin,
and matches fail-fast semantics (bad args are a bug regardless of
system state).
- **Duplicate-marker rejection** — `"foo-bar"` and `"foo_bar"` both
normalise to the marker `FOO_BAR`. Pre-fix the parser silently
overwrote one field with another's translation. Now
`translate_fields/6` returns
`{:error, {:parse_error, {:duplicate_markers, [...]}}}` upfront.
- **Partial-response rejection** — `parse_response/2` previously
returned `{:ok, %{title: ...}}` when the model forgot `---BODY---`.
Callers persisting that result wrote half-translated rows. Now
returns `{:error, {:parse_error, {:missing_fields, [...]}}}` unless
every requested field is present.
- **Plugin-call normalisation** — `PhoenixKitAI.ask_with_prompt/4` is
now wrapped so non-binary `{:ok, _}` payloads and any raised
exception come out as `{:error, {:ai_error, _}}` — matching the
documented contract.
### Switcher component
- **Event-name gating** — `enabled: true` with missing/blank `event`
rendered a clickable button with no host handler to dispatch to.
Now the sparkle and bulk CTA both check `event_name/1` (non-empty,
non-whitespace) and hide entirely otherwise.
- **Bulk count subtracts in_flight** — `missing -- in_flight` so a
single bulk click doesn't redundantly re-enqueue jobs the host
already has running. Bulk CTA threshold (≥2) now applies to the
actionable count, not the raw missing count.
- **`available?/0` docstring softened** — was "installed and ready";
now "module-loadability check; does NOT verify a configured
endpoint or working credentials". Hosts needing stricter checks
query `PhoenixKitAI.list_endpoints/0` directly.
## Round 2 (1 fix + 1 concern + 1 nit)
- **`exit`/`throw` normalisation** — Round 1's `try/rescue` caught
raised exceptions but not GenServer.call exits (timeout default
path) or throws. Added `catch :exit, _` and `catch :throw, _`
clauses so every failure mode of `PhoenixKitAI.ask_with_prompt/4`
comes out as `{:error, {:ai_error, _}}`.
- **Whitespace event-name** — `event: " "` slipped through Round 1's
`e != ""` check. Now uses `String.trim/1`. Test now covers all four
misconfiguration shapes (missing key, empty string, whitespace, nil).
- **`available?/0` doc wording nit** — "compile-time presence check"
was inaccurate; `Code.ensure_loaded?` is a runtime loadability
check. Reworded.
## Verification
`mix test`: 1366 tests, 0 failures (was 1358 + 8 new across the
two test files).
`mix compile --warnings-as-errors`, `mix credo --strict`,
`mix format --check-formatted`, `mix deps.unlock --check-unused`
all clean.ddon pushed a commit
that referenced
this pull request
May 21, 2026
ddon pushed a commit
that referenced
this pull request
May 21, 2026
Follow-ups from the post-merge review of #556 and #557: - media_gallery: replace `length(selected) >= 1` emptiness check with `selected != []` — clears the credo --strict warning that #556 left on dev (credo exited 16 / CI-blocking). - table_default: card-view container sets its `display` utility per-branch instead of carrying a permanent `grid` plus a layered `hidden`, so controlled table mode no longer relies on Tailwind's hidden-beats-grid source order. - AI.Translation: add the `i` flag to the marker regex so parsing is actually case-insensitive, matching the documented contract. - language_switcher: drop the documented-but-unimplemented `completed` key from the ai_translate shape, and correct the bulk-handler doc to enqueue actionable (missing minus in_flight) languages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ddon pushed a commit
that referenced
this pull request
May 21, 2026
The headline fix: #557's PhoenixKitAI.ask_with_prompt/4 call only had a `@compile {:no_warn_undefined, ...}` (compiler-only) — dialyzer still flagged it as unknown_function, leaving `mix quality.ci` / precommit red on dev since the merge. Added the matching `.dialyzer_ignore.exs` entry, mirroring how publishing/integration guard their optional plugins. Code-review follow-ups (#556/#557): - AI.Translation: drop the dead `variable_key/1` fallback clause and the reduce that just rebuilt `fields`; field keys are strings per the contract, so they Map.merge directly with the language slots. - language_switcher: `event_name/1` now delegates to PhoenixKit.Utils.Values.presence/1 instead of hand-rolling trim-or-nil. mix precommit (compile, deps.unlock, format, credo --strict, dialyzer) passes: REAL_PRECOMMIT_EXIT=0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ddon pushed a commit
that referenced
this pull request
May 21, 2026
Add author-addressed 'Follow-up changes applied' sections so @timujinne and @mdon see what landed on dev on top of their PRs: - #556: credo --strict regression (length/1 → != []) that was red on dev, plus the table_default card-view display cleanup. - #557: the dialyzer ignore fix (was red on dev), variable_key removal, marker-regex i flag, and the language_switcher doc/contract fixes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ddon pushed a commit
that referenced
this pull request
May 21, 2026
Covers PR #556 (MediaGallery max_count; TableDefault card_media slot, controlled view_mode, table_default_header class override + bg-base-300 default) and PR #557 (PhoenixKit.Modules.AI + AI.Translation; language switcher ai_translate affordance). Dependency bumps omitted by request. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4 tasks
ddon pushed a commit
that referenced
this pull request
May 21, 2026
PR #557 follow-up — boss left this NITPICK as my call. An empty `fields` map passed to `translate_fields/6` passed `is_map/1` and `validate_unique_markers([])`, then rendered a prompt with no field variables, called `PhoenixKitAI.ask_with_prompt/4` (real token spend), and only failed downstream in `parse_response/2` with `{:parse_error, :no_markers}`. Reject up front so a caller bug doesn't burn a request. Returns `{:error, {:parse_error, :no_markers}}` — same sentinel the downstream parser returns, so callers don't have to branch on a new error class.
5 tasks
ddon pushed a commit
that referenced
this pull request
May 25, 2026
BUG-HIGH (dialyzer failure on ask_with_prompt/4) fixed pre-existing via .dialyzer_ignore.exs entry. Two NITPICKs (doc example re-enqueue phrasing, regex /i flag) fixed in post-merge commits. Two items N/A (completed-key doc trimmed; empty-fields-guard was scope of PR #558).
ddon pushed a commit
that referenced
this pull request
May 25, 2026
Release rollup since 1.7.120: - PR #568: native <dialog> modal (PkDialog), core list-UI toolkit (BulkSelect, Sortable, ReorderModal, load_more), race-free sort_selector - PR #568 post-merge review fixes (untranslated reorder label, named group/row) - PR #569: PhoenixKit.boot/1 hook, locale-aware Activity dates, broad i18n sweep - PR #550/#552/#554/#557/#558/#559 follow-ups Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
First of three PRs for AI-driven translation across PhoenixKit modules. This core PR adds the shared orchestration layer + the language-switcher UI affordance. Sibling PRs follow:
TranslateProjectWorkerusing this helper, wires the switcher'sai_translateattr into project/template/task forms.TranslatePostWorkerby delegating its AI-call + structured-parse halves to this helper.What's new
PhoenixKit.Modules.AI(lib/modules/ai/ai.ex)Utility namespace.
available?/0returns true when the optionalPhoenixKitAIplugin is loaded and exportsask_with_prompt/4. Host call sites gate AI-driven UI on this so apps without the plugin get the regular non-AI behavior automatically. Explicitly documented as a module-loadability check — not a runtime-readiness check.PhoenixKit.Modules.AI.Translation(lib/modules/ai/translation.ex)The shared orchestration:
translate_fields/6—%{field_name => text}in, same shape out, or{:error, atom_or_tuple}covering every failure mode.parse_response/2— generic---FIELD_NAME---parser. Public for testing; arbitrary field names, case-insensitive marker matching.core.ai_translation.requestedrow per dispatched request — unified audit trail of AI token spend across consumers. Each consumer still logs its own per-resource action.@compile {:no_warn_undefined, [{PhoenixKitAI, :ask_with_prompt, 4}]}— narrow MFA target so typos in any other PhoenixKitAI call still warn.ai_translateattr onlanguage_switcher_dropdownNew optional map attr (
default: nil→ today's behavior unchanged):```elixir
%{
enabled: true,
event: "translate_lang", # phx-click target on the host LV
missing: ["es", "de"], # base codes lacking a translation
in_flight: ["es"] # show spinner, disable click
}
```
Rendering:
<a>link for languages in:missing. Click fires the host's event; the host enqueues its own translation worker.loading-spinner, click disabled.phx-value-lang=\"*\"sentinel; host handlers branch on the value.Component is pure event-emit. Zero
PhoenixKitAIreferences. Buttons + inline variants don't get the AI affordance in v1.Hardening from review (two Codex passes)
The initial commit went through two rounds of external review that surfaced 8 real issues, all fixed before this PR was opened:
Translation helper:
\" \") now correctly trigger:no_endpoint/:missing_prompt.\"foo-bar\"and\"foo_bar\"both normalise toFOO_BAR— caught at entry with{:error, {:parse_error, {:duplicate_markers, [...]}}}rather than silently overwriting one field with another's translation.{:error, {:parse_error, {:missing_fields, [...]}}}instead of half-translated{:ok, _}results.try/rescue + catch :exit + catch :throwensures every failure mode ofPhoenixKitAI.ask_with_prompt/4comes out as{:error, {:ai_error, _}}per the documented contract.Switcher component:
enabled: truewith missing/empty/whitespaceeventhides the affordance entirely (no dead clickable UI).in_flightfrommissingso a single bulk click doesn't redundantly re-enqueue running jobs.Docs:
available?/0doc explicitly notes it's a module-loadability check, not a runtime-readiness check; hosts needing stricter checks queryPhoenixKitAI.list_endpoints/0directly.Tests
test/phoenix_kit/modules/ai/translation_test.exs— 17 tests: argument validation (5), duplicate-marker rejection, structured-response parser (9), missing-fields error (2).test/phoenix_kit_web/components/core/language_switcher_test.exs— 9 new tests: nil/disabled/single-missing/multiple-missing/in_flight/string-keyed/event-gating/bulk-count-subtracts-in_flight.mix test: 1366 tests, 0 failures.mix compile --warnings-as-errors,mix credo --strict,mix format --check-formatted,mix deps.unlock --check-unusedclean.Test plan
ai_translate: %{enabled: true, event: \"...\", missing: [\"de\"]}in any host; confirm sparkle button appears next to the missing languagein_flight; bulk CTA disappears (only 1 actionable)