diff --git a/RELEASES.json b/RELEASES.json
index 2614b7a..7dbffef 100644
--- a/RELEASES.json
+++ b/RELEASES.json
@@ -1,4 +1,12 @@
[
+ {
+ "version": "2026-05-22T09:37Z",
+ "date": "2026-05-22",
+ "title": "Neue Übersetzungsfunktion im Dashboard",
+ "items": [
+ "Texte können jetzt im Dashboard per Klick manuell übersetzt werden."
+ ]
+ },
{
"version": "2026-05-17T19:19Z",
"date": "2026-05-17",
diff --git a/src/main.py b/src/main.py
index 127236b..db6134c 100644
--- a/src/main.py
+++ b/src/main.py
@@ -11,7 +11,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from config import STATIC_DIR, PORT
-from routers import auth, organizations, licenses, users, dashboard, sources, token_usage, audit
+from routers import auth, organizations, licenses, users, dashboard, sources, token_usage, audit, translation
logging.basicConfig(
level=logging.INFO,
@@ -42,6 +42,7 @@ app.include_router(dashboard.router)
app.include_router(sources.router)
app.include_router(token_usage.router)
app.include_router(audit.router)
+app.include_router(translation.router)
# --- Statische Dateien ---
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
diff --git a/src/routers/translation.py b/src/routers/translation.py
new file mode 100644
index 0000000..1aeeb25
--- /dev/null
+++ b/src/routers/translation.py
@@ -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"}
diff --git a/src/static/dashboard.html b/src/static/dashboard.html
index f40effd..b4813a3 100644
--- a/src/static/dashboard.html
+++ b/src/static/dashboard.html
@@ -59,6 +59,32 @@
+
+
+
+
+
+
+ Die automatische Übersetzung im Monitor ist deaktiviert. Hier lassen sich
+ fremdsprachige Artikel ohne deutsche Fassung manuell übersetzen.
+
+
Status wird geladen…
+
+
+
+
+
+
+
+
+
@@ -912,7 +938,7 @@
-
+
diff --git a/src/static/js/app.js b/src/static/js/app.js
index 9b8cc46..2558b0b 100644
--- a/src/static/js/app.js
+++ b/src/static/js/app.js
@@ -59,8 +59,10 @@ document.addEventListener("DOMContentLoaded", () => {
setupNavTabs();
setupOrgDetailTabs();
setupForms();
+ setupTranslation();
loadDashboard();
loadDashboardTokenStats();
+ loadTranslationStatus();
loadOrgs();
});
@@ -80,6 +82,7 @@ function setupNavTabs() {
document.querySelectorAll(".app-content > .section").forEach(s => s.classList.remove("active"));
document.getElementById(`sec-${section}`).classList.add("active");
+ if (section === "dashboard") loadTranslationStatus();
if (section === "licenses") loadExpiringLicenses();
if (section === "audit" && typeof loadAudit === "function") loadAudit();
});
@@ -652,6 +655,151 @@ function formatDate(iso) {
}
+// ===== Artikel-Übersetzung =====
+let translationPollTimer = null;
+
+function setupTranslation() {
+ const runBtn = document.getElementById("translationRunBtn");
+ const cancelBtn = document.getElementById("translationCancelBtn");
+ if (runBtn) runBtn.addEventListener("click", startTranslation);
+ if (cancelBtn) cancelBtn.addEventListener("click", cancelTranslation);
+}
+
+function formatDuration(seconds) {
+ seconds = Math.max(0, Math.round(seconds || 0));
+ if (seconds < 60) return seconds + " Sek.";
+ const min = Math.round(seconds / 60);
+ if (min < 60) return min + " Min.";
+ const h = Math.floor(min / 60), m = min % 60;
+ return h + " Std. " + (m ? m + " Min." : "").trim();
+}
+
+function renderTranslation(st) {
+ const info = document.getElementById("translationInfo");
+ const wrap = document.getElementById("translationProgressWrap");
+ const bar = document.getElementById("translationProgressBar");
+ const ptext = document.getElementById("translationProgressText");
+ const runBtn = document.getElementById("translationRunBtn");
+ const cancelBtn = document.getElementById("translationCancelBtn");
+ if (!info || !runBtn) return;
+
+ if (st.running) {
+ runBtn.style.display = "none";
+ cancelBtn.style.display = "";
+ wrap.style.display = "";
+ const pct = st.total > 0 ? Math.round((st.done / st.total) * 100) : 0;
+ bar.style.width = pct + "%";
+ ptext.textContent = `${st.done} / ${st.total} verarbeitet, ${st.translated} übersetzt (${pct}%)`;
+ info.textContent = "Übersetzung läuft…";
+ return;
+ }
+
+ runBtn.style.display = "";
+ cancelBtn.style.display = "none";
+ wrap.style.display = "none";
+
+ let resultLine = "";
+ if (st.finished_at && (st.total > 0 || st.error)) {
+ if (st.error) {
+ resultLine = `Letzter Lauf mit Fehler beendet: ${st.error}. `;
+ } else if (st.cancelled) {
+ resultLine = `Letzter Lauf abgebrochen, ${st.translated} von ${st.total} Artikeln übersetzt. `;
+ } else {
+ resultLine = `Letzter Lauf abgeschlossen, ${st.translated} Artikel übersetzt. `;
+ }
+ }
+
+ if (st.pending > 0) {
+ const est = st.estimate || {};
+ info.textContent = resultLine +
+ `${st.pending} Artikel ohne deutsche Übersetzung. ` +
+ `Geschätzt: ${formatDuration(est.seconds)}, ca. $${est.cost_usd}.`;
+ runBtn.disabled = false;
+ } else {
+ info.textContent = resultLine + "Alle Artikel sind übersetzt.";
+ runBtn.disabled = true;
+ }
+}
+
+async function loadTranslationStatus() {
+ try {
+ const st = await API.get("/api/translation/status");
+ renderTranslation(st);
+ if (st.running && !translationPollTimer) {
+ translationPollTimer = setInterval(pollTranslation, 3000);
+ }
+ } catch (e) {
+ const info = document.getElementById("translationInfo");
+ if (info) info.textContent = "Status nicht abrufbar: " + (e.message || e);
+ }
+}
+
+async function pollTranslation() {
+ try {
+ const st = await API.get("/api/translation/status");
+ renderTranslation(st);
+ if (!st.running) {
+ clearInterval(translationPollTimer);
+ translationPollTimer = null;
+ if (st.error) {
+ showToast("Übersetzung mit Fehler beendet", "error");
+ } else if (st.cancelled) {
+ showToast(`Übersetzung abgebrochen, ${st.translated} übersetzt`, "info");
+ } else {
+ showToast(`Übersetzung fertig: ${st.translated} Artikel`, "success");
+ }
+ }
+ } catch (e) {
+ console.warn("Translation-Poll fehlgeschlagen:", e);
+ }
+}
+
+async function startTranslation() {
+ let st;
+ try {
+ st = await API.get("/api/translation/status");
+ } catch (e) {
+ showToast(e.message || "Status nicht abrufbar", "error");
+ return;
+ }
+ if (st.running) { showToast("Es läuft bereits eine Übersetzung", "info"); return; }
+ if (!st.pending) { showToast("Es gibt nichts zu übersetzen", "info"); return; }
+
+ const est = st.estimate || {};
+ const ok = await showConfirm(
+ "Übersetzung starten",
+ `${st.pending} Artikel werden ins Deutsche übersetzt. ` +
+ `Geschätzte Dauer: ${formatDuration(est.seconds)}, geschätzte Kosten: ca. $${est.cost_usd}. ` +
+ `Der Lauf kann jederzeit abgebrochen werden.`
+ );
+ if (!ok) return;
+
+ try {
+ const res = await API.post("/api/translation/run", {});
+ if (res && res.status === "started") {
+ showToast(`Übersetzung gestartet (${res.pending} Artikel)`, "success");
+ await loadTranslationStatus();
+ if (!translationPollTimer) {
+ translationPollTimer = setInterval(pollTranslation, 3000);
+ }
+ } else {
+ showToast("Es gibt nichts zu übersetzen", "info");
+ loadTranslationStatus();
+ }
+ } catch (e) {
+ showToast(e.message || "Start fehlgeschlagen", "error");
+ }
+}
+
+async function cancelTranslation() {
+ try {
+ await API.post("/api/translation/cancel", {});
+ showToast("Übersetzung wird abgebrochen…", "info");
+ } catch (e) {
+ showToast(e.message || "Abbruch fehlgeschlagen", "error");
+ }
+}
+
// ===== Token-Nutzung =====
async function loadOrgTokenUsage(orgId) {
try {
diff --git a/src/translation_agent.py b/src/translation_agent.py
new file mode 100644
index 0000000..e360eba
--- /dev/null
+++ b/src/translation_agent.py
@@ -0,0 +1,422 @@
+"""Translator-Agent: übersetzt fremdsprachige Artikel ins Deutsche.
+
+Verwaltungs-Adaption des gleichnamigen Monitor-Agents. Nutzt CLAUDE_MODEL_FAST
+(Haiku) in Batches. Im Verwaltungsportal wird der Translator ausschließlich
+manuell über den Übersetzungs-Button (routers/translation.py) angestoßen,
+niemals automatisch.
+
+Quelle: AegisSight-Monitor/src/agents/translator.py - bei größeren Änderungen
+am Monitor-Original hier nachziehen. Die Imports weichen bewusst ab
+(shared.agents.claude_client statt agents.claude_client). Der restliche Code
+unterhalb ist eine 1:1-Kopie und behält daher den Stil des Originals.
+"""
+import json
+import logging
+import re
+
+from shared.agents.claude_client import call_claude, ClaudeUsage, UsageAccumulator
+from config import CLAUDE_MODEL_FAST
+
+logger = logging.getLogger("verwaltung.translation")
+
+# Im Verwaltungsportal gibt es kein automatisches Übersetzen: der Translator
+# läuft nur, wenn translate_articles() explizit mit enabled=True gerufen wird.
+# Diese Konstante ist daher der konservative Default für enabled=None.
+TRANSLATOR_ENABLED = False
+
+# Pro Batch nicht mehr als so viele Artikel an Claude geben.
+# Bei Haiku ist das Output-Limit ca. 8k Tokens. Pro Artikel kommen leicht
+# 400-600 Tokens raus (headline_de + content_de bis 1000 Zeichen). Bei 15
+# wurde regelmaessig getrunkt (mid-JSON broken). 5 ist sicher mit Reserve.
+DEFAULT_BATCH_SIZE = 5
+
+# content_original wird ohnehin auf 1000 Zeichen gecappt (rss_parser).
+# Fuer den Translator nochmal verkuerzen, falls vorhanden mehr.
+CONTENT_INPUT_MAX = 1200
+
+# content_de soll wie content_original auf 1000 Zeichen begrenzt sein.
+CONTENT_OUTPUT_MAX = 1000
+
+
+def _extract_complete_objects(text: str) -> list[dict]:
+ """Extrahiert vollstaendige JSON-Objekte aus moeglicherweise abgeschnittenem Text.
+
+ Klammer-Counter-Ansatz: jedes balancierte {...} wird probiert.
+ """
+ results = []
+ depth = 0
+ start = -1
+ in_string = False
+ escape = False
+ for i, ch in enumerate(text):
+ if escape:
+ escape = False
+ continue
+ if ch == "\\":
+ escape = True
+ continue
+ if ch == '"' and not escape:
+ in_string = not in_string
+ continue
+ if in_string:
+ continue
+ if ch == "{":
+ if depth == 0:
+ start = i
+ depth += 1
+ elif ch == "}":
+ depth -= 1
+ if depth == 0 and start >= 0:
+ obj_text = text[start:i + 1]
+ try:
+ obj = json.loads(obj_text)
+ if isinstance(obj, dict):
+ results.append(obj)
+ except json.JSONDecodeError:
+ pass
+ start = -1
+ return results
+
+
+def _build_prompt(articles: list[dict], output_lang: str = "de") -> str:
+ """Bauen den Translation-Prompt fuer eine Batch."""
+ lang_label = {"de": "Deutsch", "en": "Englisch"}.get(output_lang, output_lang)
+
+ items = []
+ for a in articles:
+ items.append({
+ "id": a["id"],
+ "headline": a.get("headline", "") or "",
+ "content": (a.get("content_original") or "")[:CONTENT_INPUT_MAX],
+ "source_lang": a.get("language", "en"),
+ })
+
+ return f"""Du bist ein praeziser Uebersetzer fuer Nachrichten-Artikel.
+Uebersetze die folgenden Artikel nach {lang_label}.
+
+WICHTIG:
+- Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) - NIEMALS Umschreibungen wie ae, oe, ue, ss.
+ Beispiele: "Gespraeche" -> "Gespräche", "Fuehrer" -> "Führer", "grosse" -> "große".
+- Behalte Eigennamen (Personen, Orte, Organisationen) im Original.
+- Headline kurz und buendig wie im Original.
+- Content auf MAX {CONTENT_OUTPUT_MAX} Zeichen kuerzen, kein HTML, kein Markdown.
+- Wenn der Artikel schon auf {lang_label} ist (z.B. source_lang="{output_lang}"),
+ kopiere headline und content unveraendert.
+
+Antworte AUSSCHLIESSLICH mit einem flachen JSON-Array (kein Wrapper-Objekt!).
+Format genau so:
+[
+ {{"id": 1, "headline_de": "Titel auf Deutsch", "content_de": "Inhalt auf Deutsch"}},
+ {{"id": 2, "headline_de": "...", "content_de": "..."}}
+]
+
+NICHT erlaubt: {{"translations": [...]}} oder {{"items": [...]}} oder Markdown-Codefences.
+Nur das Array, ohne Einleitung, ohne Erklaerung.
+
+ARTIKEL:
+{json.dumps(items, ensure_ascii=False, indent=2)}
+"""
+
+
+def _parse_response(text: str) -> list[dict]:
+ """Robustes JSON-Array-Parsing.
+
+ Handhabt:
+ - reines JSON
+ - JSON in Markdown-Codefence ```json ... ```
+ - abgeschnittene Antworten (extrahiert vollstaendige Top-Level-Objekte)
+ """
+ text = text.strip()
+ # Markdown-Codefence entfernen
+ if text.startswith("```"):
+ text = re.sub(r"^```(?:json)?\s*", "", text)
+ text = re.sub(r"\s*```\s*$", "", text)
+ text = text.strip()
+
+ try:
+ data = json.loads(text)
+ except json.JSONDecodeError:
+ # Erst Array versuchen
+ match = re.search(r"\[.*\]", text, re.DOTALL)
+ if match:
+ try:
+ data = json.loads(match.group(0))
+ except json.JSONDecodeError:
+ # Truncate-Fallback: einzelne Top-Level-Objekte extrahieren
+ data = _extract_complete_objects(text)
+ else:
+ data = _extract_complete_objects(text)
+
+ # Claude wraps das Array gelegentlich in {"translations": [...]} oder {"items": [...]}
+ if isinstance(data, dict):
+ for key in ("translations", "items", "results", "data"):
+ if isinstance(data.get(key), list):
+ data = data[key]
+ break
+ else:
+ # Einzelnes Objekt? Dann als Liste mit einem Element behandeln
+ if "id" in data:
+ data = [data]
+ else:
+ raise ValueError(f"Translator-Antwort: Dict ohne erwarteten Array-Key (keys={list(data.keys())[:5]})")
+
+ if not isinstance(data, list):
+ raise ValueError(f"Translator-Antwort ist kein Array: {type(data).__name__}")
+
+ cleaned = []
+ for item in data:
+ if not isinstance(item, dict):
+ continue
+ aid = item.get("id")
+ if not isinstance(aid, int):
+ try:
+ aid = int(aid)
+ except (TypeError, ValueError):
+ continue
+ cleaned.append({
+ "id": aid,
+ "headline_de": (item.get("headline_de") or "").strip() or None,
+ "content_de": (item.get("content_de") or "").strip() or None,
+ })
+ return cleaned
+
+
+async def translate_articles_batch(
+ articles: list[dict],
+ output_lang: str = "de",
+) -> tuple[list[dict], ClaudeUsage]:
+ """Uebersetzt eine Batch von Artikeln.
+
+ Erwartet articles als Liste von Dicts mit den Feldern id, headline,
+ content_original, language.
+
+ Rueckgabe: (uebersetzte_artikel, usage)
+ Wenn der Call fehlschlaegt, wird ([], leere_usage) zurueckgegeben - der
+ Caller kann entscheiden, ob retry oder skip.
+ """
+ if not articles:
+ return [], ClaudeUsage()
+
+ prompt = _build_prompt(articles, output_lang)
+
+ try:
+ result_text, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
+ except Exception as e:
+ logger.error(f"Translator Claude-Call fehlgeschlagen: {e}")
+ return [], ClaudeUsage()
+
+ try:
+ translations = _parse_response(result_text)
+ except Exception as e:
+ logger.error(f"Translator JSON-Parsing fehlgeschlagen: {e}; raw: {result_text[:300]!r}")
+ return [], usage
+
+ # Validierung: nur Translations zurueckgeben, deren id wirklich
+ # in der angefragten Batch war
+ requested_ids = {a["id"] for a in articles}
+ valid = [t for t in translations if t["id"] in requested_ids]
+ if len(valid) != len(translations):
+ logger.warning(
+ "Translator: %d von %d Translations referenzieren unbekannte IDs",
+ len(translations) - len(valid), len(translations),
+ )
+ return valid, usage
+
+
+# --- Pre-Topic-Filter: schmale Headline-Übersetzung -----------------------------
+#
+# Der Topic-Filter (analyzer.filter_relevant_articles) ist ein Haiku-Call, der pro
+# Artikel beurteilt, ob er thematisch zur Lage passt. Bei fremdsprachigen Headlines
+# (CJK/Arabisch/Hebräisch/Kyrillisch) bewertet Haiku konservativ und verwirft sie
+# häufig, weil er sie nur halb versteht. Damit landeten z.B. die japanischen
+# Ministeriums-Feeds (MOD, NHK, Asahi) in Lagen mit Japan-Bezug nie in der finalen
+# Auswahl, obwohl der RSS-Match korrekt griff.
+#
+# Diese Funktion übersetzt einen einzelnen Batch-Call alle nicht-lateinischen
+# Headlines + erste Content-Sätze ins Englische und hängt das Ergebnis als
+# article["headline_en_for_topic"] / article["content_en_for_topic"] an. Der
+# Topic-Filter zeigt das dem LLM zusätzlich zum Original.
+#
+# WICHTIG: Diese Mini-Übersetzung ist UNABHÄNGIG vom TRANSLATOR_ENABLED-Flag —
+# sie wird auch dann gemacht, wenn der nachgelagerte Volltext-Translator
+# deaktiviert ist (Pflicht für korrektes Topic-Filtering, sehr kleine Kosten).
+
+_TOPIC_TRANSLATE_CONTENT_MAX = 500
+
+
+def _needs_pretopic_translate(article: dict) -> bool:
+ """Erkennt fremdsprachige Headlines, die für den Topic-Filter übersetzt
+ werden sollten.
+
+ Heuristik: Headline enthält Non-ASCII-Zeichen, die NICHT in den typischen
+ deutsch/franz./span./port./skand. Latin-1-Erweiterungen liegen.
+ Das sind v.a. CJK (Kanji/Kana/Hangul), Arabisch, Hebräisch, Kyrillisch,
+ Thai, Devanagari etc.
+ """
+ headline = (article.get("headline_de") or article.get("headline") or "").strip()
+ if not headline:
+ return False
+ for ch in headline:
+ cp = ord(ch)
+ # Bereiche ausschließen, die in Latin-Schrift normal sind:
+ # ASCII (0-127), Latin-1 Supplement (128-255), Latin Extended-A/B (256-591)
+ if cp <= 591:
+ continue
+ # Alles darüber sind fremde Schriftsysteme → übersetzen
+ return True
+ return False
+
+
+async def translate_headlines_for_topic_filter(
+ articles: list[dict],
+ target_lang: str = "en",
+) -> tuple[int, ClaudeUsage]:
+ """Übersetzt die Headlines fremdsprachiger Artikel ins Englische, damit der
+ nachgelagerte Topic-Filter (Haiku) sie zuverlässig beurteilen kann.
+
+ Setzt direkt auf den Artikel-Dicts:
+ article["headline_en_for_topic"]: str | None
+ article["content_en_for_topic"]: str | None
+
+ Returns:
+ (anzahl_übersetzt, ClaudeUsage)
+ """
+ if not articles:
+ return 0, ClaudeUsage()
+
+ candidates = [a for a in articles if _needs_pretopic_translate(a)]
+ if not candidates:
+ return 0, ClaudeUsage()
+
+ # Eindeutige Indizes (auch wenn article kein "id"-Feld hat, weil noch nicht
+ # in der DB): wir nutzen die Position in der gesamten articles-Liste.
+ idx_by_obj = {id(a): i for i, a in enumerate(articles)}
+
+ items = []
+ for a in candidates:
+ idx = idx_by_obj.get(id(a))
+ if idx is None:
+ continue
+ headline = (a.get("headline_de") or a.get("headline") or "").strip()
+ content_src = (a.get("content_de") or a.get("content_original") or "")
+ items.append({
+ "i": idx,
+ "h": headline[:200],
+ "c": content_src[:_TOPIC_TRANSLATE_CONTENT_MAX],
+ })
+
+ if not items:
+ return 0, ClaudeUsage()
+
+ lang_label = {"en": "English", "de": "German"}.get(target_lang, target_lang)
+ prompt = f"""Translate these news headlines and short content snippets to {lang_label}.
+Keep proper names (people, organizations, places) untouched. Keep it concise; the goal
+is to let another model judge topical relevance, not to publish.
+
+Return ONLY a JSON array. Each item: {{"i": , "h": , "c": }}.
+Keep the same "i" values. No prose, no markdown fences.
+
+INPUT:
+{json.dumps(items, ensure_ascii=False)}
+"""
+
+ try:
+ result_text, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
+ except Exception as e:
+ logger.warning(f"Pre-Topic-Translate Claude-Call fehlgeschlagen: {e}")
+ return 0, ClaudeUsage()
+
+ # Robustes Parsing (Markdown-Codefence + nacktes Array)
+ text = result_text.strip()
+ if text.startswith("```"):
+ text = re.sub(r"^```(?:json)?\s*", "", text)
+ text = re.sub(r"\s*```\s*$", "", text)
+ text = text.strip()
+ try:
+ data = json.loads(text)
+ except json.JSONDecodeError:
+ m = re.search(r"\[.*\]", text, re.DOTALL)
+ if not m:
+ logger.warning(
+ f"Pre-Topic-Translate: kein JSON-Array in Antwort. Sample: {text[:200]!r}"
+ )
+ return 0, usage
+ try:
+ data = json.loads(m.group(0))
+ except json.JSONDecodeError:
+ data = _extract_complete_objects(text)
+
+ if not isinstance(data, list):
+ logger.warning(
+ f"Pre-Topic-Translate: Antwort ist kein Array ({type(data).__name__})"
+ )
+ return 0, usage
+
+ applied = 0
+ for entry in data:
+ if not isinstance(entry, dict):
+ continue
+ idx = entry.get("i")
+ if not isinstance(idx, int) or not (0 <= idx < len(articles)):
+ try:
+ idx = int(idx)
+ if not (0 <= idx < len(articles)):
+ continue
+ except (TypeError, ValueError):
+ continue
+ h = (entry.get("h") or "").strip() or None
+ c = (entry.get("c") or "").strip() or None
+ if h:
+ articles[idx]["headline_en_for_topic"] = h
+ if c:
+ articles[idx]["content_en_for_topic"] = c
+ if h or c:
+ applied += 1
+
+ return applied, usage
+
+
+async def translate_articles(
+ articles: list[dict],
+ output_lang: str = "de",
+ batch_size: int = DEFAULT_BATCH_SIZE,
+ usage_accumulator: UsageAccumulator | None = None,
+ enabled: bool | None = None,
+) -> list[dict]:
+ """Uebersetzt eine beliebige Anzahl Artikel in Batches.
+
+ Bringt die Batches durch Logik in `translate_articles_batch` und gibt
+ EINE flache Liste der Translations zurueck. Wenn ein Batch fehlschlaegt,
+ wird er uebersprungen (anderer Batches laufen weiter).
+
+ enabled: Pro-Aufruf-Override des globalen TRANSLATOR_ENABLED-Flags. Wenn None,
+ greift das Modul-Default (config.TRANSLATOR_ENABLED, abgeleitet aus .env).
+ Der Orchestrator setzt das aus dem Org-Setting 'translator_enabled', damit
+ jp_demo (Translator zwingend an) trotz global deaktiviertem Flag funktioniert.
+ """
+ if not articles:
+ return []
+
+ is_enabled = TRANSLATOR_ENABLED if enabled is None else bool(enabled)
+ if not is_enabled:
+ logger.info(
+ "Translator deaktiviert (enabled=%s, global TRANSLATOR_ENABLED=%s), %d Artikel uebersprungen",
+ enabled, TRANSLATOR_ENABLED, len(articles),
+ )
+ return []
+
+ all_translations = []
+ for i in range(0, len(articles), batch_size):
+ batch = articles[i : i + batch_size]
+ translations, usage = await translate_articles_batch(batch, output_lang)
+ if usage_accumulator is not None:
+ usage_accumulator.add(usage)
+ all_translations.extend(translations)
+ logger.info(
+ "Translator-Batch %d/%d: %d/%d uebersetzt (cost=$%.4f)",
+ (i // batch_size) + 1,
+ (len(articles) + batch_size - 1) // batch_size,
+ len(translations), len(batch),
+ usage.cost_usd,
+ )
+ return all_translations
diff --git a/tests/test_imports.py b/tests/test_imports.py
index ea23ff2..781f241 100644
--- a/tests/test_imports.py
+++ b/tests/test_imports.py
@@ -10,7 +10,7 @@ def test_main_app_imports():
def test_all_routers_importable():
"""Bei Syntax-Fehlern in einem Router crasht das Ganze - hier fangen wir das ab."""
for mod in ("auth", "organizations", "licenses", "users",
- "dashboard", "sources", "token_usage", "audit"):
+ "dashboard", "sources", "token_usage", "audit", "translation"):
m = importlib.import_module(f"routers.{mod}")
assert hasattr(m, "router"), f"routers/{mod} hat keinen router-Objekt"