import { GoogleGenAI, Type, createUserContent, createPartFromUri } from "@google/genai"; import { Quiz, Question, AnswerOption, GenerateQuizOptions, ProcessedDocument, AIProvider } from "../types"; import { v4 as uuidv4 } from 'uuid'; const getGeminiClient = (apiKey?: string) => { const key = apiKey || process.env.API_KEY; if (!key) { throw new Error("Gemini API key is missing"); } return new GoogleGenAI({ apiKey: key }); }; const DEFAULT_GEMINI_MODEL = 'gemini-3-flash-preview'; const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; const DEFAULT_OPENROUTER_MODEL = 'google/gemini-3-flash-preview'; const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions'; const DEFAULT_OPENAI_MODEL = 'gpt-5-mini'; 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(options: GenerateQuizOptions, hasDocuments: boolean, includeJsonExample: boolean = false): string { const questionCount = options.questionCount || 10; let 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 (includeJsonExample) { baseInstructions += ` You MUST respond with a single JSON object in this exact structure: { "title": "Quiz Title Here", "questions": [ { "text": "Question text here?", "options": [ { "text": "Option A", "isCorrect": false, "reason": "Explanation why this is wrong" }, { "text": "Option B", "isCorrect": true, "reason": "Explanation why this is correct" }, { "text": "Option C", "isCorrect": false, "reason": "Explanation why this is wrong" }, { "text": "Option D", "isCorrect": false, "reason": "Explanation why this is wrong" } ] } ] } Return ONLY valid JSON with no additional text before or after.`; } if (hasDocuments) { const topicContext = options.topic ? ` Focus on aspects related to "${options.topic}".` : ''; return `Generate a quiz based on the provided content.${topicContext} ${baseInstructions}`; } return `Generate a trivia quiz about "${options.topic}". ${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): Quiz { const shapes = ['triangle', 'diamond', 'circle', 'square'] as const; const colors = ['red', 'blue', 'yellow', 'green'] as const; const questions: Question[] = data.questions.map((q: any) => { const shuffledOpts = shuffleArray(q.options); const options: AnswerOption[] = 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: options, timeLimit: 20 }; }); return { title: data.title, questions }; } async function uploadNativeDocument(ai: GoogleGenAI, doc: ProcessedDocument): Promise<{ uri: string; mimeType: string }> { const buffer = typeof doc.content === 'string' ? Buffer.from(doc.content, 'base64') : doc.content; 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) { throw new Error("Failed to upload document to Gemini"); } return { uri: uploadedFile.uri, mimeType: uploadedFile.mimeType }; } const JSON_SCHEMA_FOR_OPENROUTER = { type: "object", properties: { title: { type: "string", description: "A catchy title for the quiz" }, questions: { type: "array", items: { type: "object", properties: { text: { type: "string", description: "The question text" }, options: { type: "array", items: { type: "object", properties: { text: { type: "string" }, isCorrect: { type: "boolean" }, reason: { type: "string", description: "Brief explanation of why this answer is correct or incorrect" } }, required: ["text", "isCorrect", "reason"] }, } }, required: ["text", "options"] } } }, required: ["title", "questions"] }; const JSON_SCHEMA_FOR_OPENAI = { type: "object", properties: { title: { type: "string", description: "A catchy title for the quiz" }, questions: { type: "array", items: { type: "object", properties: { text: { type: "string", description: "The question text" }, options: { type: "array", items: { type: "object", properties: { text: { type: "string" }, isCorrect: { type: "boolean" }, reason: { type: "string", description: "Brief explanation of why this answer is correct or incorrect" } }, required: ["text", "isCorrect", "reason"], additionalProperties: false }, } }, required: ["text", "options"], additionalProperties: false } } }, required: ["title", "questions"], additionalProperties: false }; async function generateQuizWithGemini(options: GenerateQuizOptions): Promise { const ai = getGeminiClient(options.apiKey); const docs = options.documents || []; const hasDocuments = docs.length > 0; const prompt = buildPrompt(options, hasDocuments); let contents: any; if (hasDocuments) { const parts: any[] = []; for (const doc of docs) { if (doc.type === 'native' && doc.mimeType) { const uploaded = await uploadNativeDocument(ai, doc); parts.push(createPartFromUri(uploaded.uri, uploaded.mimeType)); } else if (doc.type === 'text') { parts.push({ text: doc.content as string }); } } parts.push({ text: prompt }); contents = createUserContent(parts); } else { contents = prompt; } const model = options.geminiModel || DEFAULT_GEMINI_MODEL; const response = await ai.models.generateContent({ model, contents, config: { responseMimeType: "application/json", responseSchema: QUIZ_SCHEMA } }); if (!response.text) { throw new Error("Failed to generate quiz content"); } const data = JSON.parse(response.text); return transformToQuiz(data); } async function generateQuizWithOpenRouter(options: GenerateQuizOptions): Promise { const apiKey = options.apiKey; if (!apiKey) { throw new Error("OpenRouter API key is missing"); } const docs = options.documents || []; const hasDocuments = docs.length > 0; const prompt = buildPrompt(options, hasDocuments, true); let fullPrompt = prompt; // For OpenRouter, we can't upload files directly - include text content in the prompt if (hasDocuments) { const textParts: string[] = []; for (const doc of docs) { if (doc.type === 'text') { textParts.push(doc.content as string); } else if (doc.type === 'native') { // For native documents, they should have been converted to text on the backend // If not, skip them with a warning console.warn('Native document type not supported with OpenRouter - document will be skipped'); } } if (textParts.length > 0) { fullPrompt = `Here is the content to create a quiz from:\n\n${textParts.join('\n\n---\n\n')}\n\n${prompt}`; } } const model = options.openRouterModel || DEFAULT_OPENROUTER_MODEL; const response = await fetch(OPENROUTER_API_URL, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': window.location.origin, 'X-Title': 'Kaboot Quiz Generator', }, body: JSON.stringify({ model, messages: [ { role: 'user', content: fullPrompt } ], response_format: { type: 'json_schema', json_schema: { name: 'quiz', strict: true, schema: JSON_SCHEMA_FOR_OPENROUTER } } }) }); if (!response.ok) { const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } })); throw new Error(error.error?.message || `OpenRouter API error: ${response.status}`); } const result = await response.json(); const content = result.choices?.[0]?.message?.content; // console.log('[OpenRouter] Raw response:', content); if (!content) { throw new Error("Failed to generate quiz content from OpenRouter"); } const data = JSON.parse(content); return transformToQuiz(data); } async function generateQuizWithOpenAI(options: GenerateQuizOptions): Promise { const apiKey = options.apiKey; if (!apiKey) { throw new Error("OpenAI API key is missing"); } const docs = options.documents || []; const hasDocuments = docs.length > 0; const prompt = buildPrompt(options, hasDocuments, true); let fullPrompt = prompt; if (hasDocuments) { const textParts: string[] = []; for (const doc of docs) { if (doc.type === 'text') { textParts.push(doc.content as string); } else if (doc.type === 'native') { console.warn('Native document type not supported with OpenAI - document will be skipped'); } } if (textParts.length > 0) { fullPrompt = `Here is the content to create a quiz from:\n\n${textParts.join('\n\n---\n\n')}\n\n${prompt}`; } } const model = options.openAIModel || DEFAULT_OPENAI_MODEL; const response = await fetch(OPENAI_API_URL, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model, messages: [ { role: 'user', content: fullPrompt } ], response_format: { type: 'json_schema', json_schema: { name: 'quiz', strict: true, schema: JSON_SCHEMA_FOR_OPENAI } } }) }); if (!response.ok) { const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } })); throw new Error(error.error?.message || `OpenAI API error: ${response.status}`); } const result = await response.json(); const content = result.choices?.[0]?.message?.content; // console.log('[OpenAI] Raw response:', content); if (!content) { throw new Error("Failed to generate quiz content from OpenAI"); } const data = JSON.parse(content); return transformToQuiz(data); } const API_URL = import.meta.env.VITE_API_URL || ''; async function generateQuizWithServer(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); } if (provider === 'openai') { return generateQuizWithOpenAI(options); } return generateQuizWithGemini(options); };