Skip to content

Add PhoenixKit.Modules.AI.Translation + language-switcher ai_translate attr - #557

Merged
ddon merged 2 commits into
BeamLabEU:devfrom
mdon:dev
May 21, 2026
Merged

Add PhoenixKit.Modules.AI.Translation + language-switcher ai_translate attr#557
ddon merged 2 commits into
BeamLabEU:devfrom
mdon:dev

Conversation

@mdon

@mdonmdon commented May 20, 2026

Copy link
Copy Markdown
Contributor

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:

  • phoenix_kit_projectsTranslateProjectWorker using this helper, wires the switcher's ai_translate attr into project/template/task forms.
  • phoenix_kit_publishing (cleanup, separate timeline) — collapse the 939-line TranslatePostWorker by delegating its AI-call + structured-parse halves to this helper.

What's new

PhoenixKit.Modules.AI (lib/modules/ai/ai.ex)

Utility namespace. available?/0 returns true when the optional PhoenixKitAI plugin is loaded 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. 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.
  • 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.

ai_translate attr on language_switcher_dropdown

New 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:

  • Per-missing-language sparkle 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 (N)" footer button when ≥2 actionable languages exist (missing minus in_flight). Same event with phx-value-lang=\"*\" sentinel; host handlers branch on the value.

Component is pure event-emit. Zero PhoenixKitAI references. 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:

  • Whitespace-only UUIDs (\" \") now correctly trigger :no_endpoint / :missing_prompt.
  • Validation order rearranged: input validation runs before plugin-availability so callers can unit-test the input contract without a configured plugin.
  • Duplicate-marker rejection: \"foo-bar\" and \"foo_bar\" both normalise to FOO_BAR — caught at entry with {:error, {:parse_error, {:duplicate_markers, [...]}}} rather than silently overwriting one field with another's translation.
  • Partial-response rejection: missing field markers now return {:error, {:parse_error, {:missing_fields, [...]}}} instead of half-translated {:ok, _} results.
  • Full plugin-call normalisation: try/rescue + catch :exit + catch :throw ensures every failure mode of PhoenixKitAI.ask_with_prompt/4 comes out as {:error, {:ai_error, _}} per the documented contract.

Switcher component:

  • Event-name gating: enabled: true with missing/empty/whitespace event hides the affordance entirely (no dead clickable UI).
  • Bulk count: subtracts in_flight from missing so a single bulk click doesn't redundantly re-enqueue running jobs.

Docs:

  • available?/0 doc explicitly notes it's a module-loadability check, not a runtime-readiness check; hosts needing stricter checks query PhoenixKitAI.list_endpoints/0 directly.

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-unused clean.

Test plan

  • CI green
  • Manual: render the switcher with ai_translate: %{enabled: true, event: \"...\", missing: [\"de\"]} in any host; confirm sparkle button appears next to the missing language
  • Manual: configure two missing languages; confirm the bulk CTA renders
  • Manual: move one of the two to in_flight; bulk CTA disappears (only 1 actionable)

mdon added 2 commits May 21, 2026 00:55
…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
ddon merged commit bc10206 into BeamLabEU:devMay 21, 2026
ddon pushed a commit that referenced this pull request May 21, 2026
Post-merge reviews of the MediaGallery max_count / table_default
controlled-view-mode work (#556) and the AI translation helper +
language-switcher ai_translate affordance (#557).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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.
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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@mdon@ddon