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:
@@ -97,6 +97,68 @@ async def test_translation_run_enqueues_when_configured(client, monkeypatch):
|
||||
assert (await resp.get_json())["celery_task_id"] == "t1"
|
||||
|
||||
|
||||
def _fake_retranslate(monkeypatch, sink):
|
||||
# Record the kwargs the endpoint enqueues with, return a fake AsyncResult.
|
||||
monkeypatch.setattr(
|
||||
"backend.app.tasks.translation.retranslate_posts.delay",
|
||||
lambda *a, **k: sink.update(k) or type("R", (), {"id": "r1"})(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_retranslate_requires_config(client):
|
||||
# Disabled → 400 even with an explicit scope (no reset is enqueued).
|
||||
resp = await client.post(
|
||||
"/api/settings/translation/retranslate", json={"all": True})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_retranslate_requires_target(client, monkeypatch):
|
||||
# 'all' must be explicit: an empty body is a 400, so a stray call can't
|
||||
# wipe every translation.
|
||||
_fake_retranslate(monkeypatch, {})
|
||||
await client.patch("/api/settings/import", json={
|
||||
"translation_enabled": True, "interpreter_base_url": "http://i.lan",
|
||||
})
|
||||
resp = await client.post("/api/settings/translation/retranslate")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_retranslate_artist_scope(client, monkeypatch):
|
||||
sink = {}
|
||||
_fake_retranslate(monkeypatch, sink)
|
||||
await client.patch("/api/settings/import", json={
|
||||
"translation_enabled": True, "interpreter_base_url": "http://i.lan",
|
||||
})
|
||||
resp = await client.post(
|
||||
"/api/settings/translation/retranslate", json={"artist_id": 7})
|
||||
assert resp.status_code == 202
|
||||
assert (await resp.get_json())["celery_task_id"] == "r1"
|
||||
assert sink["artist_ids"] == [7]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_retranslate_all_scope(client, monkeypatch):
|
||||
sink = {}
|
||||
_fake_retranslate(monkeypatch, sink)
|
||||
await client.patch("/api/settings/import", json={
|
||||
"translation_enabled": True, "interpreter_base_url": "http://i.lan",
|
||||
})
|
||||
resp = await client.post(
|
||||
"/api/settings/translation/retranslate", json={"all": True})
|
||||
assert resp.status_code == 202
|
||||
assert sink["artist_ids"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_retranslate_bad_artist_id(client):
|
||||
resp = await client.post(
|
||||
"/api/settings/translation/retranslate", json={"artist_id": "abc"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_rejects_non_object(client):
|
||||
resp = await client.patch("/api/settings/import", json=[1, 2, 3])
|
||||
|
||||
@@ -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