Skip to content

Generalize AI translation: shared pipeline, glue, hint + quality#582

Merged
ddon merged 7 commits into
BeamLabEU:mainfrom
mdon:main
Jun 4, 2026
Merged

Generalize AI translation: shared pipeline, glue, hint + quality#582
ddon merged 7 commits into
BeamLabEU:mainfrom
mdon:main

Conversation

@mdon

@mdon mdon commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Generalizes AI-driven translation into core so feature modules plug in via a small adapter instead of re-implementing the whole stack (projects + publishing each carried ~1000 lines of it). Adds the generic per-language Oban worker, the orchestration context, the shared modal + LiveView glue with a "taking a while" stall hint, retry-on-timeout, and a Phase 1/2 quality pass.

Generic pipeline

  • Modules.AI.Translatable behaviour (fetch/2, source_fields/2, put_translation/4, optional pubsub_topics/1) + ai_translatables/0 module callback + ModuleRegistry discovery.
  • Modules.AI.Translations orchestration — availability, endpoint/prompt defaults, idempotent shared prompt, enqueue/1 + enqueue_all_missing/2, PubSub, missing_languages/3.
  • Modules.AI.TranslateWorker — generic one-job-per-language Oban worker: retry classification (transient incl. :timeout retries, 5xx retries, deterministic discards), {:snooze, 30} on rate-limit, ai.translation_added audit.

Shared UI

  • PhoenixKitWeb.Components.AITranslate — button / modal / progress / hint (the "taking a while, runs in the background" reassurance on a stalled bar).
  • PhoenixKitWeb.Components.AITranslate.{FormGlue,FormBinding} — the shared LiveView state machine (modal events, scope dispatch, live progress, the stall timer with a per-arm token guard) behind a 3-callback binding, so consumers wire an adapter + a tiny binding and delegate.

Hardening

  • broadcast/3 keeps translated content off the global topic: the full payload (with :fields) goes only to the per-resource topic the form consumes; the global + adapter topics get a content-free summary.

Tests & docs

Verification

  • mix format --check-formatted / mix credo --strict / mix dialyzer clean; 14 new tests green.
  • Browser-verified end to end via the integration app: catalogue, projects, and staff all translate, fill the form live, and persist.

Note

This is the foundational PR — the catalogue / projects / publishing PRs consume this generalization and need a release containing it before they build standalone.

mdon added 7 commits June 2, 2026 23:13
… context)

Hoists the orchestration every consumer re-implemented on top of the
Modules.AI.Translation engine into core, so a feature module needs only a
small adapter + a UI call:

- Modules.AI.Translatable — behaviour a module implements
  (fetch/source_fields/put_translation/optional pubsub_topics) to make a
  resource AI-translatable.
- ModuleRegistry.all_ai_translatables/0 + find_ai_translatable/1 —
  discovery via the new optional ai_translatables/0 module callback.
- Modules.AI.TranslateWorker — generic Oban worker: resolve adapter by
  resource_type, translate one target language, persist via the adapter,
  broadcast {:ai_translation, _, _}, audit, retry only transient AI errors
  (and stay quiet on non-final retries).
- Modules.AI.Translations — availability, endpoint/prompt resolution, an
  idempotent shared default prompt, enqueue/1 + enqueue_all_missing/2,
  PubSub topics, missing_languages/3. De-dup is app-level (querying the
  four always-present Oban states) rather than Oban unique:, whose query
  references the :suspended state absent from oban_job_state on some hosts
  (22P02) — same trade-off as the catalogue PDF worker.

Additive: existing consumers (projects, publishing) are untouched.
See dev_docs/plans/2026-06-02-ai-translation-generalization.md.
PhoenixKitWeb.Components.AITranslate — a reusable button + modal + progress
bar driven by a single ai_translate config map, on top of the
Modules.AI.Translations pipeline. The modal carries endpoint + prompt
selectors, a Generate-Default-Prompt button, a scope picker
(missing-only / all-overwrite / current-tab), in-flight status, and one
scope-driven Translate action. Generic (core Gettext + Core.Icon) so any
multilang form LV can drop it in; the host owns the state + event handlers.

Also adds Translations.default_prompt_exists?/0 to gate the modal's
generate-prompt affordance.
- TranslateWorker: classify {:ai_error, :timeout} as retryable (it's the
  transient request-timeout the AI HTTP client surfaces) so a one-off
  slow/hung provider call re-runs instead of being discarded.
- AITranslate component: add <.ai_translate_hint> — a reassurance line
  (short text + info-icon tooltip) shown when a translation has been in
  flight past the host's threshold, telling the user it runs in the
  background and they can leave. Driven by a new :slow config key.
Extracts the AI-translate modal's stateful LiveView glue — mount state +
per-resource PubSub subscription, modal open/close, endpoint/prompt/scope
selection, generate-default-prompt, scope-driven dispatch, live progress,
the "taking a while" stall hint (per-arm timer with a token guard), and
folding `{:ai_translation, _, _}` events back into the form — into a single
shared module, the stateful counterpart to the render-only
`PhoenixKitWeb.Components.AITranslate` components.

