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:
@@ -4,7 +4,7 @@ import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models import Artist, ImportSettings, Post
|
||||
from backend.app.tasks.translation import translate_posts
|
||||
from backend.app.tasks.translation import retranslate_posts, translate_posts
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
@@ -28,8 +28,8 @@ def _sf(db_sync):
|
||||
return _SM()
|
||||
|
||||
|
||||
def _artist(db):
|
||||
a = Artist(name="A", slug="a")
|
||||
def _artist(db, name="A", slug="a"):
|
||||
a = Artist(name=name, slug=slug)
|
||||
db.add(a)
|
||||
db.flush()
|
||||
return a
|
||||
@@ -45,6 +45,15 @@ def _post(db, artist_id, *, title=None, desc=None, ext="p1"):
|
||||
return p
|
||||
|
||||
|
||||
def _mark_translated(p, *, lang="ja", ver="v1"):
|
||||
"""Simulate a prior (old-model) translation so retranslate has something to
|
||||
reset."""
|
||||
p.post_title_translated = "OLD"
|
||||
p.description_translated = "OLD"
|
||||
p.translated_source_lang = lang
|
||||
p.translation_engine_version = ver
|
||||
|
||||
|
||||
def _enable(db, url="http://i.lan"):
|
||||
cfg = db.execute(
|
||||
select(ImportSettings).where(ImportSettings.id == 1)
|
||||
@@ -125,3 +134,108 @@ def test_translate_posts_service_down_leaves_untranslated(db_sync, monkeypatch):
|
||||
assert translate_posts() == "interpreter unavailable"
|
||||
db_sync.refresh(p)
|
||||
assert p.translated_source_lang is None # will retry next run
|
||||
|
||||
|
||||
def _mock_new_model(monkeypatch, *, ver="v2"):
|
||||
"""Interpreter is up and translates with a NEW engine version."""
|
||||
monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True)
|
||||
monkeypatch.setattr(
|
||||
"backend.app.tasks.translation.ic.translate",
|
||||
lambda texts, **k: {
|
||||
"translations": [f"NEW:{t}" for t in texts],
|
||||
"detected_lang": "ja", "engine": "llm", "engine_version": ver,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_retranslate_resets_and_reruns(db_sync, monkeypatch):
|
||||
# A post translated by the old model gets cleared + re-run through the new
|
||||
# one (new engine_version proves the reset happened, not a cache short-circuit).
|
||||
_patch(monkeypatch, db_sync)
|
||||
_mock_new_model(monkeypatch)
|
||||
a = _artist(db_sync)
|
||||
p = _post(db_sync, a.id, title="ねこ", desc="かわいい")
|
||||
_mark_translated(p)
|
||||
_enable(db_sync)
|
||||
db_sync.commit()
|
||||
|
||||
retranslate_posts(artist_ids=[a.id])
|
||||
db_sync.refresh(p)
|
||||
assert p.post_title_translated == "NEW:ねこ"
|
||||
assert p.description_translated == "NEW:かわいい"
|
||||
assert p.translation_engine_version == "v2"
|
||||
|
||||
|
||||
def test_retranslate_artist_scope_leaves_others(db_sync, monkeypatch):
|
||||
# Scoping to one artist must not touch another artist's translations.
|
||||
_patch(monkeypatch, db_sync)
|
||||
_mock_new_model(monkeypatch)
|
||||
a1 = _artist(db_sync)
|
||||
a2 = _artist(db_sync, name="B", slug="b")
|
||||
p1 = _post(db_sync, a1.id, title="ねこ", ext="p1")
|
||||
p2 = _post(db_sync, a2.id, title="いぬ", ext="p2")
|
||||
_mark_translated(p1)
|
||||
_mark_translated(p2)
|
||||
_enable(db_sync)
|
||||
db_sync.commit()
|
||||
|
||||
retranslate_posts(artist_ids=[a1.id])
|
||||
db_sync.refresh(p1)
|
||||
db_sync.refresh(p2)
|
||||
assert p1.post_title_translated == "NEW:ねこ" # re-run
|
||||
assert p1.translation_engine_version == "v2"
|
||||
assert p2.post_title_translated == "OLD" # untouched
|
||||
assert p2.translation_engine_version == "v1"
|
||||
|
||||
|
||||
def test_retranslate_runs_until_done(db_sync, monkeypatch):
|
||||
# With the chunk capped at 1 and 2 posts to redo, the first chunk re-enqueues
|
||||
# itself (with _reset_done=True so it doesn't wipe the fresh work) to finish.
|
||||
_patch(monkeypatch, db_sync)
|
||||
_mock_new_model(monkeypatch)
|
||||
monkeypatch.setattr("backend.app.tasks.translation._MAX_POSTS_PER_RUN", 1)
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"backend.app.tasks.translation.retranslate_posts.apply_async",
|
||||
lambda *a, **k: calls.append((a, k)),
|
||||
)
|
||||
a = _artist(db_sync)
|
||||
p1 = _post(db_sync, a.id, title="ねこ", ext="p1")
|
||||
p2 = _post(db_sync, a.id, title="いぬ", ext="p2")
|
||||
_mark_translated(p1)
|
||||
_mark_translated(p2)
|
||||
_enable(db_sync)
|
||||
db_sync.commit()
|
||||
|
||||
retranslate_posts(artist_ids=[a.id])
|
||||
assert len(calls) == 1 # one re-enqueue for the tail
|
||||
args, _kw = calls[0]
|
||||
assert args[1]["_reset_done"] is True
|
||||
assert args[1]["artist_ids"] == [a.id]
|
||||
|
||||
|
||||
def test_retranslate_disabled_does_not_reset(db_sync, monkeypatch):
|
||||
# Never wipe translations we can't rebuild — a disabled service leaves them.
|
||||
_patch(monkeypatch, db_sync)
|
||||
a = _artist(db_sync)
|
||||
p = _post(db_sync, a.id, title="ねこ", ext="p1")
|
||||
_mark_translated(p)
|
||||
db_sync.commit() # translation_enabled defaults False
|
||||
assert retranslate_posts(artist_ids=[a.id]) == "disabled"
|
||||
db_sync.refresh(p)
|
||||
assert p.translated_source_lang == "ja"
|
||||
assert p.post_title_translated == "OLD"
|
||||
|
||||
|
||||
def test_retranslate_unhealthy_does_not_reset(db_sync, monkeypatch):
|
||||
_patch(monkeypatch, db_sync)
|
||||
monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: False)
|
||||
a = _artist(db_sync)
|
||||
p = _post(db_sync, a.id, title="ねこ", ext="p1")
|
||||
_mark_translated(p)
|
||||
_enable(db_sync)
|
||||
db_sync.commit()
|
||||
assert retranslate_posts(artist_ids=[a.id]) == "interpreter unavailable"
|
||||
db_sync.refresh(p)
|
||||
assert p.translated_source_lang == "ja"
|
||||
assert p.post_title_translated == "OLD"
|
||||
|
||||
Reference in New Issue
Block a user