import { Notice, requestUrl } from 'obsidian'; import { GEMINI, MCQS, MCQ, API } from '../ui/constants'; import SpaceforgePlugin from '../main'; import { MCQQuestion, MCQSet } from '../models/mcq'; import { IMCQGenerationService } from './mcq-generation-service'; import { SpaceforgeSettings, MCQQuestionAmountMode, MCQDifficulty } from '../models/settings'; interface GeminiResponse { candidates: Array<{ content: { parts: Array<{ text: string }> } }>; } interface GeminiErrorResponse { error?: { message?: string }; message?: string; } export class GeminiService implements IMCQGenerationService { plugin: SpaceforgePlugin; constructor(plugin: SpaceforgePlugin) { this.plugin = plugin; } async generateMCQs(notePath: string, noteContent: string, settings: SpaceforgeSettings): Promise { if (!settings.geminiApiKey) { new Notice(`${GEMINI} ${API} key is not set. Please add it in the settings`); return null; } if (!settings.geminiModel) { new Notice(`${GEMINI} model is not set. Please add it in the settings`); return null; } try { new Notice(`Generating ${MCQS} using ${GEMINI}...`); // Determine the number of questions to generate let numQuestionsToGenerate: number; if (settings.mcqQuestionAmountMode === MCQQuestionAmountMode.WordsPerQuestion) { const wordCount = noteContent.split(/\s+/).filter(Boolean).length; numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion)); } else { // Fixed mode numQuestionsToGenerate = settings.mcqQuestionsPerNote; } const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate); const response = await this.makeApiRequest(prompt, settings); const questions = this.parseResponse(response, settings, numQuestionsToGenerate); if (questions.length === 0) { new Notice(`Failed to generate valid ${MCQS} from ${GEMINI}. Please try again`); return null; } return { notePath, questions, generatedAt: Date.now() }; } catch { new Notice(`Failed to generate ${MCQS} with ${GEMINI}. Please check console for details`); return null; } } private generatePrompt(noteContent: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): string { const questionCount = numQuestionsToGenerate; // Use calculated number const choiceCount = settings.mcqChoicesPerQuestion; const promptType = settings.mcqPromptType; const difficulty = settings.mcqDifficulty; let basePrompt = ""; // Gemini prefers direct instructions. System prompts are handled differently or not at all in simpler SDKs. // For direct API calls, we include system-like instructions at the beginning of the user prompt. const systemInstruction = difficulty === MCQDifficulty.Basic ? settings.mcqBasicSystemPrompt : settings.mcqAdvancedSystemPrompt; if (promptType === 'basic') { basePrompt = `${systemInstruction}\n\nGenerate ${questionCount} multiple-choice questions based on the following note content. Each question should have ${choiceCount} choices, with one correct answer. Format the output as a list of questions with bullet points for each answer choice. Mark the correct answer by putting [CORRECT] at the end of the line.`; } else { basePrompt = `${systemInstruction}\n\nGenerate ${questionCount} multiple-choice questions that test understanding of key concepts in the following note. Each question should have ${choiceCount} choices, with only one correct answer. Format the output as a numbered list of questions with lettered choices (A, B, C, etc.). Mark the correct answer by putting [CORRECT] at the end of the line.\n\nFor example:\n1. What is the capital of France?\n A) London\n B) Berlin\n C) Paris [CORRECT]\n D) Madrid\n E) Rome`; } // Difficulty instructions are already prepended via systemInstruction. return `${basePrompt}\n\nNote Content:\n${noteContent}`; } private async makeApiRequest(prompt: string, settings: SpaceforgeSettings): Promise { const apiKey = settings.geminiApiKey; const model = settings.geminiModel; // e.g., 'gemini-pro' const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`; try { const response = await requestUrl({ url: apiUrl, method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ // Gemini API expects contents as an array of parts contents: [{ parts: [{ text: prompt }] }], // Optional: Add generationConfig if needed (e.g., temperature, maxOutputTokens) // generationConfig: { // temperature: 0.7, // maxOutputTokens: 1024, // } }) }); if (response.status !== 200) { const errorData = response.json as GeminiErrorResponse; const errorMessage = errorData?.error?.message ?? errorData?.message ?? 'Unknown error'; throw new Error(`API request failed (${response.status}): ${errorMessage}`); } const data = response.json as GeminiResponse; if (!data.candidates?.length || !data.candidates[0]?.content?.parts?.length || !data.candidates[0]?.content?.parts[0]?.text) { throw new Error('Invalid API response format from Gemini - missing content'); } return data.candidates[0].content.parts[0].text; } catch (error) { const msg = error instanceof Error ? error.message : String(error); new Notice(`Gemini API error: ${msg}`); throw error; } } private parseResponse(response: string, _settings: SpaceforgeSettings, numQuestionsToGenerate: number): MCQQuestion[] { const questions: MCQQuestion[] = []; try { const questionBlocks: string[] = response.split(/\d+\.\s+/).filter(block => block.trim().length > 0); if (questionBlocks.length === 0) { const lines = response.split('\n'); let currentQuestion = ''; for (const line of lines) { if (/^\d+\./.test(line.trim())) { if (currentQuestion) questionBlocks.push(currentQuestion); currentQuestion = line.replace(/^\d+\.\s*/, '') + '\n'; } else if (currentQuestion) { currentQuestion += line + '\n'; } } if (currentQuestion) questionBlocks.push(currentQuestion); } for (const block of questionBlocks) { const lines = block.split('\n').filter(line => line.trim().length > 0); if (lines.length < 2) continue; let questionText = lines[0].trim(); // Remove and tags from the question text questionText = questionText.replace(//g, '').replace(/<\/think>/g, ''); const choices: string[] = []; let correctAnswerIndex = -1; for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); const isCorrect = line.includes('[CORRECT]'); const cleanedLine = line.replace(/\[CORRECT\]/g, '').replace(/^[A-Z]\)\s*|^[A-Z]\.\s*|^\w+\)\s*|^\w+\.\s*/, '').trim(); choices.push(cleanedLine); if (isCorrect) correctAnswerIndex = choices.length - 1; } if (correctAnswerIndex === -1) { for (let i = 0; i < choices.length; i++) { if (lines[i + 1] && (lines[i + 1].toLowerCase().includes('correct') || lines[i + 1].includes('✓') || lines[i + 1].includes('✔️'))) { correctAnswerIndex = i; break; } } } if (correctAnswerIndex === -1 && choices.length > 0) correctAnswerIndex = 0; if (questionText && choices.length >= 2) { questions.push({ question: questionText, choices, correctAnswerIndex }); } } return questions.slice(0, numQuestionsToGenerate); // Use calculated number } catch { new Notice(`Error parsing ${MCQ} response from ${GEMINI}. Please try again`); return []; } } }