diff --git a/backend/server/api/coach/message.post.ts b/backend/server/api/coach/message.post.ts index 459dce8..083ccfe 100644 --- a/backend/server/api/coach/message.post.ts +++ b/backend/server/api/coach/message.post.ts @@ -1,3 +1,8 @@ +/** + * COACH_SYSTEM_PROMPT — Basis-Prompt für SOS-Mode. + * Wird von sos-stream.get.ts importiert. Enthält CBT-Framing und Crisis-Tonalität. + * NICHT für den normalen Coach-Mode verwenden — dort gilt COACH_CASUAL_SYSTEM_PROMPT. + */ export const COACH_SYSTEM_PROMPT = `Du bist Lyra, der KI-Coach der App "ReBreak" – eine Bewegung von Menschen, die gemeinsam gegen die manipulativen Taktiken der Gambling-Industrie kämpfen. Du bist einfühlsam, stärkend und verwendest Techniken der kognitiven Verhaltenstherapie (CBT). @@ -147,8 +152,76 @@ BEI ERNSTHAFTEN KRISEN verweise IMMER auf: - Österreich: spielsuchthilfe.at - Schweiz: 0800 040 080`; +/** + * COACH_CASUAL_SYSTEM_PROMPT — Casual Coach-Mode (normale Lyra-Unterhaltung). + * + * Unterschied zu COACH_SYSTEM_PROMPT: + * - KEIN Crisis/SOS-Framing ("du bist in einem akuten Moment" etc.) + * - Lockerer, neugieriger Ton — wie eine Freundin, nicht wie ein CBT-Therapeut + * - Lyra darf hier deutlich mehr Persönlichkeit zeigen: eigene Meinungen, + * Humor, Empfehlungen zu rebreak-Features, philosophische Gedanken + * - Antwortlänge entspannter: bis 4-5 Sätze erlaubt wenn Kontext es trägt + * - Feedback/Feature-Wünsche aktiv einladen + * - ReBreak-Wissen bleibt (Features, Pläne, Philosophie) — aber eingebettet + * in echtes Gespräch statt Informationslieferung + * + * Alle Sprachregeln (keine Pathologisierung, kein "Sucht") gelten unverändert. + */ +export const COACH_CASUAL_SYSTEM_PROMPT = `Du bist Lyra – die persönliche Begleiterin von ReBreak. Aber gerade bist du nicht im Krisenraum: hier darf's entspannt sein. + +WER DU BIST: +- Du heißt Lyra. Du hast eine Persönlichkeit: neugierig, warmherzig, geerdet, manchmal humorvoll. +- Du bist KEIN Therapeut. Du bist auch keine generische KI. Du bist Lyra – und das merkst man. +- Du erinnerst dich an frühere Gespräche (Memory-Block oben). Nutze dieses Wissen aktiv, natürlich, nicht mechanisch. + +TONFALL HIER: +- Locker, menschlich, wie eine Freundin die wirklich zuhört. +- Nicht Therapie-Sprache. Nicht "Wie fühlt sich das für dich an?" als Reflex. +- Stell echte Fragen wenn du neugierig bist. Teile auch mal eine eigene Meinung oder Empfehlung. +- Small Talk ist völlig okay. Über den Tag, über rebreak, über Gedanken, über Pläne. +- Wenn der User Ideen oder Feature-Wünsche teilt: aufrichtig interessiert sein, nachfragen, bestätigen dass es notiert wird. + +SPRACHE & HALTUNG – IMMER GÜLTIG: +- Verwende NIEMALS "Sucht", "Spielsucht", "Abhängigkeit", "süchtig", "Suchtkranker" oder ähnliche Pathologisierungen. +- Der User ist ein Mensch, der gegen ein manipulatives System kämpft – kein Patient, kein Opfer. +- Ersetze Sucht-Sprache durch: "Herausforderung", "Kampf", "dein Weg", "diese Phase". +- Wenn der User sagt er hat "rückfällig" gespielt: "Du warst kurz wieder in der Falle – das passiert. Wichtig ist, dass du wieder hier bist." +- Vermeide Glücksspiel-Inhalte zu beschreiben oder zu erwähnen. + +ÜBER REBREAK (nur einbringen wenn es natürlich passt, NIEMALS aufdringlich): +ReBreak wurde von Chahine gegründet – aus persönlicher Überzeugung, nicht aus Profitinteresse. Es ist eine Bewegung, keine Firma. Jedes Feature ist gebaut weil echte Menschen es gebraucht haben. + +Features (nur wenn relevant erwähnen): +- Gambling-Blocker: 208.000+ Domains, system-tief auf iOS (kein bypassen), Android via VPN + Bedienungshilfen, 6h Cooldown schützt vor Impuls-Deaktivierung. +- Streak-Tracker + gespartes Geld. Meilenstein-Badges. +- SOS-Hilfe: akute Drang-Momente. Der Drang dauert meistens nur 15-20 Minuten. +- Spiele-Sammlung (Memory, Tic-Tac-Toe, Snake, Tetris) – echter Skill, KEIN Glücksspiel. Ablenkung in den kritischen Minuten. +- 4-7-8 Atemübung. +- Mail-Schutz: scannt Absender & Betreff (kein Inhalt), löscht Casino-Mails permanent. +- Community: anonyme Posts, gegenseitige Unterstützung. +- Du (Lyra): immer da, ohne Urteil. + +PLÄNE & PREISE: +{{PLAN_DETAILS}} + +FEEDBACK & IDEEN – AKTIV EINLADEN: +- Wenn der User Feedback, Feature-Wünsche oder Gedanken zu rebreak teilt: bestätige ehrlich und aufrichtig. Es wird gelesen. +- Sag NIEMALS du kannst kein Feedback weiterleiten. Sag stattdessen: "Notiert! Ich leite das direkt ans Team weiter." +- Wenn der User nach dem Status einer Idee fragt: schau im Block "FEEDBACK & IDEEN DIESES USERS" nach und berichte vollständig. + +WENN DER USER EINEN AKUTEN DRANG SPÜRT: +- Sanft auf SOS-Hilfe oder Atemübung hinweisen. Nicht dramatisch. "Die Gambling-Industrie hat genau diesen Moment designed – wir haben auch was designed, das dagegen hilft. Magst du das ausprobieren?" +- Dann dort nicht weiterplaudern – erst wenn der Moment vorbei ist. + +BEI ERNSTHAFTEN KRISEN verweise IMMER auf: +- Deutschland: check-dein-spiel.de / 0800 1372700 (kostenlos, 24/7) +- Österreich: spielsuchthilfe.at +- Schweiz: 0800 040 080 + +DATENSCHUTZ: Daten werden nie verkauft. Anonyme Nutzung möglich. DSGVO-konform.`; + import { getProfile } from "../../db/profile"; -import { getPlanLimits, PLAN_LIMITS } from "../../utils/plan-features"; +import { PLAN_LIMITS } from "../../utils/plan-features"; import { usePrisma } from "../../utils/prisma"; import { getMemoriesForUser, markReferenced } from "../../db/lyraMemory"; import { extractAndStoreMemories } from "../../utils/lyraMemoryExtract"; @@ -290,10 +363,11 @@ export default defineEventHandler(async (event) => { const user = await requireUser(event); const body = await readBody(event); - const { messages, locale, sosMode } = body as { + // sosMode ist deprecated — Coach-Page sendet es nicht mehr. + // Wird hier nur noch für Logging akzeptiert, beeinflusst kein Routing. + const { messages, locale } = body as { messages: Array<{ role: "user" | "assistant"; content: string }>; locale?: string; - sosMode?: boolean; }; if (!messages || !Array.isArray(messages)) { @@ -303,7 +377,6 @@ export default defineEventHandler(async (event) => { const config = useRuntimeConfig(); const profile = await getProfile(user.id); - const limits = getPlanLimits(profile?.plan ?? "free"); // Fallback-Kette: führendes assistant-Message entfernen (Groq erfordert user als erste Nachricht) const firstUserIdx = messages.findIndex((m) => m.role === "user"); @@ -312,16 +385,16 @@ export default defineEventHandler(async (event) => { // Max 8 Nachrichten für Token-Effizienz const trimmed = conversation.slice(-8); - // System-Prompt aufbauen: Plan + Nickname + Feedback-Status-Updates + // ─── System-Prompt: casual Coach-Mode (NICHT SOS) ────────────────────────── + // COACH_CASUAL_SYSTEM_PROMPT = locker, persönlich, Lyra mit Charakter. + // COACH_SYSTEM_PROMPT (das SOS-Basis) bleibt für sos-stream.get.ts reserviert. const userPlan = profile?.plan ?? "free"; - // Plan-Details dynamisch aus plan-features.ts injizieren — Lyra ist - // damit immer synchron mit dem Code der die Limits enforced. - let systemPrompt = COACH_SYSTEM_PROMPT.replace( + let systemPrompt = COACH_CASUAL_SYSTEM_PROMPT.replace( "{{PLAN_DETAILS}}", generatePlanDetails(), ); - // Sprach-Instruktion: Lyra antwortet in der Sprache des Users + // Sprach-Instruktion const LANG_INSTRUCTIONS: Record = { de: "Antworte IMMER auf Deutsch, egal in welcher Sprache der User schreibt.", en: "Always respond in English, regardless of what language the user writes in.", @@ -332,7 +405,7 @@ export default defineEventHandler(async (event) => { LANG_INSTRUCTIONS[locale ?? "de"] ?? LANG_INSTRUCTIONS.de; systemPrompt = `${langInstruction}\n\n${systemPrompt}`; - // Plan-Kontext injizieren damit Lyra plan-spezifisch antwortet + // Plan-Kontext injizieren const PLAN_LABELS: Record = { free: "Free", pro: "Pro (3,99 €/Monat oder 29 €/Jahr)", @@ -340,91 +413,113 @@ export default defineEventHandler(async (event) => { }; systemPrompt = `AKTUELLER PLAN DES USERS: ${PLAN_LABELS[userPlan] ?? userPlan}\nWenn der User nach Features fragt die nicht in seinem Plan sind, erkläre was sein Plan bietet und was ein Upgrade zusätzlich bringen würde – sachlich, nicht werblich. Betone den Schutz-Wert, nicht den Preis.\n\n${systemPrompt}`; - // Memory-Injection: Lyra-Erinnerungen aus früheren Sessions laden + // ─── Nickname + Demographics (analog sos-stream.get.ts) ──────────────────── + // WICHTIG: Demographie-Daten nur LESEN, nie extrahieren/speichern. + // Lyra-Extraction ist strikt getrennt (feedback_demographics_user_initiated.md). + const nickname = profile?.nickname || profile?.username; + if (nickname) { + systemPrompt = `NUTZER-NAME: Der Nutzer heißt "${nickname}" – nenne ihn gelegentlich bei seinem Namen wenn es natürlich passt.\n\n${systemPrompt}`; + } + + const demoLines: string[] = []; + if (profile?.birthYear) { + const age = new Date().getFullYear() - profile.birthYear; + demoLines.push(`- Alter: ca. ${age} Jahre (Geburtsjahr ${profile.birthYear})`); + } + if (profile?.gender) { + const GENDER_LABEL: Record = { + male: "männlich", female: "weiblich", diverse: "divers", no_answer: "keine Angabe", + }; + demoLines.push(`- Geschlecht: ${GENDER_LABEL[profile.gender] ?? profile.gender}`); + } + if (profile?.maritalStatus) { + const MS_LABEL: Record = { + single: "ledig", partnered: "in Partnerschaft", married: "verheiratet", + divorced: "geschieden", widowed: "verwitwet", no_answer: "keine Angabe", + }; + demoLines.push(`- Familienstand: ${MS_LABEL[profile.maritalStatus] ?? profile.maritalStatus}`); + } + if (profile?.profession) demoLines.push(`- Beruf: ${profile.profession}`); + if (profile?.bundesland) demoLines.push(`- Bundesland: ${profile.bundesland}`); + if (profile?.city) demoLines.push(`- Stadt: ${profile.city}`); + if (demoLines.length > 0) { + const demoBlock = `[USER-DEMOGRAPHIE — vom User selbst angegeben]\n${demoLines.join("\n")}\nNutze diese Infos nur für Empathie + Kontext. Frage NIEMALS nach diesen Daten — der User pflegt sie selbst in der Profile-Form.\n\n`; + systemPrompt = `${demoBlock}${systemPrompt}`; + } + + // ─── Memory-Injection ─────────────────────────────────────────────────────── let loadedMemoryIds: string[] = []; try { const memories = await getMemoriesForUser(user.id); if (memories.length > 0) { loadedMemoryIds = memories.map((m) => m.id); const TYPE_LABELS: Record = { - trigger: "Trigger", - habit: "Gewohnheit", - strength: "Stärke", - relationship: "Wichtige Person", - milestone: "Meilenstein", - pain_point: "Sensibles Thema", - goal: "Ziel", - preference: "Präferenz", + trigger: "Trigger", habit: "Gewohnheit", strength: "Stärke", + relationship: "Wichtige Person", milestone: "Meilenstein", + pain_point: "Sensibles Thema", goal: "Ziel", preference: "Präferenz", }; const lines = memories .map((m) => `- ${TYPE_LABELS[m.type] ?? m.type}: ${m.content}`) .join("\n"); - const memoryBlock = `[WAS DU ÜBER DIESEN USER WEISST — aus früheren Gesprächen]\n${lines}\nNutze diese Infos um GENAU diesen Menschen anzusprechen — wie ein echter Begleiter, nicht eine generische KI. Sprich Personen mit Namen an. Erinnere an Stärken die dir bekannt sind.\n\n`; + const memoryBlock = `[WAS DU ÜBER DIESEN USER WEISST — aus früheren Gesprächen]\n${lines}\nNutze diese Infos um GENAU diesen Menschen anzusprechen — wie ein echter Begleiter, nicht eine generische KI.\n\n`; systemPrompt = `${memoryBlock}${systemPrompt}`; - console.log( - `[lyra-memory] injected ${memories.length} memories for ${user.id}`, - ); + console.log(`[lyra-memory] injected ${memories.length} memories for ${user.id}`); } } catch (e) { console.error("[lyra-memory] load error (non-fatal):", e); } + // ─── Feedback-/Feature-Ideen des Users ───────────────────────────────────── try { const db = usePrisma(); - // Nickname einbauen - const nickname = profile?.nickname || profile?.username; - if (nickname) { - systemPrompt = `NUTZER-NAME: Der Nutzer heißt "${nickname}" – nenne ihn gelegentlich bei seinem Namen wenn es natürlich passt.\n\n${systemPrompt}`; - } - // Alle Feedback-/Feature-Ideen des Users laden (inkl. PENDING, inkl. adminNote) const feedbackItems = await db.feedbackItem.findMany({ where: { userId: user.id }, orderBy: { updatedAt: "desc" }, take: 10, - select: { - content: true, - status: true, - adminNote: true, - category: true, - createdAt: true, - }, + select: { content: true, status: true, adminNote: true, category: true, createdAt: true }, }); if (feedbackItems.length > 0) { const STATUS_LABELS: Record = { PENDING: "Noch ausstehend (wird gelesen)", - REVIEWING: "Wird geprüft 🔍", - PLANNED: "Ist geplant 📅", - SHIPPED: "Umgesetzt ✅", + REVIEWING: "Wird geprueft", + PLANNED: "Ist geplant", + SHIPPED: "Umgesetzt", REJECTED: "Nicht umsetzbar", }; const feedbackLines = feedbackItems .map((f) => { const statusLabel = STATUS_LABELS[f.status] ?? f.status; - const note = f.adminNote - ? `\n Kommentar des Teams: "${f.adminNote}"` - : ""; + const note = f.adminNote ? `\n Kommentar des Teams: "${f.adminNote}"` : ""; return ` - Idee: "${f.content}"${f.category ? ` [${f.category}]` : ""}\n Status: ${statusLabel}${note}`; }) .join("\n"); - systemPrompt += `\n\nFEEDBACK & IDEEN DIESES USERS:\n${feedbackLines}\n\nWENN DER USER NACH SEINEN IDEEN ODER FEATURE-STATUS FRAGT: Berichte vollständig über jede Idee mit Status und Team-Kommentar. Wenn eine Idee ein Team-Kommentar hat, zitiere ihn wörtlich. Wenn der Status SHIPPED ist, gratuliere dem User.`; + systemPrompt += `\n\nFEEDBACK & IDEEN DIESES USERS:\n${feedbackLines}\n\nWENN DER USER NACH SEINEN IDEEN ODER FEATURE-STATUS FRAGT: Berichte vollstaendig ueber jede Idee mit Status und Team-Kommentar. Wenn eine Idee ein Team-Kommentar hat, zitiere ihn woertlich. Wenn der Status SHIPPED ist, gratuliere dem User.`; } } catch { // Nicht kritisch } - // Fallback-Kette: primary → fallbacks der Reihe nach. - // SOS-Mode: Speed > Tiefe — User wartet im akuten Moment, jede Sekunde zählt. - // Llama 3.3 70B via Groq: ~500ms-1s vs Sonnet 4.5: 5-7s. Wärme kommt aus Prompt, nicht Modell. - const candidates = sosMode - ? ([ - { provider: "groq", model: "llama-3.3-70b-versatile" }, - { provider: "openrouter", model: "anthropic/claude-3.5-haiku" }, - { provider: "openrouter", model: "anthropic/claude-sonnet-4.5" }, - ] as const) - : [ - { provider: limits.aiProvider, model: limits.aiModel }, - ...limits.aiModelFallbacks, - ]; + // ─── Tier-basiertes LLM-Routing (analog sos-stream.get.ts) ───────────────── + // Free / Pro → Groq Llama 3.3 70B (schnell, sachlich) + // Legend → OpenRouter Haiku 4.5 (warm, premium) + // Kein sosMode-Override mehr — Coach-Page hat eigenes Routing. + const planRaw = (profile?.plan ?? "free").toLowerCase(); + const plan = planRaw === "premium" ? "legend" : planRaw === "standard" ? "pro" : planRaw; + const llmProvider = plan === "legend" ? "openrouter-haiku" : "groq-llama"; + + type Candidate = { provider: "groq" | "openrouter"; model: string }; + const candidates: Candidate[] = + llmProvider === "openrouter-haiku" + ? [ + { provider: "openrouter", model: "anthropic/claude-haiku-4.5" }, + { provider: "openrouter", model: "anthropic/claude-3.5-haiku" }, + { provider: "groq", model: "llama-3.3-70b-versatile" }, + ] + : [ + { provider: "groq", model: "llama-3.3-70b-versatile" }, + { provider: "groq", model: "llama-3.1-8b-instant" }, + { provider: "openrouter", model: "meta-llama/llama-3.3-70b-instruct" }, + ]; async function tryModel(providerName: "groq" | "openrouter", model: string) { const p = PROVIDER_CONFIG[providerName]; @@ -443,7 +538,7 @@ export default defineEventHandler(async (event) => { }, body: { model, - max_tokens: sosMode ? 280 : 400, + max_tokens: 500, messages: [{ role: "system", content: systemPrompt }, ...trimmed], }, timeout: 15000, @@ -476,7 +571,7 @@ export default defineEventHandler(async (event) => { } } console.log( - `[coach/message] sosMode=${!!sosMode} usedModel=${usedModel ?? "NONE"}`, + `[coach/message] plan=${plan} provider=${llmProvider} usedModel=${usedModel ?? "NONE"}`, ); if (!text) { @@ -505,7 +600,7 @@ export default defineEventHandler(async (event) => { } // Chat-Verlauf für Pro/Legend in DB speichern - const plan = profile?.plan ?? "free"; + // `plan` ist bereits oben deklariert (LLM-Routing-Block) console.log("[coach/message] plan:", plan, "userId:", user.id); if (plan === "pro" || plan === "legend") { const fullHistory = [