From a7ad1e9bbac7d5b3caa72f7ae56367ff3ad525aa Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 15 Jan 2026 19:39:38 -0700 Subject: [PATCH] System AI --- .env.example | 7 +- docker-compose.prod.yml | 1 + hooks/useGame.ts | 3 +- scripts/setup-prod.sh | 15 +-- server/package.json | 1 + server/src/index.ts | 2 + server/src/middleware/auth.ts | 20 ++++ server/src/routes/generate.ts | 180 ++++++++++++++++++++++++++++++++++ services/geminiService.ts | 52 ++++++++++ types.ts | 3 +- 10 files changed, 271 insertions(+), 13 deletions(-) create mode 100644 server/src/routes/generate.ts diff --git a/.env.example b/.env.example index 48dbc56..faf2832 100644 --- a/.env.example +++ b/.env.example @@ -44,9 +44,8 @@ AUTHENTIK_BOOTSTRAP_TOKEN= LOG_REQUESTS=false # ============================================================================== -# OPTIONAL - System AI (Gemini API Key for quiz generation) -# If set, users can generate quizzes without providing their own API key. +# OPTIONAL - System AI (Gemini API Key for server-side quiz generation) +# If set, users can generate quizzes using "System AI" without their own key. # Get a key at: https://aistudio.google.com/apikey -# WARNING: This key is embedded in the frontend and visible to users. # ============================================================================== -VITE_API_KEY= +GEMINI_API_KEY= diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1cfec27..a002a82 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -100,6 +100,7 @@ services: OIDC_INTERNAL_JWKS_URI: http://authentik-server:9000/application/o/kaboot/jwks/ CORS_ORIGIN: ${CORS_ORIGIN} LOG_REQUESTS: ${LOG_REQUESTS:-true} + GEMINI_API_KEY: ${GEMINI_API_KEY:-} volumes: - kaboot-data:/data networks: diff --git a/hooks/useGame.ts b/hooks/useGame.ts index 6ea27ec..cb2a0e8 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -615,7 +615,7 @@ export const useGame = () => { questionCount?: number; files?: File[]; useOcr?: boolean; - aiProvider?: 'gemini' | 'openrouter' | 'openai'; + aiProvider?: 'gemini' | 'openrouter' | 'openai' | 'system'; apiKey?: string; geminiModel?: string; openRouterModel?: string; @@ -642,6 +642,7 @@ export const useGame = () => { geminiModel: options.geminiModel, openRouterModel: options.openRouterModel, openAIModel: options.openAIModel, + accessToken: options.aiProvider === 'system' ? auth.user?.access_token : undefined, }; const generatedQuiz = await generateQuiz(generateOptions); diff --git a/scripts/setup-prod.sh b/scripts/setup-prod.sh index b70228f..0bcfb4c 100755 --- a/scripts/setup-prod.sh +++ b/scripts/setup-prod.sh @@ -156,8 +156,8 @@ CORS_ORIGIN=https://${KABOOT_DOMAIN} NODE_ENV=production LOG_REQUESTS=true -# System AI (optional - for quiz generation without user's own key) -VITE_API_KEY=${GEMINI_API_KEY} +# System AI (optional - server-side quiz generation) +GEMINI_API_KEY=${GEMINI_API_KEY} EOF print_success "Created .env" @@ -216,17 +216,18 @@ fi print_step "Building frontend with production URLs..." -BUILD_ENV="VITE_API_URL=https://${KABOOT_DOMAIN} VITE_AUTHENTIK_URL=https://${AUTH_DOMAIN} VITE_OIDC_CLIENT_ID=kaboot-spa VITE_OIDC_APP_SLUG=kaboot" +VITE_API_URL="https://${KABOOT_DOMAIN}" \ +VITE_AUTHENTIK_URL="https://${AUTH_DOMAIN}" \ +VITE_OIDC_CLIENT_ID="kaboot-spa" \ +VITE_OIDC_APP_SLUG="kaboot" \ +npm run build --silent 2>/dev/null || npm run build if [ -n "$GEMINI_API_KEY" ]; then - BUILD_ENV="$BUILD_ENV VITE_API_KEY=$GEMINI_API_KEY" - print_success "System AI enabled with provided Gemini key" + print_success "System AI will be available (key configured for backend)" else print_warning "No Gemini API key provided - users must configure their own" fi -eval "$BUILD_ENV npm run build --silent 2>/dev/null || $BUILD_ENV npm run build" - print_success "Frontend built" echo "" diff --git a/server/package.json b/server/package.json index 53307be..e8c603e 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/index.ts b/server/src/index.ts index 3a994b1..e77fd69 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index bbf6c14..0f003e1 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -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(); +} diff --git a/server/src/routes/generate.ts b/server/src/routes/generate.ts new file mode 100644 index 0000000..693196a --- /dev/null +++ b/server/src/routes/generate.ts @@ -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(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; diff --git a/services/geminiService.ts b/services/geminiService.ts index 87f8c40..a8794d4 100644 --- a/services/geminiService.ts +++ b/services/geminiService.ts @@ -406,9 +406,61 @@ async function generateQuizWithOpenAI(options: GenerateQuizOptions): Promise { + if (!options.accessToken) { + throw new Error("Authentication required for system AI"); + } + + const docs = options.documents || []; + const documentsPayload = docs.map(doc => ({ + type: doc.type, + content: doc.type === 'native' && doc.content instanceof ArrayBuffer + ? btoa(String.fromCharCode(...new Uint8Array(doc.content))) + : doc.content, + mimeType: doc.mimeType + })); + + const response = await fetch(`${API_URL}/api/generate`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${options.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + topic: options.topic, + questionCount: options.questionCount || 10, + documents: documentsPayload + }) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(error.error || `Server error: ${response.status}`); + } + + return response.json(); +} + +export async function checkSystemAIAvailable(): Promise { + try { + const response = await fetch(`${API_URL}/api/generate/status`); + if (!response.ok) return false; + const data = await response.json(); + return data.available === true; + } catch { + return false; + } +} + export const generateQuiz = async (options: GenerateQuizOptions): Promise => { const provider = options.aiProvider || 'gemini'; + if (provider === 'system') { + return generateQuizWithServer(options); + } + if (provider === 'openrouter') { return generateQuizWithOpenRouter(options); } diff --git a/types.ts b/types.ts index 414a38f..46064b1 100644 --- a/types.ts +++ b/types.ts @@ -36,7 +36,7 @@ export const COLOR_SCHEMES: ColorScheme[] = [ { id: 'rose', name: 'Rose', primary: '#e11d48', primaryDark: '#be123c', primaryDarker: '#5f1a2a' }, ]; -export type AIProvider = 'gemini' | 'openrouter' | 'openai'; +export type AIProvider = 'gemini' | 'openrouter' | 'openai' | 'system'; export interface UserPreferences { colorScheme: string; @@ -140,6 +140,7 @@ export interface GenerateQuizOptions { geminiModel?: string; openRouterModel?: string; openAIModel?: string; + accessToken?: string; } export interface PointsBreakdown {