feat(translation): manueller Übersetzungs-Button im Dashboard
Fremdsprachige Artikel ohne deutsche Fassung lassen sich jetzt manuell über das Verwaltungs-Dashboard übersetzen. Hintergrund: die automatische Übersetzung im Monitor wurde deaktiviert (TRANSLATOR_ENABLED=false), nachdem ein sehr großer Lauf den Refresh-Worker blockiert hatte. - translation_agent.py: Verwaltungs-Adaption des Monitor-Translators (Haiku-Batches), Imports auf shared.agents.claude_client umgestellt - routers/translation.py: Endpoints /api/translation/status, /run und /cancel. Der Lauf läuft als entkoppelter Hintergrund-Task, blockiert keinen Request und ist jederzeit abbrechbar - Dashboard-Karte mit Fortschrittsbalken, Aufwandsschätzung vorab und Abbrechen-Button - test_imports.py: neuen Router in den Smoke-Test aufgenommen Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
222
src/routers/translation.py
Normale Datei
222
src/routers/translation.py
Normale Datei
@@ -0,0 +1,222 @@
|
||||
"""Manuelle Artikel-Übersetzung.
|
||||
|
||||
Stößt die Haiku-Übersetzung fremdsprachiger Artikel an, die noch keine
|
||||
deutsche Fassung haben. Im Monitor läuft der Translator seit 2026-05-22 NICHT
|
||||
mehr automatisch (TRANSLATOR_ENABLED=false), weil ein sehr großer Lauf den
|
||||
Refresh-Worker blockierte. Dieser Endpoint ist der bewusste manuelle Ersatz:
|
||||
er läuft als entkoppelter Hintergrund-Task, blockiert keinen Request und ist
|
||||
jederzeit abbrechbar.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from auth import get_current_admin
|
||||
from audit import log_action, get_client_ip
|
||||
from database import get_db
|
||||
from translation_agent import translate_articles_batch
|
||||
|
||||
logger = logging.getLogger("verwaltung.translation")
|
||||
router = APIRouter(prefix="/api/translation", tags=["Translation"])
|
||||
|
||||
# Batch-Größe wie im Translator-Agent (durch das Haiku-Output-Limit bestimmt).
|
||||
_BATCH_SIZE = 5
|
||||
|
||||
# Grobe Schätzwerte aus Produktiv-Logs (Haiku, 5 Artikel/Batch):
|
||||
# rund 17 s und rund $0.03 pro Batch.
|
||||
_SECONDS_PER_ARTICLE = 3.5
|
||||
_COST_PER_ARTICLE = 0.006
|
||||
|
||||
# Artikel ohne deutsche Fassung: fremdsprachig (language gesetzt und != de)
|
||||
# und headline_de ODER content_de fehlt.
|
||||
_PENDING_WHERE = (
|
||||
"language IS NOT NULL AND LOWER(language) != 'de' "
|
||||
"AND (headline_de IS NULL OR headline_de = '' "
|
||||
"OR content_de IS NULL OR content_de = '')"
|
||||
)
|
||||
|
||||
# Modul-globaler Job-Status. Es gibt bewusst nur EINEN Übersetzungs-Job
|
||||
# gleichzeitig, das hält Claude-Last und DB-Schreiblast kalkulierbar.
|
||||
_job: dict = {
|
||||
"running": False,
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"total": 0,
|
||||
"done": 0,
|
||||
"translated": 0,
|
||||
"failed_batches": 0,
|
||||
"cancelled": False,
|
||||
"error": None,
|
||||
"started_by": None,
|
||||
}
|
||||
_job_lock = asyncio.Lock()
|
||||
_cancel_event = asyncio.Event()
|
||||
# Referenz auf den laufenden Task halten, damit der Garbage Collector ihn
|
||||
# nicht vorzeitig einsammelt.
|
||||
_job_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
async def _count_pending(db) -> int:
|
||||
cursor = await db.execute(
|
||||
f"SELECT COUNT(*) FROM articles WHERE {_PENDING_WHERE}"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row[0] if row else 0
|
||||
|
||||
|
||||
async def _run_translation_job(started_by: str):
|
||||
"""Hintergrund-Task: übersetzt alle ausstehenden Artikel batchweise.
|
||||
|
||||
Schreibt nach jedem Batch in die DB zurück und aktualisiert den
|
||||
Fortschritt, damit das Frontend live mitlesen kann. Bricht zwischen den
|
||||
Batches ab, sobald _cancel_event gesetzt ist.
|
||||
"""
|
||||
db = await get_db()
|
||||
try:
|
||||
# Großzügiger Lock-Timeout, weil der Monitor parallel in dieselbe
|
||||
# geteilte DB schreiben kann (WAL erlaubt nur einen Writer).
|
||||
await db.execute("PRAGMA busy_timeout=30000")
|
||||
cursor = await db.execute(
|
||||
f"SELECT id, headline, content_original, language "
|
||||
f"FROM articles WHERE {_PENDING_WHERE} ORDER BY id DESC"
|
||||
)
|
||||
articles = [dict(r) for r in await cursor.fetchall()]
|
||||
_job["total"] = len(articles)
|
||||
logger.info(
|
||||
"Übersetzungs-Job gestartet von %s: %d Artikel",
|
||||
started_by, len(articles),
|
||||
)
|
||||
|
||||
for i in range(0, len(articles), _BATCH_SIZE):
|
||||
if _cancel_event.is_set():
|
||||
_job["cancelled"] = True
|
||||
logger.info(
|
||||
"Übersetzungs-Job abgebrochen bei %d/%d",
|
||||
_job["done"], _job["total"],
|
||||
)
|
||||
break
|
||||
batch = articles[i : i + _BATCH_SIZE]
|
||||
try:
|
||||
translations, _usage = await translate_articles_batch(batch, "de")
|
||||
except Exception as e: # pragma: no cover - defensiv
|
||||
_job["failed_batches"] += 1
|
||||
logger.error("Übersetzungs-Batch fehlgeschlagen: %s", e)
|
||||
_job["done"] = min(i + _BATCH_SIZE, len(articles))
|
||||
continue
|
||||
for t in translations:
|
||||
hd = t.get("headline_de")
|
||||
cd = t.get("content_de")
|
||||
if hd or cd:
|
||||
await db.execute(
|
||||
"UPDATE articles SET "
|
||||
"headline_de = COALESCE(?, headline_de), "
|
||||
"content_de = COALESCE(?, content_de) WHERE id = ?",
|
||||
(hd, cd, t["id"]),
|
||||
)
|
||||
_job["translated"] += 1
|
||||
await db.commit()
|
||||
_job["done"] = min(i + _BATCH_SIZE, len(articles))
|
||||
|
||||
logger.info(
|
||||
"Übersetzungs-Job beendet: %d/%d übersetzt, %d Batch-Fehler, abgebrochen=%s",
|
||||
_job["translated"], _job["total"], _job["failed_batches"],
|
||||
_job["cancelled"],
|
||||
)
|
||||
except Exception as e:
|
||||
_job["error"] = str(e)
|
||||
logger.error(
|
||||
"Übersetzungs-Job mit Fehler beendet: %s", e, exc_info=True
|
||||
)
|
||||
finally:
|
||||
_job["running"] = False
|
||||
_job["finished_at"] = _now_iso()
|
||||
await db.close()
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def translation_status(admin=Depends(get_current_admin)):
|
||||
"""Aktueller Job-Status plus Anzahl noch nicht übersetzter Artikel."""
|
||||
db = await get_db()
|
||||
try:
|
||||
pending = await _count_pending(db)
|
||||
finally:
|
||||
await db.close()
|
||||
snap = dict(_job)
|
||||
snap["pending"] = pending
|
||||
snap["estimate"] = {
|
||||
"seconds": round(pending * _SECONDS_PER_ARTICLE),
|
||||
"cost_usd": round(pending * _COST_PER_ARTICLE, 2),
|
||||
}
|
||||
return snap
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
async def translation_run(request: Request, admin=Depends(get_current_admin)):
|
||||
"""Startet die Übersetzung aller ausstehenden Artikel als Hintergrund-Task."""
|
||||
global _job_task
|
||||
async with _job_lock:
|
||||
if _job["running"]:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Es läuft bereits eine Übersetzung."
|
||||
)
|
||||
|
||||
db = await get_db()
|
||||
try:
|
||||
pending = await _count_pending(db)
|
||||
if pending == 0:
|
||||
return {"status": "nothing_to_do", "pending": 0}
|
||||
await log_action(
|
||||
db, admin, get_client_ip(request), "translation.run",
|
||||
resource_type="articles", after={"pending": pending},
|
||||
)
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
started_by = (
|
||||
admin.get("email") or admin.get("username") or str(admin.get("id"))
|
||||
)
|
||||
# Job-Status zurücksetzen und Task entkoppelt starten.
|
||||
_cancel_event.clear()
|
||||
_job.update({
|
||||
"running": True,
|
||||
"started_at": _now_iso(),
|
||||
"finished_at": None,
|
||||
"total": pending,
|
||||
"done": 0,
|
||||
"translated": 0,
|
||||
"failed_batches": 0,
|
||||
"cancelled": False,
|
||||
"error": None,
|
||||
"started_by": started_by,
|
||||
})
|
||||
_job_task = asyncio.create_task(_run_translation_job(started_by))
|
||||
|
||||
logger.info(
|
||||
"Übersetzung manuell gestartet von %s (%d Artikel)", started_by, pending
|
||||
)
|
||||
return {"status": "started", "pending": pending}
|
||||
|
||||
|
||||
@router.post("/cancel")
|
||||
async def translation_cancel(request: Request, admin=Depends(get_current_admin)):
|
||||
"""Bricht einen laufenden Übersetzungs-Job nach dem aktuellen Batch ab."""
|
||||
if not _job["running"]:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Es läuft keine Übersetzung."
|
||||
)
|
||||
_cancel_event.set()
|
||||
db = await get_db()
|
||||
try:
|
||||
await log_action(
|
||||
db, admin, get_client_ip(request), "translation.cancel",
|
||||
resource_type="articles",
|
||||
)
|
||||
finally:
|
||||
await db.close()
|
||||
return {"status": "cancelling"}
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren