feat(coach): tier-based LLM-Routing + COACH_CASUAL_SYSTEM_PROMPT

Coach-Page ist NICHT SOS — User ist nicht in Krise, will small-talk,
Reflexion, Feature-Wuensche, Philosophie. „Lockere Lyra".

Aenderungen:
- Neuer Prompt COACH_CASUAL_SYSTEM_PROMPT (exportiert): warm/neugierig/
  manchmal humorvoll, bis 4-5 Saetze, darf eigene Empfehlungen + Mini-
  Meinungen formulieren, lädt Feedback aktiv ein. Kein Crisis-Framing.
  Sprachregeln (keine Pathologisierung, kein „Sucht") gelten unveraendert.
- Tier-LLM-Routing analog zu sos-stream:
  Free/Pro = Groq llama-3.3-70b-versatile (Fallback llama-3.1-8b)
  Legend  = OpenRouter anthropic/claude-haiku-4.5 (Fallback claude-3.5-haiku)
- max_tokens 280→500 (Coach darf laenger antworten)
- Demographics-Injection (analog sos-stream): birthYear/gender/etc als
  USER-DEMOGRAPHIE-Block in Prompt (read-only, kein Extract)
- sosMode-Branch deprecated — Frontend kann den Param noch senden, wird
  ignoriert. Folge-TODO: UI-Agent entfernt sosMode aus Coach-Call.

NICHT geändert:
- TTS-Endpoints bleiben plan-agnostisch (Frontend routet by tier)
- sos-stream.get.ts/sos-stream.post.ts unberuehrt (importieren weiter
  COACH_SYSTEM_PROMPT, kein Breaking Change)

Memory:
- project_llm_per_plan.md (tier-LLM-Default-Logic)
- feedback_anonymity_nickname.md
- feedback_demographics_user_initiated.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-07 21:22:51 +02:00
parent 9ccd0bd334
commit 192f67cd07

View File

@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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 = [