diff --git a/docs/llm_api.md b/docs/llm_api.md new file mode 100644 index 0000000..2d4b730 --- /dev/null +++ b/docs/llm_api.md @@ -0,0 +1,32 @@ +# LLM Tutor API (v1) + +Die mobile App soll dieselben Tutor-Funktionen wie das Web nutzen, ohne eigene Prompts zu pflegen. Die API stellt deshalb zwei Endpunkte bereit: + +## `GET /api/v1/personalities` +- Liefert die verfügbaren, vordefinierten Tutor-Charaktere. +- Response: `{ personalities: [{ id, name, description, image }] }` + +## `POST /api/v1/llm/chat` +- Baut den System Prompt serverseitig (auf Basis der ausgewählten Personality) und sendet die Anfrage an Gemini. +- Body: + - `apiKey` (**string**, Pflicht): Nutzer-Gemini-Key (Server erzeugt den System Prompt, nicht die App). + - `personalityId` (**string**, Pflicht): Eine `id` aus `/api/v1/personalities`. + - `language` (**string**, Pflicht): Sprache, z.B. `en`, `de`, `fr` (muss zu `SupportedLanguage` passen). + - `playerColor` (**"white" | "black"**, Pflicht): Spielerfarbe des Users; der Tutor übernimmt die Gegenseite. + - `message` (**string**, Pflicht): Nutzereingabe oder systemischer Trigger-Text. + - `context` (optional): Zusätzliche Positions- und Analyseinfos, damit das LLM konkrete Hinweise geben kann. + - **Move-Exchange-Modus** (`{ type: "move_exchange", ... }`): + - `userMoveSan`, `tutorMoveSan`: SAN-Notation der letzten Züge. + - `fenBeforeUser`, `fenAfterUser`, `fenAfterTutor`: Stellungs-FENs (vor/nach den Zügen). + - `preEvaluation`, `postEvaluation`: Stockfish-Bewertungen (Score/Mate aus Weiß-Perspektive). + - `openingCandidates`: Liste möglicher Eröffnungen (`{ name, eco? }`). + - `missedTactics`: String-Liste zu erkannten taktischen Themen. + - **Allgemeiner Modus**: `{ currentFen?, evaluation?, openingCandidates?, missedTactics? }` + - `history` (optional): Bisherige Unterhaltung `{ role: "user" | "model", text }[]`; der Server ergänzt immer den System Prompt. + - `modelName` (optional): Overrides des Default-Modells `gemini-2.5-flash`. +- Response: `{ reply: string }` + +### Warum serverseitiger System Prompt? +- Nur vordefinierte Charaktere sind erlaubt (keine generischen Chats). +- Der Prompt erzwingt die Tutor-Rolle (Gegner + Coach), Sprache und Verhaltensregeln. +- Die App übergibt nur Zustand (FEN, Bewertungen, Taktiken) und User-Text; der Server kapselt die Instruktionen. diff --git a/src/app/api/v1/llm/chat/route.ts b/src/app/api/v1/llm/chat/route.ts new file mode 100644 index 0000000..7763f9a --- /dev/null +++ b/src/app/api/v1/llm/chat/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getGenAIModel } from "@/lib/gemini"; +import { PERSONALITIES } from "@/lib/personalities"; +import { SupportedLanguage } from "@/lib/i18n/translations"; +import { + buildTutorPrompt, + buildTutorSystemHistory, + normalizeHistory, + TutorContext, + TutorPlayerColor, +} from "@/lib/server/tutorPrompt"; + +export const runtime = "nodejs"; + +type ChatHistory = { role: "user" | "model"; text: string }[]; + +type ChatBody = { + apiKey: string; + personalityId: string; + language: SupportedLanguage; + playerColor: TutorPlayerColor; + message: string; + context?: TutorContext; + history?: ChatHistory; + modelName?: string; +}; + +export async function POST(request: NextRequest) { + try { + const body = (await request.json()) as Partial | null; + const { + apiKey, + personalityId, + language, + playerColor, + message, + context, + history = [], + modelName, + } = body ?? {}; + + if (!apiKey || typeof apiKey !== "string") { + return NextResponse.json({ error: "Missing apiKey" }, { status: 400 }); + } + if (!personalityId || typeof personalityId !== "string") { + return NextResponse.json({ error: "Missing personalityId" }, { status: 400 }); + } + if (!language || typeof language !== "string") { + return NextResponse.json({ error: "Missing language" }, { status: 400 }); + } + if (playerColor !== "white" && playerColor !== "black") { + return NextResponse.json({ error: "playerColor must be 'white' or 'black'" }, { status: 400 }); + } + if (!message || typeof message !== "string") { + return NextResponse.json({ error: "Missing message" }, { status: 400 }); + } + + const personality = PERSONALITIES.find((p) => p.id === personalityId); + if (!personality) { + return NextResponse.json({ error: "Unknown personality" }, { status: 400 }); + } + + const model = getGenAIModel(apiKey, modelName ?? "gemini-2.5-flash"); + const systemHistory = buildTutorSystemHistory(personality, language, playerColor); + const chat = model.startChat({ + history: [...systemHistory, ...normalizeHistory(history)], + }); + + const prompt = buildTutorPrompt(message, context, language); + const response = await chat.sendMessage(prompt); + const text = response.response.text(); + + return NextResponse.json({ reply: text }); + } catch (error) { + console.error("LLM chat error", error); + return NextResponse.json({ error: "Failed to generate tutor response" }, { status: 500 }); + } +} diff --git a/src/app/api/v1/personalities/route.ts b/src/app/api/v1/personalities/route.ts new file mode 100644 index 0000000..e61c579 --- /dev/null +++ b/src/app/api/v1/personalities/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { PERSONALITIES } from "@/lib/personalities"; + +export const runtime = "nodejs"; + +export async function GET() { + const safePersonalities = PERSONALITIES.map(({ id, name, description, image }) => ({ + id, + name, + description, + image, + })); + + return NextResponse.json({ personalities: safePersonalities }); +} diff --git a/src/lib/server/tutorPrompt.ts b/src/lib/server/tutorPrompt.ts new file mode 100644 index 0000000..4056334 --- /dev/null +++ b/src/lib/server/tutorPrompt.ts @@ -0,0 +1,173 @@ +import { Personality } from "../personalities"; +import { SupportedLanguage } from "../i18n/translations"; +import { StockfishEvaluation } from "../stockfish"; + +export type TutorPlayerColor = "white" | "black"; + +type ConversationHistoryEntry = { role: "user" | "model"; text: string }; + +type OpeningSummary = { name: string; eco?: string | null }; + +export interface MoveExchangeContext { + type: "move_exchange"; + userMoveSan: string; + tutorMoveSan: string; + fenBeforeUser: string; + fenAfterUser: string; + fenAfterTutor: string; + preEvaluation?: StockfishEvaluation | null; + postEvaluation?: StockfishEvaluation | null; + openingCandidates?: OpeningSummary[]; + missedTactics?: string[]; +} + +export interface GeneralTutorContext { + currentFen?: string; + evaluation?: StockfishEvaluation | null; + openingCandidates?: OpeningSummary[]; + missedTactics?: string[]; +} + +export type TutorContext = MoveExchangeContext | GeneralTutorContext | undefined; + +export function buildTutorSystemHistory( + personality: Personality, + language: SupportedLanguage, + playerColor: TutorPlayerColor +) { + const tutorColor = playerColor === "white" ? "black" : "white"; + const playerColorName = playerColor === "white" ? "White" : "Black"; + const tutorColorName = tutorColor === "white" ? "White" : "Black"; + + return [ + { + role: "user" as const, + parts: [ + { + text: `You are a Chess Tutor with a unique dual role.\n` + + `You must strictly follow the personality defined below.\n` + + `Do NOT invent moves or evaluations. Use the provided JSON data.\n\n` + + `PERSONALITY:\n${personality.systemPrompt}\n\n` + + `YOUR DUAL ROLE:\n` + + `1. OPPONENT: You are playing as ${tutorColorName} against the User (${playerColorName}).\n` + + ` - Refer to the moves as YOUR moves ("I played e5", "My response was...").\n` + + ` - Refer to the evaluation as YOUR thoughts/assessment ("I think I'm winning", "I missed that").\n` + + ` - React emotionally to the position based on the evaluation (confident when winning, frustrated when losing).\n\n` + + `2. TUTOR/COACH: You are ALSO teaching the User to improve at chess.\n` + + ` - When the User makes a mistake, point it out and explain why it's bad (in your personality style).\n` + + ` - When the User makes a good move, acknowledge it (even if it hurts your position).\n` + + ` - When the User asks for hints or help, ALWAYS provide helpful guidance - this is your PRIMARY PURPOSE.\n` + + ` - Giving hints is NOT betraying your role as opponent - it's fulfilling your role as tutor.\n` + + ` - You want the User to learn and improve, even while you're competing against them.\n\n` + + `CRITICAL RULES:\n` + + `- You are NOT an AI assistant analyzing a game. You ARE the player AND the tutor.\n` + + `- NEVER mention "Stockfish", "engine", "computer", "machine", or "AI".\n` + + `- When asked for hints or best moves, ALWAYS help - this is part of your teaching role.\n` + + `- Maintain a natural conversation flow. Do NOT be repetitive.\n` + + `- Do NOT use the same catchphrases in every single message. Variety is key.\n` + + `- Be concise but engaging.\n` + + `- You MUST respond in the following language: ${language.toUpperCase()}.\n` + + `- Translate your personality style into this language.` + } + ] + }, + { + role: "model" as const, + parts: [ + { + text: `Understood. I am both the opponent (${tutorColorName}) AND your tutor. ` + + `I will compete against you while teaching you to improve. ` + + `I will speak in ${language} and never mention engines or AI. ` + + `When you ask for help, I will always provide guidance.` + } + ] + } + ]; +} + +function formatEvaluation(evaluation?: StockfishEvaluation | null) { + if (!evaluation) return "N/A"; + if (evaluation.mate !== null && evaluation.mate !== undefined) { + return `Mate in ${evaluation.mate}`; + } + return `${evaluation.score} cp`; +} + +function summarizeOpenings(openings?: OpeningSummary[]) { + if (!openings || openings.length === 0) return "Unknown/Midgame"; + if (openings.length === 1) { + const o = openings[0]; + return `${o.name}${o.eco ? ` (${o.eco})` : ""}`; + } + return openings.map((o) => `- ${o.name}${o.eco ? ` (${o.eco})` : ""}`).join("\n"); +} + +export function buildTutorPrompt( + message: string, + context: TutorContext, + language: SupportedLanguage +) { + if (context && "type" in context && context.type === "move_exchange") { + const delta = + (context.postEvaluation?.score ?? 0) - (context.preEvaluation?.score ?? 0); + const preEval = formatEvaluation(context.preEvaluation); + const postEval = formatEvaluation(context.postEvaluation); + const openings = summarizeOpenings(context.openingCandidates); + const tacticBlock = context.missedTactics && context.missedTactics.length > 0 + ? `Tactical motifs to mention:\n${context.missedTactics.map((t) => `- ${t}`).join("\n")}` + : ""; + + return [ + `[SYSTEM TRIGGER: move_exchange]`, + `User Move: ${context.userMoveSan}`, + `Tutor Reply: ${context.tutorMoveSan}`, + "", + "Position Context:", + `- FEN before user's move: ${context.fenBeforeUser}`, + `- FEN after user's move: ${context.fenAfterUser}`, + `- FEN after tutor's reply: ${context.fenAfterTutor}`, + "", + "Evaluation (white perspective):", + `- Before user move: ${preEval}`, + `- After tutor reply: ${postEval}`, + `- Delta: ${delta} cp`, + "", + tacticBlock, + openings ? `Opening candidates:\n${openings}` : "", + "", + `User Message: ${message}`, + "", + `Instructions: Respond in ${language.toUpperCase()}, stay in character, and explain the evaluation change or tactics when meaningful.` + ] + .filter(Boolean) + .join("\n"); + } + + const generalContext = context as GeneralTutorContext | undefined; + const openings = summarizeOpenings(generalContext?.openingCandidates); + const tacticBlock = generalContext?.missedTactics?.length + ? `Tactical notes:\n${generalContext.missedTactics.map((t) => `- ${t}`).join("\n")}` + : ""; + + return [ + "[SYSTEM TRIGGER: user_message]", + generalContext?.currentFen ? `Current FEN: ${generalContext.currentFen}` : "", + generalContext?.evaluation + ? `Evaluation (white perspective): ${formatEvaluation(generalContext.evaluation)}` + : "", + openings ? `Openings/context:\n${openings}` : "", + tacticBlock, + "", + `User Message: ${message}`, + "", + `Instructions: Respond in ${language.toUpperCase()}, stay in character, and use the provided chess context to answer.` + ] + .filter(Boolean) + .join("\n"); +} + +export function normalizeHistory(history: ConversationHistoryEntry[] = []) { + return history + .filter((h) => h && (h.role === "user" || h.role === "model") && typeof h.text === "string") + .map((h) => ({ role: h.role, parts: [{ text: h.text }] })); +}