feat(translation): re-translate on model change — artist-scoped + global re-run (#146)
retranslate_posts resets the 5 translation columns to NULL for a scoped set of posts (all, or WHERE artist_id IN ids) then reuses the untranslated sweep to re-run them, chasing the tail until drained (run-until-done). Interpreter cache keys on engine_version so a changed model re-translates, an unchanged one is cache-fast. Reset only happens when the service is configured+healthy so translations are never wiped when they can't be rebuilt. New POST /settings/translation/retranslate (artist_id | all=true). UI: per-artist 'Re-translate posts' on the Artist Management tab + 'Re-translate all' in the Settings Translation card, both with confirm dialogs. No migration (reuses m143 columns). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -351,3 +351,39 @@ async def translation_run():
|
||||
|
||||
r = translate_posts.delay()
|
||||
return jsonify({"celery_task_id": r.id}), 202
|
||||
|
||||
|
||||
@settings_bp.route("/settings/translation/retranslate", methods=["POST"])
|
||||
async def translation_retranslate():
|
||||
"""Re-translate stored translations after a model change (m146). Body:
|
||||
``{"artist_id": <int>}`` aims at one artist; ``{"all": true}`` re-runs every
|
||||
artist. ``all`` must be explicit so an empty/typo body can't wipe everything.
|
||||
Clears the scoped translations and enqueues the run-until-done retranslate
|
||||
sweep (the Interpreter cache re-translates on a changed model, is cache-fast
|
||||
otherwise). Same enabled + base-URL guard as 'Translate now'."""
|
||||
body = await request.get_json(silent=True)
|
||||
body = body if isinstance(body, dict) else {}
|
||||
artist_id = body.get("artist_id")
|
||||
do_all = bool(body.get("all"))
|
||||
if artist_id is None and not do_all:
|
||||
return jsonify(
|
||||
{"error": "provide artist_id, or all=true to re-translate everything"}
|
||||
), 400
|
||||
if artist_id is not None:
|
||||
try:
|
||||
artist_id = int(artist_id)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "artist_id must be an integer"}), 400
|
||||
|
||||
async with get_session() as session:
|
||||
cfg = await ImportSettings.load(session)
|
||||
if not cfg.translation_enabled or not (cfg.interpreter_base_url or "").strip():
|
||||
return jsonify(
|
||||
{"error": "translation is disabled or no base URL is set"}
|
||||
), 400
|
||||
from ..tasks.translation import retranslate_posts
|
||||
|
||||
# artist_id wins when both are sent; otherwise all=true → None (every artist).
|
||||
artist_ids = [artist_id] if artist_id is not None else None
|
||||
r = retranslate_posts.delay(artist_ids=artist_ids)
|
||||
return jsonify({"celery_task_id": r.id}), 202
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
"""Post-text translation Celery task (milestone 143).
|
||||
"""Post-text translation Celery tasks (milestone 143 + re-translate m146).
|
||||
|
||||
Backfills non-English post title/description to the target language via the
|
||||
self-hosted Interpreter LAN service, storing the result + engine_version so
|
||||
viewing is instant and re-runs are cache-fast. Sync (Celery workers are sync),
|
||||
same pattern as the other backfill sweeps. No-op unless translation is enabled,
|
||||
a base URL is set, and the service is healthy.
|
||||
|
||||
Two entry points:
|
||||
- ``translate_posts`` — the daily untranslated sweep (translated_source_lang IS
|
||||
NULL only); also the Settings "Translate now" button.
|
||||
- ``retranslate_posts`` — after a model change, resets the stored translation
|
||||
columns for a scoped set of posts (all, or a given set of artists) so the
|
||||
untranslated selection re-runs them, then chases the tail until drained
|
||||
(run-until-done). The Interpreter cache keys on engine_version: a changed
|
||||
model genuinely re-translates, an unchanged one is ~1ms cache-fast.
|
||||
"""
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy import func, or_, select, update
|
||||
|
||||
from ..celery_app import celery
|
||||
from ..models import ImportSettings, Post
|
||||
@@ -24,6 +33,9 @@ log = logging.getLogger(__name__)
|
||||
# limit; the rest resumes next cycle (idempotent — only untranslated posts are
|
||||
# picked, so an interrupted run just re-runs = rule-89 recovery).
|
||||
_MAX_POSTS_PER_RUN = 300
|
||||
# Short gap between run-until-done chunks so a big re-translate finishes on its
|
||||
# own without monopolising the maintenance_long worker in one uninterrupted run.
|
||||
_RETRANSLATE_COUNTDOWN = 5
|
||||
|
||||
|
||||
@celery.task(
|
||||
@@ -39,38 +51,156 @@ def translate_posts() -> str:
|
||||
committed. No-op unless configured + healthy. Returns a summary string."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
cfg = ImportSettings.load_sync(session)
|
||||
if not cfg.translation_enabled or not cfg.interpreter_base_url.strip():
|
||||
return "disabled"
|
||||
base_url = cfg.interpreter_base_url.strip()
|
||||
target = (cfg.translation_target_lang or "en").strip() or "en"
|
||||
if not ic.health(base_url):
|
||||
return "interpreter unavailable"
|
||||
ready = _translation_config(session)
|
||||
if isinstance(ready, str):
|
||||
return ready
|
||||
base_url, target = ready
|
||||
posts = _select_untranslated(session, None, _MAX_POSTS_PER_RUN)
|
||||
status, translated = _translate_batch(session, posts, base_url, target)
|
||||
return _summary(status, translated, len(posts))
|
||||
|
||||
posts = session.execute(
|
||||
select(Post)
|
||||
.where(Post.translated_source_lang.is_(None))
|
||||
.where(or_(
|
||||
Post.post_title.is_not(None), Post.description.is_not(None),
|
||||
))
|
||||
.limit(_MAX_POSTS_PER_RUN)
|
||||
).scalars().all()
|
||||
|
||||
translated = 0
|
||||
@celery.task(
|
||||
name="backend.app.tasks.translation.retranslate_posts",
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
)
|
||||
def retranslate_posts(artist_ids=None, _reset_done=False) -> str:
|
||||
"""Re-translate posts after a model change. On the first call resets the
|
||||
stored translation columns to NULL for the scoped posts — all artists when
|
||||
``artist_ids`` is falsy, else ``WHERE artist_id IN artist_ids`` — so the
|
||||
normal untranslated selection re-runs them through Interpreter. Then handles
|
||||
one bounded chunk and re-enqueues itself until the scoped backlog is drained
|
||||
(run-until-done; ``_reset_done`` guards against wiping fresh work on the
|
||||
follow-up chunks). No reset happens unless the service is configured +
|
||||
healthy, so translations are never cleared when they can't be rebuilt."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
ready = _translation_config(session)
|
||||
if isinstance(ready, str):
|
||||
return ready
|
||||
base_url, target = ready
|
||||
|
||||
if not _reset_done:
|
||||
n = _reset_translations(session, artist_ids)
|
||||
log.info(
|
||||
"retranslate_posts: reset %d post(s) (artist_ids=%s)",
|
||||
n, artist_ids,
|
||||
)
|
||||
|
||||
posts = _select_untranslated(session, artist_ids, _MAX_POSTS_PER_RUN)
|
||||
status, translated = _translate_batch(session, posts, base_url, target)
|
||||
|
||||
# run-until-done: only chase the tail when the chunk finished cleanly. A
|
||||
# service interruption / bad request stops here and leaves the rest NULL
|
||||
# for the daily beat or a fresh trigger to resume (rule-89 recovery).
|
||||
# Termination is guaranteed: every handled post leaves the NULL set, so
|
||||
# the remaining count strictly decreases each chunk.
|
||||
if status == "ok":
|
||||
remaining = _count_untranslated(session, artist_ids)
|
||||
if remaining:
|
||||
retranslate_posts.apply_async(
|
||||
(),
|
||||
{"artist_ids": artist_ids, "_reset_done": True},
|
||||
countdown=_RETRANSLATE_COUNTDOWN,
|
||||
)
|
||||
log.info(
|
||||
"retranslate_posts: %d remaining → re-enqueued", remaining,
|
||||
)
|
||||
return _summary(status, translated, len(posts))
|
||||
|
||||
|
||||
def _translation_config(session):
|
||||
"""Resolve (base_url, target) if translation is enabled + healthy, else a
|
||||
short status string ("disabled" / "interpreter unavailable") for the caller
|
||||
to return. Centralises the guard so translate/retranslate agree exactly."""
|
||||
cfg = ImportSettings.load_sync(session)
|
||||
if not cfg.translation_enabled or not cfg.interpreter_base_url.strip():
|
||||
return "disabled"
|
||||
base_url = cfg.interpreter_base_url.strip()
|
||||
target = (cfg.translation_target_lang or "en").strip() or "en"
|
||||
if not ic.health(base_url):
|
||||
return "interpreter unavailable"
|
||||
return base_url, target
|
||||
|
||||
|
||||
def _untranslated_filter(stmt, artist_ids):
|
||||
"""Add the untranslated-post predicate (+ optional artist scope) to a
|
||||
select/count over Post. Untranslated = translated_source_lang IS NULL with
|
||||
some text to translate."""
|
||||
stmt = stmt.where(
|
||||
Post.translated_source_lang.is_(None)
|
||||
).where(or_(
|
||||
Post.post_title.is_not(None), Post.description.is_not(None),
|
||||
))
|
||||
if artist_ids:
|
||||
stmt = stmt.where(Post.artist_id.in_(artist_ids))
|
||||
return stmt
|
||||
|
||||
|
||||
def _select_untranslated(session, artist_ids, limit):
|
||||
return session.execute(
|
||||
_untranslated_filter(select(Post), artist_ids).limit(limit)
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def _count_untranslated(session, artist_ids) -> int:
|
||||
return int(session.execute(
|
||||
_untranslated_filter(select(func.count(Post.id)), artist_ids)
|
||||
).scalar_one())
|
||||
|
||||
|
||||
def _reset_translations(session, artist_ids) -> int:
|
||||
"""Clear the stored translation columns for scoped, already-handled posts so
|
||||
the untranslated sweep re-runs them. Only touches rows that were translated
|
||||
(translated_source_lang IS NOT NULL) — untranslated rows are already NULL.
|
||||
Returns the row count reset (commits it)."""
|
||||
stmt = (
|
||||
update(Post)
|
||||
.where(Post.translated_source_lang.is_not(None))
|
||||
.values(
|
||||
post_title_translated=None,
|
||||
description_translated=None,
|
||||
translated_source_lang=None,
|
||||
translation_engine_version=None,
|
||||
translated_at=None,
|
||||
)
|
||||
)
|
||||
if artist_ids:
|
||||
stmt = stmt.where(Post.artist_id.in_(artist_ids))
|
||||
n = session.execute(stmt).rowcount
|
||||
session.commit()
|
||||
return n
|
||||
|
||||
|
||||
def _translate_batch(session, posts, base_url: str, target: str):
|
||||
"""Translate each post in the chunk with a per-post commit (an interrupted
|
||||
run keeps progress). Returns (status, translated) where status is one of
|
||||
"ok" / "interrupted" (503/conn) / "stopped" (400) / "timeout"."""
|
||||
translated = 0
|
||||
for post in posts:
|
||||
try:
|
||||
for post in posts:
|
||||
translated += _translate_one(session, post, base_url, target)
|
||||
session.commit() # per-post → an interrupted run keeps progress
|
||||
translated += _translate_one(session, post, base_url, target)
|
||||
session.commit()
|
||||
except ic.InterpreterUnavailable:
|
||||
session.rollback()
|
||||
return f"interrupted (service down) — translated={translated}"
|
||||
return "interrupted", translated
|
||||
except ic.InterpreterBadRequest as e:
|
||||
session.rollback()
|
||||
log.warning("translate_posts bad request: %s", e)
|
||||
return f"stopped (bad request) — translated={translated}"
|
||||
log.warning("translate bad request: %s", e)
|
||||
return "stopped", translated
|
||||
except SoftTimeLimitExceeded:
|
||||
return f"time limit — translated={translated}"
|
||||
return f"translated={translated} scanned={len(posts)}"
|
||||
return "timeout", translated
|
||||
return "ok", translated
|
||||
|
||||
|
||||
def _summary(status: str, translated: int, scanned: int) -> str:
|
||||
if status == "interrupted":
|
||||
return f"interrupted (service down) — translated={translated}"
|
||||
if status == "stopped":
|
||||
return f"stopped (bad request) — translated={translated}"
|
||||
if status == "timeout":
|
||||
return f"time limit — translated={translated}"
|
||||
return f"translated={translated} scanned={scanned}"
|
||||
|
||||
|
||||
def _translate_one(session, post, base_url: str, target: str) -> int:
|
||||
|
||||
Reference in New Issue
Block a user