Files
FabledCurator/backend/app/tasks/translation.py
T
bvandeusen 7a4de7278d
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m49s
feat(translation): backfill sweep + beat + manual trigger (#143 step 3)
tasks/translation.py — translate_posts: picks untranslated posts (title OR
description non-empty), per-post [title, description] batch via the Interpreter
client, stores translations + detected lang + engine_version; passthrough /
already-target posts are marked handled with no stored translation. 503 or a
connection error interrupts (retry next cycle), 400 stops (fix config), per-post
commit keeps progress; wall-clock bounded. Wired into celery (maintenance_long
lane) + a daily beat. No-op unless enabled + base URL set + healthy. GET
/settings/translation/status + POST .../run for the Settings card. Task tests
(stubbed client, monkeypatched session).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
2026-07-07 12:29:28 -04:00

102 lines
4.4 KiB
Python

"""Post-text translation Celery task (milestone 143).
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.
"""
import logging
from datetime import UTC, datetime
from celery.exceptions import SoftTimeLimitExceeded
from sqlalchemy import or_, select
from ..celery_app import celery
from ..models import ImportSettings, Post
from ..services import interpreter_client as ic
from ..utils.text import html_to_plain
from ._sync_engine import sync_session_factory as _sync_session_factory
log = logging.getLogger(__name__)
# Bound one run's work so it commits progress rather than dying at the time
# 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
@celery.task(
name="backend.app.tasks.translation.translate_posts",
soft_time_limit=1800, time_limit=2100,
)
def translate_posts() -> str:
"""Translate untranslated non-English post title/description (default target
en) via Interpreter. Per-post [title, description] batch → per-post detected
language. Passthrough / already-target posts are marked handled (source ==
target) with no stored translation. 503 or a connection error interrupts the
run (retry next cycle); 400 stops it (fix config); posts done so far stay
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"
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
try:
for post in posts:
translated += _translate_one(session, post, base_url, target)
session.commit() # per-post → an interrupted run keeps progress
except ic.InterpreterUnavailable:
session.rollback()
return f"interrupted (service down) — translated={translated}"
except ic.InterpreterBadRequest as e:
session.rollback()
log.warning("translate_posts bad request: %s", e)
return f"stopped (bad request) — translated={translated}"
except SoftTimeLimitExceeded:
return f"time limit — translated={translated}"
return f"translated={translated} scanned={len(posts)}"
def _translate_one(session, post, base_url: str, target: str) -> int:
"""Translate one post's title/description in place. Returns 1 if it stored a
translation, 0 for passthrough/empty (which still marks the post handled)."""
title = (post.post_title or "").strip()
desc = (html_to_plain(post.description) if post.description else "") or ""
desc = desc.strip()
fields: list[tuple[str, str]] = []
if title:
fields.append(("title", title))
if desc:
fields.append(("desc", desc))
if not fields:
post.translated_source_lang = target # nothing to translate → handled
return 0
res = ic.translate([t for _, t in fields], base_url=base_url, target=target)
detected = res["detected_lang"] or target
if detected == target or res["engine"] == "none":
post.translated_source_lang = target # already target language → handled
return 0
by_field = {f: res["translations"][i] for i, (f, _) in enumerate(fields)}
post.post_title_translated = by_field.get("title")
post.description_translated = by_field.get("desc")
post.translated_source_lang = detected
post.translation_engine_version = res["engine_version"]
post.translated_at = datetime.now(UTC)
return 1