chahinebrini 6a3c1e13da feat(lyra): admin DiGA-reminder post category
New 'erinnerung' topic for manual Lyra community posts that gently remind
users they can add optional, anonymous profile details. Wording stays
jargon-free (no 'DiGA'/'data'/'study'). Manual-only, not in the auto-cron
catalog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:11:01 +02:00

164 lines
6.5 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",
"erinnerung",
] 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.",
erinnerung:
"Erinnere die Community freundlich und ganz ohne Druck daran, dass sie im Profil ein paar freiwillige Angaben zu sich machen können (z.B. Geburtsjahr). Erkläre in einem Satz, dass das hilft, ReBreak weiterzuentwickeln und als ernsthafte Unterstützung für mehr Menschen zu etablieren. Betone deutlich: völlig freiwillig, bleibt anonym, jederzeit änderbar. Verwende NICHT die Wörter 'DiGA', 'Daten sammeln', 'Studie' oder 'Statistik'.",
};
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 };
});