Add LLM tutor API endpoints
This commit is contained in:
@@ -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.
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 }] }));
|
||||
}
|
||||
Reference in New Issue
Block a user