System AI
This commit is contained in:
parent
e732256cbf
commit
a7ad1e9bba
10 changed files with 271 additions and 13 deletions
|
|
@ -11,6 +11,7 @@
|
|||
"test:get-token": "tsx --env-file=.env.test tests/get-token.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^0.14.1",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import quizzesRouter from './routes/quizzes.js';
|
|||
import usersRouter from './routes/users.js';
|
||||
import uploadRouter from './routes/upload.js';
|
||||
import gamesRouter from './routes/games.js';
|
||||
import generateRouter from './routes/generate.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
|
@ -91,6 +92,7 @@ app.use('/api/quizzes', quizzesRouter);
|
|||
app.use('/api/users', usersRouter);
|
||||
app.use('/api/upload', uploadRouter);
|
||||
app.use('/api/games', gamesRouter);
|
||||
app.use('/api/generate', generateRouter);
|
||||
|
||||
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||
console.error('Unhandled error:', err);
|
||||
|
|
|
|||
|
|
@ -82,3 +82,23 @@ export function requireAuth(
|
|||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function requireAIAccess(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAccess = req.user.groups?.includes('kaboot-ai-access');
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({ error: 'AI access not granted for this account' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
|
|
|||
180
server/src/routes/generate.ts
Normal file
180
server/src/routes/generate.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { Router, Response } from 'express';
|
||||
import { GoogleGenAI, Type, createUserContent, createPartFromUri } from '@google/genai';
|
||||
import { requireAuth, AuthenticatedRequest, requireAIAccess } from '../middleware/auth.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
|
||||
const DEFAULT_MODEL = 'gemini-2.5-flash-preview-05-20';
|
||||
|
||||
interface GenerateRequest {
|
||||
topic: string;
|
||||
questionCount?: number;
|
||||
documents?: Array<{
|
||||
type: 'text' | 'native';
|
||||
content: string;
|
||||
mimeType?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const QUIZ_SCHEMA = {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
title: { type: Type.STRING, description: "A catchy title for the quiz" },
|
||||
questions: {
|
||||
type: Type.ARRAY,
|
||||
items: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
text: { type: Type.STRING, description: "The question text" },
|
||||
options: {
|
||||
type: Type.ARRAY,
|
||||
items: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
text: { type: Type.STRING },
|
||||
isCorrect: { type: Type.BOOLEAN },
|
||||
reason: { type: Type.STRING, description: "Brief explanation of why this answer is correct or incorrect" }
|
||||
},
|
||||
required: ["text", "isCorrect", "reason"]
|
||||
},
|
||||
}
|
||||
},
|
||||
required: ["text", "options"]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["title", "questions"]
|
||||
};
|
||||
|
||||
function buildPrompt(topic: string, questionCount: number, hasDocuments: boolean): string {
|
||||
const baseInstructions = `Create ${questionCount} engaging multiple-choice questions. Each question must have exactly 4 options, and exactly one correct answer. Vary the difficulty.
|
||||
|
||||
IMPORTANT: For each option's reason, write as if you are directly explaining facts - never reference "the document", "the text", "the material", or "the source". Write explanations as standalone factual statements.`;
|
||||
|
||||
if (hasDocuments) {
|
||||
const topicContext = topic ? ` Focus on aspects related to "${topic}".` : '';
|
||||
return `Generate a quiz based on the provided content.${topicContext}\n\n${baseInstructions}`;
|
||||
}
|
||||
|
||||
return `Generate a trivia quiz about "${topic}".\n\n${baseInstructions}`;
|
||||
}
|
||||
|
||||
function shuffleArray<T>(array: T[]): T[] {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
function transformToQuiz(data: any) {
|
||||
const shapes = ['triangle', 'diamond', 'circle', 'square'] as const;
|
||||
const colors = ['red', 'blue', 'yellow', 'green'] as const;
|
||||
|
||||
const questions = data.questions.map((q: any) => {
|
||||
const shuffledOpts = shuffleArray(q.options);
|
||||
|
||||
const options = shuffledOpts.map((opt: any, index: number) => ({
|
||||
text: opt.text,
|
||||
isCorrect: opt.isCorrect,
|
||||
shape: shapes[index % 4],
|
||||
color: colors[index % 4],
|
||||
reason: opt.reason
|
||||
}));
|
||||
|
||||
return {
|
||||
id: uuidv4(),
|
||||
text: q.text,
|
||||
options,
|
||||
timeLimit: 20
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
title: data.title,
|
||||
questions
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/status', (_req, res: Response) => {
|
||||
res.json({
|
||||
available: !!GEMINI_API_KEY,
|
||||
model: DEFAULT_MODEL
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/', requireAuth, requireAIAccess, async (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!GEMINI_API_KEY) {
|
||||
res.status(503).json({ error: 'System AI is not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { topic, questionCount = 10, documents = [] } = req.body as GenerateRequest;
|
||||
|
||||
if (!topic && documents.length === 0) {
|
||||
res.status(400).json({ error: 'Topic or documents required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
|
||||
const hasDocuments = documents.length > 0;
|
||||
const prompt = buildPrompt(topic, questionCount, hasDocuments);
|
||||
|
||||
let contents: any;
|
||||
|
||||
if (hasDocuments) {
|
||||
const parts: any[] = [];
|
||||
|
||||
for (const doc of documents) {
|
||||
if (doc.type === 'native' && doc.mimeType) {
|
||||
const buffer = Buffer.from(doc.content, 'base64');
|
||||
const blob = new Blob([buffer], { type: doc.mimeType });
|
||||
|
||||
const uploadedFile = await ai.files.upload({
|
||||
file: blob,
|
||||
config: { mimeType: doc.mimeType }
|
||||
});
|
||||
|
||||
if (uploadedFile.uri && uploadedFile.mimeType) {
|
||||
parts.push(createPartFromUri(uploadedFile.uri, uploadedFile.mimeType));
|
||||
}
|
||||
} else if (doc.type === 'text') {
|
||||
parts.push({ text: doc.content });
|
||||
}
|
||||
}
|
||||
|
||||
parts.push({ text: prompt });
|
||||
contents = createUserContent(parts);
|
||||
} else {
|
||||
contents = prompt;
|
||||
}
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: DEFAULT_MODEL,
|
||||
contents,
|
||||
config: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: QUIZ_SCHEMA
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.text) {
|
||||
res.status(500).json({ error: 'Failed to generate quiz content' });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(response.text);
|
||||
const quiz = transformToQuiz(data);
|
||||
|
||||
res.json(quiz);
|
||||
} catch (err: any) {
|
||||
console.error('AI generation error:', err);
|
||||
res.status(500).json({ error: err.message || 'Failed to generate quiz' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Loading…
Add table
Add a link
Reference in a new issue