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