mirror of
https://github.com/dralkh/spaceforge.git
synced 2026-07-22 06:45:03 +00:00
JS/TS: - Replace builtin-modules with node:module (esbuild.config.mjs) - Replace fs-extra with native fs (install.js), remove from deps - Fix createEl deprecation: use createDiv/createSpan - Remove unnecessary type assertions, fix unsafe casts - Bind event handlers with arrow functions CSS: - Remove all !important declarations (~30 instances) — replace with chained selectors (.class.class) for equal specificity - Merge all duplicate selectors across 7 CSS files (~50+ instances) - Convert 3-digit hex to 6-digit format (_variables.css) - Fix duplicate CSS properties (overflow-y in mcq.css) - Delete leftover display:none suppression rule in calendar.css Meta: - Bump version to 1.0.5 - Update minAppVersion to 1.8.7 - Add versions.json entry for 1.0.5
308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
import { Notice, requestUrl } from 'obsidian';
|
|
import { OPENROUTER, 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 OpenRouterResponse {
|
|
choices: Array<{ message: { content: string } }>;
|
|
}
|
|
|
|
|
|
/**
|
|
* Service for generating MCQs using the OpenRouter API
|
|
*/
|
|
export class OpenRouterService implements IMCQGenerationService {
|
|
/**
|
|
* Reference to the main plugin
|
|
*/
|
|
plugin: SpaceforgePlugin;
|
|
|
|
/**
|
|
* Initialize OpenRouter service
|
|
*
|
|
* @param plugin Reference to the main plugin
|
|
*/
|
|
constructor(plugin: SpaceforgePlugin) {
|
|
this.plugin = plugin;
|
|
}
|
|
|
|
/**
|
|
* Generate MCQs for a note
|
|
*
|
|
* @param notePath Path to the note
|
|
* @param noteContent Content of the note
|
|
* @param settings Current plugin settings
|
|
* @returns Generated MCQ set or null if failed
|
|
*/
|
|
async generateMCQs(notePath: string, noteContent: string, settings: SpaceforgeSettings): Promise<MCQSet | null> {
|
|
// Check if API key is set
|
|
if (!settings.openRouterApiKey) {
|
|
new Notice(`${OPENROUTER} ${API} key not set in settings.`);
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// Show loading notice
|
|
new Notice(`Generating questions using ${OPENROUTER}...`);
|
|
|
|
// Determine the number of questions to generate
|
|
let numQuestionsToGenerate: number;
|
|
if (settings.mcqQuestionAmountMode === MCQQuestionAmountMode.WordsPerQuestion) {
|
|
const wordCount = noteContent.split(/\s+/).filter(Boolean).length;
|
|
// Ensure at least 1 question, and use ceiling to round up slightly
|
|
numQuestionsToGenerate = Math.max(1, Math.ceil(wordCount / settings.mcqWordsPerQuestion));
|
|
// Optional: Add a reasonable upper limit if desired, e.g., Math.min(numQuestionsToGenerate, 15);
|
|
} else { // Fixed mode
|
|
numQuestionsToGenerate = settings.mcqQuestionsPerNote;
|
|
}
|
|
|
|
// Generate prompt based on settings and calculated question count
|
|
const prompt = this.generatePrompt(noteContent, settings, numQuestionsToGenerate);
|
|
|
|
// Make API request
|
|
const response = await this.makeApiRequest(prompt, settings);
|
|
|
|
// Parse response to extract MCQs, respecting the target number
|
|
const questions = this.parseResponse(response, settings, numQuestionsToGenerate);
|
|
|
|
// Ensure we have at least one question if requested
|
|
if (questions.length === 0) {
|
|
new Notice('Failed to generate valid questions. Try again.');
|
|
return null;
|
|
}
|
|
|
|
// Create MCQ set
|
|
const mcqSet: MCQSet = {
|
|
notePath,
|
|
questions,
|
|
generatedAt: Date.now()
|
|
};
|
|
|
|
return mcqSet;
|
|
} catch {
|
|
new Notice('Failed to generate questions. Check console for details.');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate prompt for the AI
|
|
*
|
|
* @param noteContent Content of the note
|
|
* @param settings Current plugin settings
|
|
* @param numQuestionsToGenerate The target number of questions to ask for
|
|
* @returns Prompt for the AI
|
|
*/
|
|
private generatePrompt(noteContent: string, settings: SpaceforgeSettings, numQuestionsToGenerate: number): string {
|
|
// Use the passed numQuestionsToGenerate instead of settings.mcqQuestionsPerNote directly
|
|
const questionCount = numQuestionsToGenerate;
|
|
const choiceCount = settings.mcqChoicesPerQuestion;
|
|
const promptType = settings.mcqPromptType;
|
|
const difficulty = settings.mcqDifficulty;
|
|
|
|
// Base prompt template according to prompt type
|
|
let basePrompt = "";
|
|
|
|
if (promptType === 'basic') {
|
|
basePrompt = `Generate ${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 = `Generate ${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.
|
|
|
|
For example:
|
|
1. What is the capital of France?
|
|
A) London
|
|
B) Berlin
|
|
C) Paris [CORRECT]
|
|
D) Madrid
|
|
E) Rome`;
|
|
}
|
|
|
|
// Add difficulty-specific instructions
|
|
if (difficulty === MCQDifficulty.Basic) {
|
|
basePrompt += `\n\nCreate straightforward questions that focus on key facts and basic concepts. Make the questions clear and direct, suitable for beginners or initial review.`;
|
|
} else {
|
|
basePrompt += `\n\nCreate challenging questions that test deeper understanding and application of concepts. Make the incorrect choices plausible to encourage critical thinking.`;
|
|
}
|
|
|
|
// Add note content
|
|
return `${basePrompt}\n\nNote Content:\n${noteContent}`;
|
|
}
|
|
|
|
/**
|
|
* Make API request to OpenRouter
|
|
*
|
|
* @param prompt Prompt for the AI
|
|
* @param settings Current plugin settings
|
|
* @returns AI response text
|
|
*/
|
|
private async makeApiRequest(prompt: string, settings: SpaceforgeSettings): Promise<string> {
|
|
const apiKey = settings.openRouterApiKey; // Use key from settings argument
|
|
const model = settings.openRouterModel; // Use model from settings argument
|
|
const difficulty = settings.mcqDifficulty;
|
|
|
|
try {
|
|
// Get the appropriate system prompt based on difficulty
|
|
const systemPrompt = difficulty === MCQDifficulty.Basic
|
|
? settings.mcqBasicSystemPrompt
|
|
: settings.mcqAdvancedSystemPrompt;
|
|
|
|
const response = await requestUrl({
|
|
url: 'https://openrouter.ai/api/v1/chat/completions',
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
'HTTP-Referer': 'https://obsidian.md', // Required by OpenRouter
|
|
'X-Title': 'Spaceforge Plugin for Obsidian' // Identifying the app
|
|
},
|
|
body: JSON.stringify({
|
|
model: model,
|
|
messages: [
|
|
{
|
|
role: 'system',
|
|
content: systemPrompt
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: prompt
|
|
}
|
|
]
|
|
})
|
|
});
|
|
|
|
if (response.status !== 200) {
|
|
throw new Error(`API request failed (${response.status}): ${response.text}`);
|
|
}
|
|
|
|
const data = response.json as OpenRouterResponse;
|
|
|
|
if (!data.choices?.length || !data.choices[0]?.message) {
|
|
throw new Error('Invalid API response format from OpenRouter - missing choices');
|
|
}
|
|
|
|
return data.choices[0].message.content;
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
new Notice(`OpenRouter API error: ${msg}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse the AI response to extract MCQs
|
|
*
|
|
* @param response AI response text
|
|
* @param settings Current plugin settings
|
|
* @param numQuestionsToGenerate The target number of questions expected
|
|
* @returns Array of parsed MCQ questions
|
|
*/
|
|
private parseResponse(response: string, _settings: SpaceforgeSettings, numQuestionsToGenerate: number): MCQQuestion[] {
|
|
const questions: MCQQuestion[] = [];
|
|
|
|
try {
|
|
// Log the raw response for debugging
|
|
// Check for common response formats
|
|
let questionBlocks: string[] = [];
|
|
|
|
// Method 1: Split by numbered questions (1., 2., etc.)
|
|
questionBlocks = response.split(/\d+\.\s+/).filter(block => block.trim().length > 0);
|
|
|
|
// If we didn't find questions, try another method
|
|
if (questionBlocks.length === 0) {
|
|
// Method 2: Look for line breaks with numbered patterns
|
|
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) {
|
|
// Extract the question text and choices
|
|
const lines = block.split('\n').filter(line => line.trim().length > 0);
|
|
if (lines.length < 2) {
|
|
continue;
|
|
}
|
|
|
|
let questionText = lines[0].trim();
|
|
// Remove <think> and </think> tags from the question text
|
|
questionText = questionText.replace(/<think>/g, '').replace(/<\/think>/g, '');
|
|
|
|
const choices: string[] = [];
|
|
let correctAnswerIndex = -1;
|
|
|
|
// Debug the question
|
|
// Extract choices and identify the correct answer
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const line = lines[i].trim();
|
|
const isCorrect = line.includes('[CORRECT]');
|
|
|
|
// Remove the [CORRECT] marker and any leading identifiers (A), B., etc.)
|
|
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 no answer was marked as correct, try to check for other indicators
|
|
if (correctAnswerIndex === -1) {
|
|
for (let i = 0; i < choices.length; i++) {
|
|
// Check for any other correct answer indicator
|
|
if (lines[i + 1] && (
|
|
lines[i + 1].toLowerCase().includes('correct') ||
|
|
lines[i + 1].includes('✓') ||
|
|
lines[i + 1].includes('✔️')
|
|
)) {
|
|
correctAnswerIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If still no correct answer found, default to the first answer as a fallback
|
|
if (correctAnswerIndex === -1 && choices.length > 0) {
|
|
correctAnswerIndex = 0;
|
|
}
|
|
|
|
// Only add if we found a valid question with choices
|
|
if (questionText && choices.length >= 2) {
|
|
questions.push({
|
|
question: questionText,
|
|
choices,
|
|
correctAnswerIndex
|
|
});
|
|
} else {
|
|
// no-op
|
|
}
|
|
}
|
|
|
|
// Log the parsed questions
|
|
// Limit to the target number of questions calculated earlier
|
|
// Use numQuestionsToGenerate instead of settings.mcqQuestionsPerNote
|
|
return questions.slice(0, numQuestionsToGenerate);
|
|
} catch {
|
|
new Notice(`Error parsing response from ${OPENROUTER}. Try again.`);
|
|
return [];
|
|
}
|
|
}
|
|
}
|