diff --git a/backend/app/tasks/translation.py b/backend/app/tasks/translation.py index 02cc153..1ed6b1a 100644 --- a/backend/app/tasks/translation.py +++ b/backend/app/tasks/translation.py @@ -250,29 +250,39 @@ def _summary(status: str, translated: int, scanned: int) -> str: return f"translated={translated} scanned={scanned}" +def _translate_field(text: str, base_url: str, target: str): + """Translate ONE field independently. Returns (translated, source_lang, + engine_version), or (None, None, None) when there's nothing to store — empty + text, already the target language, or a passthrough (engine "none"). Per-field + (not aggregate first-item) detection so a non-English description still gets + translated when the title is already English (mixed-language posts).""" + if not text: + return None, None, None + res = ic.translate([text], base_url=base_url, target=target) + detected = res["detected_lang"] or target + if detected == target or res["engine"] == "none": + return None, None, None + return res["translations"][0], detected, res["engine_version"] + + 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).""" + """Translate a post's title/description in place, each field independently. + Returns 1 if it stored at least one translation, 0 when the post is all + passthrough/empty (still marks it handled so the sweep won't revisit it).""" 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 + title_tr, title_lang, title_ev = _translate_field(title, base_url, target) + desc_tr, desc_lang, desc_ev = _translate_field(desc, base_url, target) + if title_tr is None and desc_tr is None: + # Nothing non-target (or nothing to translate) → handled, store nothing. + post.translated_source_lang = target 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.post_title_translated = title_tr + post.description_translated = desc_tr + # Source lang = whichever field was actually non-target (for a mixed post, + # the translated field's language, not the already-English one). + post.translated_source_lang = title_lang or desc_lang + post.translation_engine_version = title_ev or desc_ev post.translated_at = datetime.now(UTC) return 1 diff --git a/tests/test_translate_posts.py b/tests/test_translate_posts.py index 2973241..14d9605 100644 --- a/tests/test_translate_posts.py +++ b/tests/test_translate_posts.py @@ -115,6 +115,35 @@ def test_translate_posts_passthrough_english_marks_handled(db_sync, monkeypatch) assert p.post_title_translated is None # ...but nothing to show +def test_translate_posts_mixed_language_translates_nonenglish_field(db_sync, monkeypatch): + # English title + Japanese description: the description is still translated + # even though the title is already English (per-field detection, not the old + # aggregate first-item bail). Source lang comes from the translated field. + _patch(monkeypatch, db_sync) + monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True) + + def fake_translate(texts, **k): + t = texts[0] + if t.isascii(): # already English → passthrough + return {"translations": [t], "detected_lang": "en", + "engine": "none", "engine_version": None} + return {"translations": [f"EN:{t}"], "detected_lang": "ja", + "engine": "llm", "engine_version": "v9"} + + monkeypatch.setattr("backend.app.tasks.translation.ic.translate", fake_translate) + a = _artist(db_sync) + p = _post(db_sync, a.id, title="hello", desc="ねこ") + _enable(db_sync) + db_sync.commit() + + assert "translated=1" in translate_posts() + db_sync.refresh(p) + assert p.post_title_translated is None # English title untouched + assert p.description_translated == "EN:ねこ" # Japanese desc translated + assert p.translated_source_lang == "ja" # from the translated field + assert p.translation_engine_version == "v9" + + def test_translate_posts_disabled_is_noop(db_sync, monkeypatch): _patch(monkeypatch, db_sync) a = _artist(db_sync)