feat(translation): 8h sweep + drain button + detection probe (#1376)
Throughput: translate_posts now runs every 8h (was daily) as the steady-state cadence for newly-imported posts, and the Settings "Translate now" button runs it in drain mode (run-until-done, no reset) so one press clears the whole untranslated backlog instead of a single 300-post chunk. The interrupt/backoff re-enqueue now preserves the drain flag so a bulk drain resumes cleanly after an Interpreter restart. Misdetection groundwork: surface the detector's confidence from the Interpreter client (it was in the detectedLanguage payload but discarded) and add a read-only "Test translation" box — POST /settings/translation/ probe + TranslationCard UI — that shows detected language + confidence + engine + result for pasted text, without saving. Lets the operator see why a short/abbreviation-heavy English title gets mis-detected so the detection guard (min-length + confidence floor) can be tuned from real numbers. The guard itself follows once the mis-detected cases are probed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgZP9v2otxVJymiYsnVuMy
This commit is contained in:
@@ -113,9 +113,10 @@ async def test_translation_test_connection(client, monkeypatch):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_run_enqueues_when_configured(client, monkeypatch):
|
||||
sink = {}
|
||||
monkeypatch.setattr(
|
||||
"backend.app.tasks.translation.translate_posts.delay",
|
||||
lambda *a, **k: type("R", (), {"id": "t1"})(),
|
||||
lambda *a, **k: sink.update(k) or type("R", (), {"id": "t1"})(),
|
||||
)
|
||||
await client.patch("/api/settings/import", json={
|
||||
"translation_enabled": True, "interpreter_base_url": "http://i.lan",
|
||||
@@ -123,6 +124,46 @@ async def test_translation_run_enqueues_when_configured(client, monkeypatch):
|
||||
resp = await client.post("/api/settings/translation/run")
|
||||
assert resp.status_code == 202
|
||||
assert (await resp.get_json())["celery_task_id"] == "t1"
|
||||
assert sink["drain"] is True # "Translate now" chases the whole backlog
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_probe_requires_text(client):
|
||||
resp = await client.post(
|
||||
"/api/settings/translation/probe", json={"text": " "})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_probe_requires_base_url(client):
|
||||
# Text given but no Interpreter URL saved → nothing to probe against.
|
||||
resp = await client.post(
|
||||
"/api/settings/translation/probe", json={"text": "hello"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translation_probe_returns_detection(client, monkeypatch):
|
||||
# The diagnostic surfaces detected language + confidence + result, unsaved.
|
||||
monkeypatch.setattr(
|
||||
"backend.app.api.settings.ic.translate",
|
||||
lambda texts, **k: {
|
||||
"translations": ["Work in Progress"],
|
||||
"detected_lang": "de", "detected_confidence": 71.5,
|
||||
"engine": "llm", "engine_version": "v1",
|
||||
},
|
||||
)
|
||||
await client.patch("/api/settings/import", json={
|
||||
"translation_enabled": True, "interpreter_base_url": "http://i.lan",
|
||||
})
|
||||
resp = await client.post(
|
||||
"/api/settings/translation/probe", json={"text": "WIP"})
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["detected_lang"] == "de"
|
||||
assert body["detected_confidence"] == 71.5
|
||||
assert body["translated"] == "Work in Progress"
|
||||
assert body["target"] == "en"
|
||||
|
||||
|
||||
def _fake_retranslate(monkeypatch, sink):
|
||||
|
||||
@@ -34,6 +34,7 @@ def test_translate_maps_batch_in_order(monkeypatch):
|
||||
out = ic.translate(["ねこが可愛い", "金髪ギャル!"], base_url="http://i.lan")
|
||||
assert out["translations"] == ["The cat is cute", "Blonde gal!"]
|
||||
assert out["detected_lang"] == "ja"
|
||||
assert out["detected_confidence"] == 0.98 # surfaced for the detection guard
|
||||
assert out["engine_version"] == "ollama:x:12b"
|
||||
assert captured["json"]["q"] == ["ねこが可愛い", "金髪ギャル!"]
|
||||
assert captured["json"]["target"] == "en"
|
||||
|
||||
@@ -5,7 +5,11 @@ from sqlalchemy import select
|
||||
|
||||
from backend.app.models import Artist, ImportSettings, Post
|
||||
from backend.app.services import interpreter_client as ic
|
||||
from backend.app.tasks.translation import retranslate_posts, translate_posts
|
||||
from backend.app.tasks.translation import (
|
||||
_RETRANSLATE_COUNTDOWN,
|
||||
retranslate_posts,
|
||||
translate_posts,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
@@ -191,6 +195,63 @@ def test_translate_posts_interrupt_reenqueues_after_backoff(db_sync, monkeypatch
|
||||
assert calls[0][1]["countdown"] == 30
|
||||
|
||||
|
||||
def _mock_ok(monkeypatch):
|
||||
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"EN:{t}" for t in texts],
|
||||
"detected_lang": "ja", "engine": "llm", "engine_version": "v1",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_translate_posts_drain_chases_tail(db_sync, monkeypatch):
|
||||
# "Translate now" (drain=True): with the chunk capped at 1 and 2 untranslated
|
||||
# posts, the first chunk re-enqueues itself — drain preserved — to finish the
|
||||
# backlog rather than waiting for the next beat.
|
||||
_patch(monkeypatch, db_sync)
|
||||
_mock_ok(monkeypatch)
|
||||
monkeypatch.setattr("backend.app.tasks.translation._MAX_POSTS_PER_RUN", 1)
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"backend.app.tasks.translation.translate_posts.apply_async",
|
||||
lambda *a, **k: calls.append((a, k)),
|
||||
)
|
||||
a = _artist(db_sync)
|
||||
_post(db_sync, a.id, title="ねこ", ext="p1")
|
||||
_post(db_sync, a.id, title="いぬ", ext="p2")
|
||||
_enable(db_sync)
|
||||
db_sync.commit()
|
||||
|
||||
translate_posts(drain=True)
|
||||
assert len(calls) == 1 # one re-enqueue for the tail
|
||||
args, kw = calls[0]
|
||||
assert args[1] == {"drain": True} # drain flag carried forward
|
||||
assert kw["countdown"] == _RETRANSLATE_COUNTDOWN
|
||||
|
||||
|
||||
def test_translate_posts_beat_single_chunk_no_tail_chase(db_sync, monkeypatch):
|
||||
# The periodic beat (drain defaults False) does exactly ONE chunk and never
|
||||
# re-enqueues — the 8h cadence drives the rest.
|
||||
_patch(monkeypatch, db_sync)
|
||||
_mock_ok(monkeypatch)
|
||||
monkeypatch.setattr("backend.app.tasks.translation._MAX_POSTS_PER_RUN", 1)
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"backend.app.tasks.translation.translate_posts.apply_async",
|
||||
lambda *a, **k: calls.append((a, k)),
|
||||
)
|
||||
a = _artist(db_sync)
|
||||
_post(db_sync, a.id, title="ねこ", ext="p1")
|
||||
_post(db_sync, a.id, title="いぬ", ext="p2")
|
||||
_enable(db_sync)
|
||||
db_sync.commit()
|
||||
|
||||
translate_posts() # drain=False
|
||||
assert calls == [] # no self-re-enqueue
|
||||
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user