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:
2026-05-22 10:07:37 +02:00
Ursprung 430641b128
Commit 031bd9e114
6 geänderte Dateien mit 822 neuen und 3 gelöschten Zeilen

Datei anzeigen

@@ -11,7 +11,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from config import STATIC_DIR, PORT 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( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -42,6 +42,7 @@ app.include_router(dashboard.router)
app.include_router(sources.router) app.include_router(sources.router)
app.include_router(token_usage.router) app.include_router(token_usage.router)
app.include_router(audit.router) app.include_router(audit.router)
app.include_router(translation.router)
# --- Statische Dateien --- # --- Statische Dateien ---
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")

222
src/routers/translation.py Normale Datei
Datei anzeigen

@@ -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"}

Datei anzeigen

@@ -59,6 +59,32 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Artikel-Übersetzung -->
<div class="card" id="translationCard" style="margin-top:16px;">
<div class="card-header">
<h2>Artikel-Übersetzung</h2>
</div>
<div class="card-body">
<p class="text-muted" style="margin-top:0;">
Die automatische Übersetzung im Monitor ist deaktiviert. Hier lassen sich
fremdsprachige Artikel ohne deutsche Fassung manuell übersetzen.
</p>
<p id="translationInfo" style="margin:12px 0;">Status wird geladen…</p>
<div id="translationProgressWrap" style="display:none; margin:12px 0;">
<div style="background:rgba(128,128,128,0.25); border-radius:6px; height:14px; overflow:hidden;">
<div id="translationProgressBar" style="background:#1565c0; height:100%; width:0%; transition:width .3s;"></div>
</div>
<p class="text-muted" id="translationProgressText" style="margin:6px 0 0;"></p>
</div>
<div style="margin-top:12px; display:flex; gap:8px;">
<button class="btn btn-primary" id="translationRunBtn">Übersetzung starten</button>
<button class="btn btn-danger" id="translationCancelBtn" style="display:none;">Abbrechen</button>
</div>
</div>
</div>
</div> </div>
<!-- Organizations Section --> <!-- Organizations Section -->
@@ -912,7 +938,7 @@
</div> </div>
</div> </div>
<script src="/static/js/app.js?v=20260513a"></script> <script src="/static/js/app.js?v=20260522a"></script>
<script src="/static/js/sources.js?v=20260509d"></script> <script src="/static/js/sources.js?v=20260509d"></script>
<script src="/static/js/source-health.js?v=20260509l"></script> <script src="/static/js/source-health.js?v=20260509l"></script>
<script src="/static/js/audit.js?v=20260509d"></script> <script src="/static/js/audit.js?v=20260509d"></script>

Datei anzeigen

@@ -59,8 +59,10 @@ document.addEventListener("DOMContentLoaded", () => {
setupNavTabs(); setupNavTabs();
setupOrgDetailTabs(); setupOrgDetailTabs();
setupForms(); setupForms();
setupTranslation();
loadDashboard(); loadDashboard();
loadDashboardTokenStats(); loadDashboardTokenStats();
loadTranslationStatus();
loadOrgs(); loadOrgs();
}); });
@@ -80,6 +82,7 @@ function setupNavTabs() {
document.querySelectorAll(".app-content > .section").forEach(s => s.classList.remove("active")); document.querySelectorAll(".app-content > .section").forEach(s => s.classList.remove("active"));
document.getElementById(`sec-${section}`).classList.add("active"); document.getElementById(`sec-${section}`).classList.add("active");
if (section === "dashboard") loadTranslationStatus();
if (section === "licenses") loadExpiringLicenses(); if (section === "licenses") loadExpiringLicenses();
if (section === "audit" && typeof loadAudit === "function") loadAudit(); 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 ===== // ===== Token-Nutzung =====
async function loadOrgTokenUsage(orgId) { async function loadOrgTokenUsage(orgId) {
try { try {

422
src/translation_agent.py Normale Datei
Datei anzeigen

@@ -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": <index>, "h": <headline in {lang_label}>, "c": <content snippet in {lang_label}>}}.
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

Datei anzeigen

@@ -10,7 +10,7 @@ def test_main_app_imports():
def test_all_routers_importable(): def test_all_routers_importable():
"""Bei Syntax-Fehlern in einem Router crasht das Ganze - hier fangen wir das ab.""" """Bei Syntax-Fehlern in einem Router crasht das Ganze - hier fangen wir das ab."""
for mod in ("auth", "organizations", "licenses", "users", 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}") m = importlib.import_module(f"routers.{mod}")
assert hasattr(m, "router"), f"routers/{mod} hat keinen router-Objekt" assert hasattr(m, "router"), f"routers/{mod} hat keinen router-Objekt"