feat(translation): per-field language detection for mixed-language posts
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 3m43s

_translate_one translated [title, description] in ONE Interpreter call and keyed
the whole-post passthrough on the aggregate detected_lang (the FIRST item). So an
English title + non-English description detected "en" and marked the post handled,
leaving the description untranslated. Now each field is translated independently
(its own detected_lang / passthrough) and the non-target field is stored on its
own; translated_source_lang reflects the translated field's language.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 23:31:04 -04:00
parent 06f98acf3e
commit 3c7ab44e74
2 changed files with 58 additions and 19 deletions
+29 -19
View File
@@ -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