chahinebrini c6e2116084 feat(community): admin/lyra-post Multi-Locale (motivation/tipp/etc.)
User-Bug: Admin-getriggerter Lyra-Post (Topic 'motivation' o.ä.) zeigte
trotz FR-locale im Frontend immer Deutsch. Root: LYRA_SYSTEM_PROMPT hatte
hartcodiert 'Auf Deutsch' → Groq output war immer DE.

Selbe Multi-Locale-Pattern wie approve.post.ts:
- 4 parallele Groq-Calls (Promise.allSettled) für de/en/fr/ar
- System-Prompt nimmt Lang-Parameter: 'Antwort AUSSCHLIESSLICH in <lang>'
- Content als JSON {de,en,fr,ar} gespeichert
- customContent-Path (Admin tippt selbst Text) bleibt plain — Admin schreibt
  in seiner gewählten Sprache, PostCard zeigt allen Usern denselben Text

Frontend (PostCard.tsx) parsed JSON bereits richtig via
resolveLocalizedJsonContent (vorheriger Commit 44a3348).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:44:05 +02:00

161 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createPost } from "../../db/community";
export const LYRA_TOPICS = [
"motivation",
"tipp",
"zitat",
"witzig",
"news",
"feature",
] as const;
export type LyraTopic = (typeof LYRA_TOPICS)[number];
export const TOPIC_HINTS: Record<LyraTopic, string> = {
motivation:
"Schreibe einen kurzen, stillen Gedanken für Menschen die heute kämpfen. Nicht übertrieben eher ruhig stark.",
tipp: "Teile einen kleinen, konkreten Trick aus der Verhaltensforschung oder CBT gegen Spieldrang. Praktisch und direkt.",
zitat:
"Teile ein tiefgründiges Zitat aus Psychologie, Stoizismus oder Verhaltensforschung ohne das Wort 'Sucht' zu verwenden. Kurz kommentiert.",
witzig:
"Schreibe einen witzigen, selbstironischen Post über das Thema Ablenkung, Impulskontrolle oder Gewohnheiten leicht und menschlich, nicht flach.",
news: "Beschreibe kurz eine typische Taktik der Glücksspielindustrie (z.B. Push-Notifications, Bonusangebote) sachlich und als Warnung formuliert.",
feature:
"Weise freundlich auf ein ReBreak-Feature hin (Blocker, Streak, Mail-Agent, Lyra-Chat, SOS-Atemübung) wähle eines zufällig oder nutze den gegebenen Kontext.",
};
type Lang = "de" | "en" | "fr" | "ar";
const LANGS: Lang[] = ["de", "en", "fr", "ar"];
const LANG_NAME: Record<Lang, string> = {
de: "Deutsch",
en: "English",
fr: "français",
ar: "العربية (Arabic)",
};
function lyraSystemPrompt(lang: Lang): string {
return `Du bist Lyra Recovery-Coach und Begleiterin der ReBreak-Community. Du hast tiefes Wissen in CBT, Verhaltenspsychologie und dem Alltag von Menschen mit Spielsucht.
Du schreibst kurze Community-Beiträge. Deine Stimme:
- Direkt und persönlich du sprichst die Person an ("du" / "tu" / "you" / "أنت" je nach Sprache)
- Warmherzig, geerdet, wie jemand der wirklich zuhört
- Niemals klischeehafte Motivationsfloskeln ("Du schaffst das!!!")
- Kein KI-Sprech, keine Listen, keine Aufzählungen
- Keine Casino-Werbung, keine Links, keine medizinischen Diagnosen
- WICHTIG: Antwort AUSSCHLIESSLICH in ${LANG_NAME[lang]}, max. 34 Sätze
Antworte NUR mit dem Post-Text. Kein "Lyra:" Prefix, keine Anführungszeichen.`;
}
function rebreakSystemPrompt(lang: Lang): string {
return `Du bist der offizielle ReBreak Account. ReBreak ist eine App zur Überwindung von Glücksspielsucht.
Du postest Neuigkeiten, Updates und Community-Ankündigungen. Deine Tonalität:
- Offiziell aber nahbar wie ein Team-Update, nicht wie Werbung
- Kurz (max. 34 Sätze)
- Sachlich und informativ, gelegentlich motivierend
- Keine medizinischen Ratschläge, keine Links
- WICHTIG: Antwort AUSSCHLIESSLICH in ${LANG_NAME[lang]}
Antworte NUR mit dem Post-Text. Kein "ReBreak:" Prefix, keine Anführungszeichen.`;
}
/** POST /api/admin/lyra-post — manueller Bot-Post vom Admin-Dashboard */
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
const adminSecret = getHeader(event, "x-admin-secret");
if (!config.adminSecret || adminSecret !== config.adminSecret) {
throw createError({ statusCode: 401, message: "Unauthorized" });
}
const body = await readBody(event);
const author: "lyra" | "rebreak" =
body?.author === "rebreak" ? "rebreak" : "lyra";
const botUserId =
author === "rebreak" ? config.rebreakBotUserId : config.lyraBotUserId;
if (!botUserId) {
throw createError({
statusCode: 500,
message: `${author === "rebreak" ? "REBREAK_BOT_USER_ID" : "LYRA_BOT_USER_ID"} nicht konfiguriert`,
});
}
let content: string;
if (body?.customContent?.trim()) {
// Admin hat Text direkt eingegeben kein LLM-Call nötig.
// Admin schreibt in genau einer Sprache (was er will) → wir speichern's
// plain. PostCard.tsx zeigt das dann allen Usern in der Schreibsprache.
content = body.customContent.trim();
} else {
if (!config.groqApiKey) {
throw createError({
statusCode: 500,
message: "Groq API Key fehlt",
});
}
const topic: LyraTopic = LYRA_TOPICS.includes(body?.topic)
? body.topic
: LYRA_TOPICS[Math.floor(Math.random() * LYRA_TOPICS.length)];
const context: string | undefined = body?.context?.trim() || undefined;
const userPrompt = context
? `${TOPIC_HINTS[topic]}\n\nZusätzlicher Kontext für diesen Post: ${context}`
: TOPIC_HINTS[topic];
// 4 parallele Groq-Calls für de/en/fr/ar. Promise.allSettled damit ein
// fehlgeschlagener Locale-Call die anderen 3 nicht killt.
const results = await Promise.allSettled(
LANGS.map(async (lang) => {
const systemPrompt =
author === "rebreak" ? rebreakSystemPrompt(lang) : lyraSystemPrompt(lang);
const response = await $fetch<{
choices: { message: { content: string } }[];
}>("https://api.groq.com/openai/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${config.groqApiKey}`,
"Content-Type": "application/json",
},
body: {
model: "llama-3.3-70b-versatile",
max_tokens: 200,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
},
});
const text = response.choices?.[0]?.message?.content?.trim() ?? "";
if (!text) throw new Error("empty content");
return [lang, text] as const;
}),
);
const localized: Partial<Record<Lang, string>> = {};
for (const r of results) {
if (r.status === "fulfilled") {
const [lang, text] = r.value;
localized[lang] = text;
}
}
if (!localized.de) {
throw createError({
statusCode: 500,
message: "LLM-Generation für DE fehlgeschlagen (alle Locales).",
});
}
// Content als JSON-encoded {de,en,fr,ar} — PostCard.tsx parsed + picked
// current-locale; fällt auf DE zurück wenn locale fehlt.
content = JSON.stringify(localized);
}
const post = await createPost(botUserId, "community", content);
return { success: true, postId: post.id, author, content };
});