Add LLM tutor API endpoints

This commit is contained in:
stefan-kp
2025-12-04 10:30:23 +01:00
parent cab02cfefd
commit 32f93b9305
4 changed files with 298 additions and 0 deletions
+32
View File
@@ -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.
+78
View File
@@ -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<ChatBody> | 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 });
}
}
+15
View File
@@ -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 });
}
+173
View File
@@ -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 }] }));
}