feat: Mehrsprachigkeit EN/FR/ES via Mistral + Sprachumschalter
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 11m29s
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:
+90
-3
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user