"""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