feat: Mehrsprachigkeit EN/FR/ES via Mistral + Sprachumschalter
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 11m29s

- Übersetzung per ministral-3b-latest (on-demand + DB-Cache)
- Sprachumschalter im Detailseiten-Header (DE/EN/FR/ES)
- ?lang= Query-Parameter auf /beitrag/<id>
- Neue translations-Tabelle mit submission_id+lang UNIQUE
This commit is contained in:
Hermes Agent
2026-07-16 08:04:10 +02:00
parent 5a50df7923
commit 6a55a3e475
2 changed files with 97 additions and 4 deletions
+90 -3
View File
@@ -3323,7 +3323,7 @@ def public_frontend():
</div>
</div>
<header class="site-header">
<button id="navToggle" class="nav-toggle" aria-label="Menü öffnen"></button>
<button id="navToggle" class="nav-toggle" aria-label="Menü öffnen"></button>
<a href="/" class="site-logo">
<img src="/static/logo.png" alt="Brettspiel News" style="height:38px;width:auto;display:block;">
@@ -4180,6 +4180,72 @@ def api_query():
# ── Übersetzung via Mistral ──
LANG_NAMES = {"en": "Englisch", "fr": "Französisch", "es": "Spanisch"}
LANG_FLAGS = {"en": "🇬🇧", "fr": "🇫🇷", "es": "🇪🇸"}
def translate_text(text: str, target_lang: str, content_type: str = "Beitrag") -> str:
"""Übersetzt text via Mistral API in target_lang (en/fr/es)."""
if not text or not text.strip():
return text
api_key = os.environ.get("MISTRAL_API_KEY", "")
if not api_key:
return text # fallback: Originaltext
lang_name = LANG_NAMES.get(target_lang, target_lang)
prompt = (
f"Übersetze folgenden {content_type} ins {lang_name}. "
f"Nur die Übersetzung zurückgeben, keine Erklärungen. "
f"Behalte Zeilenumbrüche und Absätze bei:\n\n{text}"
)
try:
r = requests.post(
"https://api.mistral.ai/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={"model": "ministral-3b-latest", "messages": [
{"role": "user", "content": prompt}
], "max_tokens": 2000, "temperature": 0.2},
timeout=30,
)
if r.status_code == 200:
translated = r.json()["choices"][0]["message"]["content"].strip()
return translated
except Exception:
pass
return text # fallback bei Fehler
def get_translated_content(db, sub_id: int, target_lang: str, row) -> dict:
"""Holt Übersetzung aus Cache oder übersetzt via Mistral."""
if target_lang == "de":
return {"content": (row["summary"] or row["content"] or "").strip(),
"display_text": row["display_text"] or ""}
cached = db.execute(
"SELECT content, display_text FROM translations WHERE submission_id=? AND lang=?",
(sub_id, target_lang)
).fetchone()
if cached:
return {"content": cached["content"] or "", "display_text": cached["display_text"] or ""}
original_content = (row["summary"] or row["content"] or "").strip()
original_display = row["display_text"] or ""
translated_content = translate_text(original_content, target_lang, "Beitrag")
translated_display = translate_text(original_display, target_lang, "Anzeigetext") if original_display else ""
try:
db.execute(
"INSERT OR REPLACE INTO translations (submission_id, lang, content, display_text) VALUES (?,?,?,?)",
(sub_id, target_lang, translated_content, translated_display)
)
db.commit()
except Exception:
pass
return {"content": translated_content, "display_text": translated_display}
@app.route('/api/chat', methods=['POST'])
def api_chat():
"""Chat agent with read-only access to published submissions."""
@@ -4637,6 +4703,9 @@ def api_submit():
@app.route("/beitrag/<int:sub_id>")
def public_detail(sub_id: int):
lang = request.args.get("lang", "de")
if lang not in ("de", "en", "fr", "es"):
lang = "de"
"""Public detail view — full media, text, and metadata."""
import base64
db = get_db()
@@ -4709,7 +4778,10 @@ def public_detail(sub_id: int):
except Exception:
pass
content = (row["summary"] or row["content"] or "").strip()
# ── Mehrsprachigkeit: Übersetzung laden/cachen ──
translation = get_translated_content(db, sub_id, lang, row)
content = translation["content"]
display_text = translation["display_text"]
# Comments
comment_rows = db.execute(
@@ -4835,11 +4907,26 @@ def public_detail(sub_id: int):
.detail-like-btn:hover {{ opacity:0.8; }}
.detail-like-btn.liked {{ background:none; }}
.detail-like-btn.liked svg, .detail-like-btn.liked svg path {{ stroke:#EF4444 !important; fill:#EF4444 !important; }}
.detail-like-btn:active {{ transform:scale(0.94); }}</style>
.detail-like-btn:active {{ transform:scale(0.94); }}
.lang-switcher {{ display:flex; gap:0.3rem; margin-right:auto; }}
.lang-btn {{ text-decoration:none; font-size:1rem; padding:0.2rem 0.35rem; border-radius:6px; transition:all 0.15s; opacity:0.5; line-height:1; }}
.lang-btn:hover {{ opacity:0.8; background:rgba(255,255,255,0.1); }}
.lang-btn.active {{ opacity:1; background:rgba(32,34,138,0.3); }}
@media(max-width:600px) {{
.lang-switcher {{ gap:0.15rem; }}
.lang-btn {{ font-size:0.85rem; padding:0.15rem 0.25rem; }}
}}
</style>
<link rel="stylesheet" href="/static/theme.css">
</head>
<body>
<header class="site-header">
<div class="lang-switcher">
<a href="?lang=de" class="lang-btn {'active' if lang == 'de' else ''}">🇩🇪</a>
<a href="?lang=en" class="lang-btn {'active' if lang == 'en' else ''}">🇬🇧</a>
<a href="?lang=fr" class="lang-btn {'active' if lang == 'fr' else ''}">🇫🇷</a>
<a href="?lang=es" class="lang-btn {'active' if lang == 'es' else ''}">🇪🇸</a>
</div>
<button id="navToggle" class="nav-toggle" aria-label="Menü öffnen"></button>
<a href="/" class="site-logo">
<img src="/static/logo.png" alt="Brettspiel News" style="height:38px;width:auto;display:block;" width="160" height="30">