The only storage-specific behaviour is delegated to a tiny per-consumer
`FormBinding` (3 callbacks): which langs already have a translation, how to
merge a completed translation into the live changeset, and the actor uuid.
The LV still supplies its own `(socket, changeset) -> socket` assign helper,
so each form's exact assign/sync behaviour is preserved.

Catalogue and projects both adopt this in follow-up commits, replacing two
near-identical ~570/200-line copies. Stall threshold is overridable via
`config :phoenix_kit, :ai_translation_stall_ms` (default 5s).
Quality-sweep Phase 1 catch-up for core's recent Max-authored PRs (the
author-filter excludes BeamLabEU#571/BeamLabEU#574/BeamLabEU#575 — alexdont/timujinne). No bugs:
- BeamLabEU#572 (V125 project statuses): two intentional NITPICKs (test async:false
  matches sibling V-test precedent; schema_for/1 symmetry) — skipped.
- BeamLabEU#573 (catalogue_pdf Oban queue): five duplicate ensure_*_queue/2 installer
  fns punted to a dedicated installer-dedup refactor (PR deferred it).
Adds a FOLLOW_UP.md for each.
Phase 2 sweep finding: `Translations.broadcast/3` fanned the full payload —
including the translated `fields` — to the global `phoenix_kit:ai_translation`
topic as well as the per-resource topic. Nothing subscribes globally today
(the form glue subscribes per-resource), so it's not an active leak, but it
violates the payload-minimal rule and would expose resource text to any future
global monitor/dashboard.

Now the FULL payload (with `fields`) goes ONLY to the per-resource topic —
the one the form LV consumes to patch its changeset — while the global topic
and any adapter-supplied topics get a content-free summary (everything but
`:fields`). Browser-verified a single-language translation still fills the
form field (per-resource payload intact).

Rest of the core AI surface (FormGlue stall-timer/token guard, progress
accounting, Oban dedup/retry, scope dispatch, foreign-job branch, plugin
guarding) triaged clean; format + credo --strict clean.
Phase 2 coverage follow-up for the shared AI-translation engine (no live
PhoenixKitAI plugin needed):

- translations_test: missing_languages/3 set logic; available?/list_endpoints
  /list_prompts degrade to safe defaults when the plugin is absent; and the
  broadcast/3 payload-scoping fix — pins that the FULL payload (with :fields)
  reaches only the per-resource topic while the global + adapter topics get a
  content-free summary.
- translate_worker_test: retryable?/1 classification (transient retries, 5xx
  retries, 4xx/deterministic don't) and perform/1 setup-failure paths (missing
  arg / unknown adapter) discard cleanly and broadcast a normalised
  :translation_failed before any AI call.

14 tests, all green. The success-path round-trip (real ask_with_prompt +
persist) stays covered by each consumer's browser/integration verification.
@ddon ddon merged commit cda9a36 into BeamLabEU:main Jun 4, 2026
ddon pushed a commit that referenced this pull request Jun 4, 2026
…tion generalization)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ddon pushed a commit that referenced this pull request Jun 4, 2026
- media_canvas_viewer: sanitize the client-supplied 'etcher:colors-changed'
  palette before persisting into user custom_fields — keep only short
  color-shaped strings, dedupe, cap at 24, and ignore the event when nothing
  valid survives (no garbage stored, saved palette not wiped). Closes the
  IMPROVEMENT-MEDIUM from the #581 review.
- translations_test: alias PhoenixKit.PubSub.Manager so credo --strict passes
  (the merged #582 left a nested-module-alias suggestion that exits non-zero).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ddon pushed a commit that referenced this pull request Jun 4, 2026
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ddon pushed a commit that referenced this pull request Jun 4, 2026
Covers the generic AI-translation pipeline (PR #582) and the Etcher palette
sanitization fix. 1.7.129 was bumped in-repo but never published; 1.7.130 is
the cumulative release since 1.7.128.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ddon added a commit to BeamLabEU/phoenix_kit_catalogue that referenced this pull request Jun 4, 2026
Bump version 0.5.0 -> 0.6.0 and add the CHANGELOG entry for PR #32 (AI
translation via core's shared pipeline, catalogue-detail status-filter and
reorder UX) plus the post-merge quality fixes.

Per the loose-constraint convention the mix.exs phoenix_kit requirement is
left untouched; the CHANGELOG notes that the AI-translation pipeline
(BeamLabEU/phoenix_kit#582) requires core 1.7.130+.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ddon pushed a commit that referenced this pull request Jun 5, 2026
retryable?/1 now retries {:ai_error, {:api_error, 429}} as defense-in-depth:
the built-in OpenRouter client maps 429 -> :rate_limited (snoozed), but a
custom/future provider may surface a bare {:api_error, 429}, which is the
canonical retry-after and should retry rather than discard on first attempt.
Also corrects the :timeout clause comment (Completion remaps transport
timeouts to :request_timeout before they reach the worker) and updates the
test (429 now asserts retryable; added a 404 refute).

Updates the PR #582 review doc: 429 item RESOLVED+FIXED with the verified
PhoenixKitAI contract, a dated follow-up section splitting fixed vs
developer-owned items, and a PR #583 (V129) verdict (no issues).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to 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