diff --git a/package.json b/package.json index 3764066..a7d5ee9 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "cheerio": "^1.1.2", "cors": "^2.8.6", "dotenv": "^17.0.1", + "expr-eval": "^2.0.2", "express": "^5.1.0", "express-session": "^1.18.2", "fastembed": "^2.0.0", diff --git a/public/question-bank.html b/public/question-bank.html index 9d38587..e79fe7f 100644 --- a/public/question-bank.html +++ b/public/question-bank.html @@ -153,6 +153,10 @@ Bloom's Level + + Question Type + + Status @@ -242,6 +246,7 @@

Export Summary

+ diff --git a/public/question-generation.html b/public/question-generation.html index bf555df..c2a7ee1 100644 --- a/public/question-generation.html +++ b/public/question-generation.html @@ -488,9 +488,9 @@

Questions Saved Successfully!

+ - diff --git a/public/quiz.html b/public/quiz.html index 1fde937..c41e774 100644 --- a/public/quiz.html +++ b/public/quiz.html @@ -147,6 +147,7 @@

Quiz Complete!

+ diff --git a/public/scripts/app-constants.js b/public/scripts/app-constants.js new file mode 100644 index 0000000..bbe1685 --- /dev/null +++ b/public/scripts/app-constants.js @@ -0,0 +1,20 @@ +// Browser-side mirror of src/constants/app-constants.js +// Keep in sync with QUESTION_TYPES and DEFAULT_BLOOM_TYPE_PREFERENCES in that file. + +const QUESTION_TYPES = { + MULTIPLE_CHOICE: "multiple-choice", + FILL_IN_THE_BLANK: "fill-in-the-blank", + CALCULATION: "calculation", + OPEN_ENDED: "open-ended", +}; + +window.QUESTION_TYPES = QUESTION_TYPES; + +window.DEFAULT_BLOOM_TYPE_PREFERENCES = { + Remember: [QUESTION_TYPES.FILL_IN_THE_BLANK, QUESTION_TYPES.MULTIPLE_CHOICE], + Understand: [QUESTION_TYPES.MULTIPLE_CHOICE, QUESTION_TYPES.FILL_IN_THE_BLANK], + Apply: [QUESTION_TYPES.MULTIPLE_CHOICE, QUESTION_TYPES.FILL_IN_THE_BLANK], + Analyze: [QUESTION_TYPES.MULTIPLE_CHOICE, QUESTION_TYPES.FILL_IN_THE_BLANK], + Evaluate: [QUESTION_TYPES.CALCULATION, QUESTION_TYPES.MULTIPLE_CHOICE], + Create: [QUESTION_TYPES.OPEN_ENDED, QUESTION_TYPES.MULTIPLE_CHOICE], +}; diff --git a/public/scripts/direct-ollama-service.js b/public/scripts/direct-ollama-service.js deleted file mode 100644 index a37829e..0000000 --- a/public/scripts/direct-ollama-service.js +++ /dev/null @@ -1,120 +0,0 @@ -// Direct OpenAI API Service (Fallback) -// Uses fetch to call OpenAI directly without UBC toolkit - -class DirectOpenAIService { - constructor() { - this.apiKey = window.OPENAI_API_KEY || process.env.OPENAI_API_KEY; - this.model = window.OPENAI_MODEL || process.env.OPENAI_MODEL || "gpt-4.1-mini"; - this.temperature = parseFloat(window.LLM_TEMPERATURE || process.env.LLM_TEMPERATURE) || 0.7; - this.maxTokens = parseInt(window.LLM_MAX_TOKENS || process.env.LLM_MAX_TOKENS) || 1000; - this.isInitialized = !!this.apiKey; - console.log("✅ Direct OpenAI Service initialized"); - } - - async generateQuestionWithRAG(prompt, ragContext = "") { - try { - console.log("=== DIRECT OPENAI API CALL ==="); - console.log("RAG Context length:", ragContext.length); - console.log("Sending to OpenAI API..."); - - const fullPrompt = `${ragContext}\n\n${prompt}`; - - const response = await fetch("https://api.openai.com/v1/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.apiKey}`, - }, - body: JSON.stringify({ - model: this.model, - messages: [ - { - role: "user", - content: fullPrompt, - }, - ], - temperature: this.temperature, - max_tokens: this.maxTokens, - }), - }); - - if (!response.ok) { - throw new Error( - `OpenAI API error: ${response.status} ${response.statusText}` - ); - } - - const data = await response.json(); - const responseContent = data.choices?.[0]?.message?.content || ""; - console.log( - "✅ OpenAI response received:", - responseContent.substring(0, 200) + "..." - ); - - return responseContent; - } catch (error) { - console.error("❌ Direct OpenAI API failed:", error); - throw error; - } - } - - async generateMultipleChoiceQuestion(objective, ragContent, bloomLevel) { - const prompt = this.createQuestionPrompt(objective, bloomLevel); - return await this.generateQuestionWithRAG(prompt, ragContent); - } - - createQuestionPrompt(objective, bloomLevel) { - return `You are an expert educational content creator. Generate a high-quality multiple-choice question based on the provided content. - -OBJECTIVE: ${objective} -BLOOM'S TAXONOMY LEVEL: ${bloomLevel} - -INSTRUCTIONS: -1. Create a specific, detailed question that tests understanding of the objective -2. Use actual content from the materials - don't be generic -3. Include 4 answer options -4. Make the correct answer clearly correct based on the content -5. Make incorrect answers plausible but clearly wrong -6. Focus on the specific concepts, examples, or details mentioned in the content -7. Format your response as JSON with this structure: -{ - "question": "Your specific question here", - "options": { - "A": "First option text", - "B": "Second option text", - "C": "Third option text", - "D": "Fourth option text" - }, - "correctAnswer": "A", - "explanation": "Why this answer is correct based on the content" -} - -CRITICAL FORMATTING REQUIREMENTS: -- Return ONLY valid JSON. Do NOT wrap the JSON in markdown code blocks (do not use triple backticks with json or triple backticks alone). -- Do NOT include any text before or after the JSON object. -- The response must start with { and end with }. -- Return pure JSON that can be directly parsed with JSON.parse(). - -IMPORTANT: -- Base your question on the specific details, examples, formulas, or concepts mentioned in the provided content. Don't create generic questions - make them specific to what's actually in the materials. -- CRITICAL: Use an object for options with keys "A", "B", "C", "D" (not an array). Place the correct answer at a random key (A, B, C, or D) and set correctAnswer to that exact key letter. For example, if the correct answer is in option "B", set correctAnswer to "B". This avoids array index confusion. -- CRITICAL: You MUST randomly choose which option (A, B, C, or D) contains the correct answer. Each letter should have an equal 25% chance of being the correct answer. Do NOT bias toward A, B, or C - ensure D is also used frequently. After placing the correct answer text in one of the four options, set correctAnswer to that exact letter. Use a random number generator or random selection - do NOT always use A, B, or C. Option D must be selected approximately 25% of the time. -- CRITICAL: Always wrap any mathematical expressions in LaTeX delimiters. You MUST use backslash-parenthesis \( ... \) for inline math (NOT plain parentheses). Examples: - * CORRECT: \( \frac{3}{4} \) or \( x^2 + 5 = 10 \) - * WRONG: (\frac{3}{4}) or (x^2 + 5 = 10) - these will NOT render as math - * Use \[ ... \] for display math (block equations on their own line) - * Do NOT use $ ... $ delimiters - only use \( ... \) for inline math and \[ ... \] for display math - * The backslash before the parenthesis is REQUIRED - \( not just ( -- CRITICAL: Do NOT include letter prefixes (A), B), C), D) or A., B., C., D. or A , B , C , D ) in the option text. The options object values should contain only the option text itself, without any letter labels, prefixes, or formatting. For example, use "The correct answer" NOT "A) The correct answer" or "A. The correct answer".`; - } - - isAvailable() { - return this.isInitialized; - } -} - -// Export for use in other files -window.DirectOpenAIService = DirectOpenAIService; -// Keep old name for backwards compatibility -window.DirectOllamaService = DirectOpenAIService; - diff --git a/public/scripts/generation-questions.js b/public/scripts/generation-questions.js index 6e068fb..4c16687 100644 --- a/public/scripts/generation-questions.js +++ b/public/scripts/generation-questions.js @@ -1,10 +1,10 @@ // Generation Questions Module // Handles question generation based on content and objectives -// Question Generation Class class QuestionGenerator { - constructor(contentGenerator) { + constructor(contentGenerator, options = {}) { this.contentGenerator = contentGenerator; + this.bloomTypePreferences = options.bloomTypePreferences || window.DEFAULT_BLOOM_TYPE_PREFERENCES; this.llmService = null; this.initializeLLMService(); } @@ -12,70 +12,11 @@ class QuestionGenerator { async initializeLLMService() { try { console.log("=== QUESTION GENERATOR LLM INITIALIZATION ==="); - - // Use server-side RAG + LLM endpoint this.llmService = { isAvailable: () => true, - generateMultipleChoiceQuestion: async ( - courseName, - learningObjectiveId, - learningObjectiveText, - granularLearningObjectiveText, - bloomLevel - ) => { - console.log('Generating multiple choice question...', { - courseName: courseName, - learningObjectiveId: learningObjectiveId, - learningObjectiveText: learningObjectiveText, - granularLearningObjectiveText: granularLearningObjectiveText, - bloomLevel: bloomLevel, - }); - console.log("=== CALLING SERVER-SIDE RAG + LLM ==="); - - try { - const response = await fetch("/api/rag-llm/generate-questions-with-rag", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - courseId: courseId, - courseName: courseName, - learningObjectiveId: learningObjectiveId, - learningObjectiveText: learningObjectiveText, - granularLearningObjectiveText: granularLearningObjectiveText, - bloomLevel: bloomLevel, - }), - }); - - if (!response.ok) { - const errorText = await response - .text() - .catch(() => "Unknown error"); - console.error(`Server error ${response.status}:`, errorText); - throw new Error( - `Server error: ${response.status} - ${errorText}` - ); - } - - const data = await response.json(); - console.log("✅ Server-side RAG + LLM response:", data); - - if (data.success) { - return JSON.stringify(data.questions); - } else { - throw new Error( - data.error || - "Question generation service is currently unavailable" - ); - } - } catch (error) { - console.error("❌ Server-side RAG + LLM failed:", error); - throw error; - } - }, + generateQuestionByType: async (questionType, params) => + await this.callQuestionGenerationApi({ ...params, questionType }), }; - console.log("✅ Server-side RAG + LLM service initialized"); } catch (error) { console.error("❌ Failed to initialize LLM service:", error); @@ -83,6 +24,59 @@ class QuestionGenerator { } } + async callQuestionGenerationApi({ + courseId, + courseName, + learningObjectiveId, + learningObjectiveText, + granularLearningObjectiveText, + bloomLevel, + questionType, + }) { + console.log(`Generating ${questionType} question...`, { + courseId, + courseName, + learningObjectiveId, + learningObjectiveText, + granularLearningObjectiveText, + bloomLevel, + questionType, + }); + + const response = await fetch("/api/rag-llm/generate-questions-with-rag", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + courseId, + courseName, + learningObjectiveId, + learningObjectiveText, + granularLearningObjectiveText, + bloomLevel, + questionType, + }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unknown error"); + console.error(`Server error ${response.status}:`, errorText); + throw new Error(`Server error: ${response.status} - ${errorText}`); + } + + const data = await response.json(); + console.log("✅ Server-side RAG + LLM response:", data); + + if (!data.success || !data.question) { + throw new Error( + data.error || "Question generation service is currently unavailable" + ); + } + + return data.question; + } + async generateQuestions(course, objectiveGroups) { try { console.log("=== QUESTION GENERATOR DEBUG ==="); @@ -150,6 +144,15 @@ class QuestionGenerator { } } + getBloomTypePreferences() { + return this.bloomTypePreferences; + } + + determineQuestionType(bloomLevel) { + const preferences = this.getBloomTypePreferences(); + return preferences[bloomLevel]?.[0] || QUESTION_TYPES.MULTIPLE_CHOICE; + } + prepareContentForQuestions(summary, objectiveGroups) { let content = `Summary: ${summary}\n\n`; content += `Objectives:\n`; @@ -180,17 +183,20 @@ class QuestionGenerator { for (let i = 0; i < granularLearningObjective.count; i++) { const bloomLevel = bloomLevels[i % bloomLevels.length]; + const questionType = this.determineQuestionType(bloomLevel); console.log( `Creating question ${i + 1}/${ granularLearningObjective.count - } with Bloom level: ${bloomLevel}` + } with Bloom level: ${bloomLevel} and question type: ${questionType}` ); let question = null; let retryCount = 0; const maxRetries = 3; - - // Retry logic for individual question generation + let lastError = null; + let activeQuestionType = questionType; + + // Retry per question; if a "calculation" attempt exhausts retries, fall back to multiple-choice once below. while (retryCount < maxRetries && !question) { try { question = await this.createContextualQuestion( @@ -201,7 +207,8 @@ class QuestionGenerator { granularLearningObjective.granularId, granularLearningObjective.text, bloomLevel, - i + 1 + i + 1, + activeQuestionType ); console.log(`✅ Created question ${i + 1}:`, question.text); @@ -213,6 +220,7 @@ class QuestionGenerator { } } catch (error) { retryCount++; + lastError = error; console.warn( `⚠️ Failed to generate question ${i + 1} (attempt ${retryCount}/${maxRetries}):`, error.message @@ -223,20 +231,55 @@ class QuestionGenerator { const delay = Math.min(1000 * Math.pow(2, retryCount - 1), 5000); console.log(`Retrying in ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); - } else { - console.error( - `❌ Failed to generate question ${i + 1} after ${maxRetries} attempts:`, - error.message - ); - failedQuestions.push({ - questionNumber: i + 1, - bloomLevel: bloomLevel, - error: error.message - }); - // Continue with next question instead of stopping } } } + + if (!question && activeQuestionType === QUESTION_TYPES.CALCULATION) { + console.warn( + `⚠️ Calculation retries exhausted for question ${i + 1}; falling back to multiple-choice for objective "${granularLearningObjective.text}".` + ); + try { + question = await this.createContextualQuestion( + courseId, + courseName, + learningObjective.objectiveId, + learningObjective.title, + granularLearningObjective.granularId, + granularLearningObjective.text, + bloomLevel, + i + 1, + QUESTION_TYPES.MULTIPLE_CHOICE + ); + activeQuestionType = QUESTION_TYPES.MULTIPLE_CHOICE; + console.log(`✅ Created fallback multiple-choice question ${i + 1}:`, question.text); + questions.push(question); + if (i < granularLearningObjective.count - 1) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + } catch (fallbackError) { + console.error( + `❌ Fallback to multiple-choice also failed for question ${i + 1}:`, + fallbackError.message + ); + lastError = fallbackError; + } + } + + if (!question) { + console.error( + `❌ Failed to generate question ${i + 1} after ${maxRetries} attempts${ + questionType === QUESTION_TYPES.CALCULATION ? " (including multiple-choice fallback)" : "" + }:`, + lastError ? lastError.message : "unknown error" + ); + failedQuestions.push({ + questionNumber: i + 1, + bloomLevel: bloomLevel, + questionType: questionType, + error: lastError ? lastError.message : "unknown error", + }); + } } console.log( @@ -269,55 +312,112 @@ class QuestionGenerator { granularLearningObjectiveId, granularLearningObjectiveText, bloomLevel, - questionNumber + questionNumber, + questionType ) { - // Use LLM service - if (this.llmService && this.llmService.isAvailable()) { - console.log(`Generating LLM question for objective: ${learningObjectiveText}`); - - try { - const llmResponse = await fetch('/api/rag-llm/generate-questions-with-rag', { - method: 'POST', - body: JSON.stringify({ - courseId: courseId, - courseName: courseName, - learningObjectiveId: learningObjectiveId, - learningObjectiveText: learningObjectiveText, - granularLearningObjectiveText: granularLearningObjectiveText, - bloomLevel: bloomLevel, - }), - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (!llmResponse.ok) { - const errorText = await llmResponse.text().catch(() => 'Unknown error'); - console.error(`Server error ${llmResponse.status}:`, errorText); - throw new Error(`Server error: ${llmResponse.status} - ${errorText}`); - } - - const response = await llmResponse.json(); - - if (!response.success) { - throw new Error( - response.error || "Question generation service is currently unavailable" - ); + if (!this.llmService || !this.llmService.isAvailable()) { + throw new Error("Question generation service is currently unavailable"); + } + + console.log( + `Generating ${questionType} question for objective: ${learningObjectiveText}` + ); + + try { + const questionData = await this.llmService.generateQuestionByType( + questionType, + { + courseId, + courseName, + learningObjectiveId, + learningObjectiveText, + granularLearningObjectiveText, + bloomLevel, } - - if (!response.question) { - throw new Error("Invalid response: question data missing"); + ); + + const resolvedType = questionData.type || questionData.questionType || questionType; + + if (resolvedType === QUESTION_TYPES.CALCULATION) { + const stemText = String( + questionData.stem || questionData.question || "" + ).trim(); + let topicTitleCalc = String( + questionData.topicTitle || + questionData.topic || + questionData.shortTitle || + "" + ) + .trim() + .replace(/\?+$/, ""); + if (!topicTitleCalc) { + const before = stemText.split("{{")[0].trim(); + const words = before.split(/\s+/).filter(Boolean); + topicTitleCalc = words.slice(0, 10).join(" ") || "Calculation"; } - - const questionData = response.question; + let answerDec = parseInt(questionData.calculationAnswerDecimals, 10); + if (!Number.isFinite(answerDec)) answerDec = 2; + answerDec = Math.max(0, Math.min(12, answerDec)); + let tolerancePct = parseFloat(questionData.calculationAnswerTolerancePercent); + if (!Number.isFinite(tolerancePct)) tolerancePct = null; + else tolerancePct = Math.max(0, Math.min(100, tolerancePct)); + return { + id: `${granularLearningObjectiveId}-${questionNumber}`, + granularObjectiveId: `${granularLearningObjectiveId}`, + text: stemText, + topicTitle: topicTitleCalc, + type: QUESTION_TYPES.CALCULATION, + questionType: QUESTION_TYPES.CALCULATION, + options: null, + correctAnswer: "", + acceptableAnswers: [], + calculationFormula: questionData.calculationFormula, + calculationVariables: questionData.calculationVariables, + calculationAnswerDecimals: answerDec, + calculationAnswerTolerancePercent: tolerancePct, + bloomLevel: bloomLevel, + difficulty: this.determineDifficulty(bloomLevel), + metaCode: learningObjectiveText, + loCode: granularLearningObjectiveText, + lastEdited: new Date().toISOString().slice(0, 16).replace("T", " "), + by: "LLM + RAG System", + explanation: questionData.explanation, + }; + } + if (resolvedType === QUESTION_TYPES.OPEN_ENDED) { + const stemText = String( + questionData.stem || questionData.question || "" + ).trim(); + let topicTitleOpen = String( + questionData.topicTitle || + questionData.topic || + questionData.shortTitle || + "" + ) + .trim() + .replace(/\?+$/, ""); + if (!topicTitleOpen) { + const words = stemText.split(/\s+/).filter(Boolean); + topicTitleOpen = words.slice(0, 10).join(" ") || "Open-ended"; + } return { id: `${granularLearningObjectiveId}-${questionNumber}`, granularObjectiveId: `${granularLearningObjectiveId}`, - text: questionData.question, - type: "multiple-choice", - options: questionData.options, - correctAnswer: questionData.correctAnswer, + text: stemText, + stem: stemText, + topicTitle: topicTitleOpen, + type: QUESTION_TYPES.OPEN_ENDED, + questionType: QUESTION_TYPES.OPEN_ENDED, + options: null, + correctAnswer: "", + acceptableAnswers: [], + openEndedSampleAnswer: String( + questionData.openEndedSampleAnswer || "" + ).trim(), + openEndedGradingCriteria: String( + questionData.openEndedGradingCriteria || "" + ).trim(), bloomLevel: bloomLevel, difficulty: this.determineDifficulty(bloomLevel), metaCode: learningObjectiveText, @@ -326,12 +426,58 @@ class QuestionGenerator { by: "LLM + RAG System", explanation: questionData.explanation, }; - } catch (error) { - console.error(`Error generating question ${questionNumber}:`, error); - throw error; } - } else { - throw new Error("Question generation service is currently unavailable"); + + const acceptable = + resolvedType === QUESTION_TYPES.FILL_IN_THE_BLANK + ? Array.isArray(questionData.acceptableAnswers) && questionData.acceptableAnswers.length + ? questionData.acceptableAnswers + : questionData.correctAnswer != null + ? [String(questionData.correctAnswer)] + : [] + : []; + + const fibStem = String(questionData.question || "").trim(); + const rawTopic = + resolvedType === QUESTION_TYPES.FILL_IN_THE_BLANK + ? String( + questionData.topicTitle || + questionData.topic || + questionData.shortTitle || + "" + ).trim() + : ""; + const topicTitleFib = + resolvedType === QUESTION_TYPES.FILL_IN_THE_BLANK + ? rawTopic || + (() => { + const before = fibStem.split("_________")[0].trim(); + const words = before.split(/\s+/).filter(Boolean); + return words.slice(0, 10).join(" ") || "Fill-in-the-blank"; + })() + : ""; + + return { + id: `${granularLearningObjectiveId}-${questionNumber}`, + granularObjectiveId: `${granularLearningObjectiveId}`, + text: questionData.question, + topicTitle: resolvedType === QUESTION_TYPES.FILL_IN_THE_BLANK ? topicTitleFib : undefined, + type: resolvedType, + questionType: resolvedType, + options: questionData.options || null, + correctAnswer: questionData.correctAnswer, + acceptableAnswers: acceptable, + bloomLevel: bloomLevel, + difficulty: this.determineDifficulty(bloomLevel), + metaCode: learningObjectiveText, + loCode: granularLearningObjectiveText, + lastEdited: new Date().toISOString().slice(0, 16).replace("T", " "), + by: "LLM + RAG System", + explanation: questionData.explanation, + }; + } catch (error) { + console.error(`Error generating question ${questionNumber}:`, error); + throw error; } } @@ -379,41 +525,162 @@ class QuestionGenerator { } } + escapeCsvField(value) { + if (value == null) return '""'; + return `"${String(value).replace(/"/g, '""')}"`; + } + + escapeXml(str) { + return String(str ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + formatAsCSV(questions) { let csv = - "Question,Option A,Option B,Option C,Option D,Correct Answer,Bloom Level,Difficulty\n"; + "Question Type,Question,Option A,Option B,Option C,Option D,Correct Answer,Acceptable Answers,Bloom Level,Difficulty\n"; questions.forEach((q) => { - // Options are always objects with keys A, B, C, D - const optA = q.options?.A || ''; - const optB = q.options?.B || ''; - const optC = q.options?.C || ''; - const optD = q.options?.D || ''; - // correctAnswer is always a letter (A, B, C, D) - const correctAnswerLetter = typeof q.correctAnswer === 'string' - ? q.correctAnswer.toUpperCase() - : (typeof q.correctAnswer === 'number' ? ['A', 'B', 'C', 'D'][q.correctAnswer] : 'A'); - const correctOpt = q.options?.[correctAnswerLetter] || ''; - csv += `"${q.text}","${optA}","${optB}","${optC}","${optD}","${correctOpt}","${q.bloomLevel}","${ - q.difficulty - }"\n`; + const qt = q.type || q.questionType || QUESTION_TYPES.MULTIPLE_CHOICE; + if (qt === QUESTION_TYPES.CALCULATION) { + const stem = q.text || q.stem || ""; + const formula = q.calculationFormula || ""; + const varsJson = JSON.stringify(q.calculationVariables || []); + csv += `${this.escapeCsvField(qt)},${this.escapeCsvField(stem)},${this.escapeCsvField("")},${this.escapeCsvField("")},${this.escapeCsvField("")},${this.escapeCsvField("")},${this.escapeCsvField(formula)},${this.escapeCsvField(varsJson)},${this.escapeCsvField(q.bloomLevel)},${this.escapeCsvField(q.difficulty)}\n`; + return; + } + if (qt === QUESTION_TYPES.FILL_IN_THE_BLANK) { + const acc = + Array.isArray(q.acceptableAnswers) && q.acceptableAnswers.length + ? q.acceptableAnswers.join("; ") + : q.correctAnswer != null + ? String(q.correctAnswer) + : ""; + const fibQ = q.text || q.stem || ""; + csv += `${this.escapeCsvField(qt)},${this.escapeCsvField(fibQ)},${this.escapeCsvField("")},${this.escapeCsvField("")},${this.escapeCsvField("")},${this.escapeCsvField("")},${this.escapeCsvField(q.correctAnswer)},${this.escapeCsvField(acc)},${this.escapeCsvField(q.bloomLevel)},${this.escapeCsvField(q.difficulty)}\n`; + return; + } + if (qt === QUESTION_TYPES.OPEN_ENDED) { + const stem = q.text || q.stem || ""; + const sample = q.openEndedSampleAnswer || ""; + const crit = q.openEndedGradingCriteria || ""; + const combined = `Sample: ${sample} | Criteria: ${crit}`; + csv += `${this.escapeCsvField(qt)},${this.escapeCsvField(stem)},${this.escapeCsvField("")},${this.escapeCsvField("")},${this.escapeCsvField("")},${this.escapeCsvField("")},${this.escapeCsvField("")},${this.escapeCsvField(combined)},${this.escapeCsvField(q.bloomLevel)},${this.escapeCsvField(q.difficulty)}\n`; + return; + } + const optA = q.options?.A || ""; + const optB = q.options?.B || ""; + const optC = q.options?.C || ""; + const optD = q.options?.D || ""; + const correctAnswerLetter = + typeof q.correctAnswer === "string" + ? q.correctAnswer.toUpperCase() + : typeof q.correctAnswer === "number" + ? ["A", "B", "C", "D"][q.correctAnswer] + : "A"; + const correctOpt = q.options?.[correctAnswerLetter] || ""; + csv += `${this.escapeCsvField(qt)},${this.escapeCsvField(q.text)},${this.escapeCsvField(optA)},${this.escapeCsvField(optB)},${this.escapeCsvField(optC)},${this.escapeCsvField(optD)},${this.escapeCsvField(correctOpt)},${this.escapeCsvField("")},${this.escapeCsvField(q.bloomLevel)},${this.escapeCsvField(q.difficulty)}\n`; }); return csv; } formatAsQTI(questions) { - return ` - - - - - qmd_timelimit - PT30M - - - ${questions - .map( - (q, index) => ` - + const itemsXml = questions + .map((q, index) => { + const qt = q.type || q.questionType || QUESTION_TYPES.MULTIPLE_CHOICE; + const ident = `q${index + 1}`; + if (qt === QUESTION_TYPES.CALCULATION) { + const stem = q.text || q.stem || ""; + const note = `[Formula: ${q.calculationFormula || ""}; variables JSON: ${JSON.stringify(q.calculationVariables || [])}; answerDecimals: ${q.calculationAnswerDecimals != null ? q.calculationAnswerDecimals : 2}]`; + return ` + + + + + qmd_itemtype + Calculation + + + + + + ${this.escapeXml(stem)} + + + ${this.escapeXml(note)} + + + + + + + + + + + + + `; + } + if (qt === QUESTION_TYPES.FILL_IN_THE_BLANK) { + const acceptable = + Array.isArray(q.acceptableAnswers) && q.acceptableAnswers.length + ? q.acceptableAnswers + : q.correctAnswer != null + ? [String(q.correctAnswer)] + : []; + const conditions = + acceptable.length <= 1 + ? `${this.escapeXml(acceptable[0] || "")}` + : `${acceptable.map((a) => `${this.escapeXml(a)}`).join("")}`; + return ` + + + + + qmd_itemtype + Fill In The Blank + + + + + + ${this.escapeXml(q.text || q.stem || "")} + + + + + + + + + + + + + + ${conditions} + + 1 + + + `; + } + const choiceIndex = (() => { + if (typeof q.correctAnswer === "string") { + const letter = q.correctAnswer.toUpperCase(); + if (letter === "A") return 0; + if (letter === "B") return 1; + if (letter === "C") return 2; + if (letter === "D") return 3; + } else if (typeof q.correctAnswer === "number") { + return q.correctAnswer; + } + return 0; + })(); + return ` + @@ -424,17 +691,21 @@ class QuestionGenerator { - ${q.text} + ${this.escapeXml(q.text)} - ${['A', 'B', 'C', 'D'].map((key, optIndex) => ` + ${["A", "B", "C", "D"] + .map( + (key, optIndex) => ` - ${q.options?.[key] || ''} + ${this.escapeXml(q.options?.[key] || "")} - `).join("")} + ` + ) + .join("")} @@ -444,27 +715,25 @@ class QuestionGenerator { - choice${(() => { - // Convert letter to index (0-3) for QTI format - if (typeof q.correctAnswer === 'string') { - const letter = q.correctAnswer.toUpperCase(); - if (letter === 'A') return 0; - if (letter === 'B') return 1; - if (letter === 'C') return 2; - if (letter === 'D') return 3; - } else if (typeof q.correctAnswer === 'number') { - return q.correctAnswer; - } - return 0; - })()} + choice${choiceIndex} 1 - - ` - ) - .join("")} + `; + }) + .join(""); + + return ` + + + + + qmd_timelimit + PT30M + + + ${itemsXml} `; } diff --git a/public/scripts/llm-service.js b/public/scripts/llm-service.js deleted file mode 100644 index bf9d761..0000000 --- a/public/scripts/llm-service.js +++ /dev/null @@ -1,119 +0,0 @@ -// LLM Service for Question Generation -// Interfaces with OpenAI through UBC GenAI Toolkit - -// Import UBC GenAI Toolkit at the top level -import { LLMModule } from "ubc-genai-toolkit-llm"; - -class LLMService { - constructor() { - this.isInitialized = false; - this.llmModule = null; - this.initializeLLM(); - } - - async initializeLLM() { - try { - console.log("=== LLM SERVICE INITIALIZATION ==="); - console.log("UBC GenAI Toolkit imported successfully"); - - // Configure for OpenAI - const llmConfig = { - provider: "openai", - apiKey: window.OPENAI_API_KEY || process.env.OPENAI_API_KEY, - model: window.OPENAI_MODEL || process.env.OPENAI_MODEL || "gpt-4.1-mini", - temperature: parseFloat(window.LLM_TEMPERATURE || process.env.LLM_TEMPERATURE) || 0.7, - maxTokens: parseInt(window.LLM_MAX_TOKENS || process.env.LLM_MAX_TOKENS) || 1000, - }; - - console.log("Creating LLM module with config:", { - provider: llmConfig.provider, - model: llmConfig.model, - hasApiKey: !!llmConfig.apiKey, - }); - this.llmModule = await LLMModule.create(llmConfig); - this.isInitialized = true; - console.log("✅ LLM Service initialized with OpenAI successfully"); - } catch (error) { - console.error("❌ LLM initialization failed:", error); - console.error("Error details:", error.message); - console.error("Stack trace:", error.stack); - this.isInitialized = false; - } - } - - async generateQuestionWithRAG(prompt, ragContext = "") { - if (!this.isInitialized || !this.llmModule) { - throw new Error("LLM service not initialized"); - } - - try { - // Combine RAG context with prompt - const fullPrompt = `${ragContext}\n\n${prompt}`; - - console.log("=== LLM PROMPT DEBUG ==="); - console.log("RAG Context length:", ragContext.length); - console.log("Full prompt length:", fullPrompt.length); - console.log("Sending to OpenAI..."); - - const response = await this.llmModule.generate(fullPrompt); - - console.log("=== LLM RESPONSE DEBUG ==="); - console.log("Response length:", response.length); - console.log("Response preview:", response.substring(0, 200) + "..."); - - return response; - } catch (error) { - console.error("LLM generation failed:", error); - throw error; - } - } - - async generateMultipleChoiceQuestion(objective, ragContent, bloomLevel) { - const prompt = this.createQuestionPrompt(objective, bloomLevel); - return await this.generateQuestionWithRAG(prompt, ragContent); - } - - createQuestionPrompt(objective, bloomLevel) { - return `You are an university instructor. Generate a high-quality multiple-choice question based on the provided content that effectively test students’ understanding of the course learning objective. - -OBJECTIVE: ${objective} -BLOOM'S TAXONOMY LEVEL: ${bloomLevel} - -Task: -Create a multiple-choice question on based on the provided content that effectively test students’ understanding of the learning objective. -Use actual content from the materials - don't be generic - -Format: -Each question must have four answer choices, with only one correct answer. Label each answer choice (A, B, C, D) -the response format must be a valid JSON with the exact structure as follows: -{ - "question": "Your specific question here", - "options": { - "A": "First option text", // The first option - "B": "Second option text", // The second option - "C": "Third option text", // The third option - "D": "Fourth option text", // The fourth option - }, - "correctAnswer": "A", // The letter of the correct answer - "explanation": "Why this answer is correct based on the content" // The explanation of why the correct answer is correct -} - -The distractors (incorrect answers) should be plausible but subtly flawed, to effectively test students' understanding. - -IMPORTANT: -- CRITICAL: Always wrap any mathematical expressions in LaTeX delimiters. You MUST use backslash-parenthesis \( ... \) for inline math (NOT plain parentheses). Examples: - * CORRECT: \( \frac{3}{4} \) or \( x^2 + 5 = 10 \) - * WRONG: (\frac{3}{4}) or (x^2 + 5 = 10) - these will NOT render as math - * Use \[ ... \] for display math (block equations on their own line) - * Do NOT use $ ... $ delimiters - only use \( ... \) for inline math and \[ ... \] for display math - * The backslash before the parenthesis is REQUIRED - \( not just ( -- CRITICAL: Do NOT include letter prefixes (A), B), C), D) or A., B., C., D. or A , B , C , D ) in the option text. The options object values should contain only the option text itself, without any letter labels, prefixes, or formatting. For example, use "The correct answer" NOT "A) The correct answer" or "A. The correct answer".`; - } - - isAvailable() { - return this.isInitialized && this.llmModule !== null; - } -} - -// Export for use in other files -window.LLMService = LLMService; diff --git a/public/scripts/question-bank.js b/public/scripts/question-bank.js index 38bb8f8..ef06db7 100644 --- a/public/scripts/question-bank.js +++ b/public/scripts/question-bank.js @@ -83,6 +83,31 @@ function getObjectId(obj) { return toStringId(obj._id || obj.id); } +/** Normalize API questionType / type for display (legacy rows default to multiple-choice). */ +const QUESTION_TYPE_LABELS = { + "fill-in-the-blank": "Fill-in-the-blank", + "calculation": "Calculation", + "open-ended": "Open-ended", + "multiple-choice": "Multiple choice", +}; + +function normalizeQuestionTypeKey(raw) { + const t = (raw || "").toString().trim().toLowerCase().replace(/_/g, "-"); + return QUESTION_TYPE_LABELS[t] ? t : QUESTION_TYPES.MULTIPLE_CHOICE; +} + +function formatQuestionTypeLabel(raw) { + return QUESTION_TYPE_LABELS[normalizeQuestionTypeKey(raw)]; +} + +/** Escape text placed inside body */ +function escapeForTextareaContent(str) { + return String(str ?? "") + .replace(/&/g, "&") + .replace(//g, ">"); +} + class QuestionBankPage { constructor() { // Get course from sessionStorage @@ -378,6 +403,9 @@ class QuestionBankPage { objectiveId: objectiveId, // Store the objective ID glo: objectiveId || "", // Will be replaced with name after loading objectives bloom: question.bloom || question.bloomLevel || "Understand", + questionType: normalizeQuestionTypeKey( + question.questionType || question.type + ), flagged: question.flagStatus || false, published: question.published || false, status: question.status || "Draft", @@ -1788,9 +1816,10 @@ class QuestionBankPage { const sortedQuestions = this.sortQuestions(filteredQuestions); if (sortedQuestions.length === 0) { + const emptyColspan = this.isFaculty ? 6 : 5; tableBody.innerHTML = ` - +

No questions available.

You haven't saved any questions from question generation yet.

Go to Question Generation to create and save your first questions.

@@ -1855,6 +1884,9 @@ class QuestionBankPage { ${question.bloom || "N/A"} + + ${formatQuestionTypeLabel(question.questionType)} + ${question.status || "Draft"} @@ -1912,7 +1944,12 @@ class QuestionBankPage { const titleMatch = q.title && q.title.toLowerCase().includes(searchTerm); const stemMatch = q.stem && q.stem.toLowerCase().includes(searchTerm); const gloMatch = q.glo && q.glo.toLowerCase().includes(searchTerm); - return titleMatch || stemMatch || gloMatch; + const formulaMatch = + q.calculationFormula && + String(q.calculationFormula).toLowerCase().includes(searchTerm); + const typeLabel = formatQuestionTypeLabel(q.questionType).toLowerCase(); + const typeMatch = typeLabel.includes(searchTerm); + return titleMatch || stemMatch || gloMatch || typeMatch || formulaMatch; } ); } @@ -1928,20 +1965,28 @@ class QuestionBankPage { switch (key) { case "title": - aValue = a.title.toLowerCase(); - bValue = b.title.toLowerCase(); + aValue = (a.title || "").toLowerCase(); + bValue = (b.title || "").toLowerCase(); break; case "glo": - aValue = a.glo.toLowerCase(); - bValue = b.glo.toLowerCase(); + aValue = (a.glo || "").toLowerCase(); + bValue = (b.glo || "").toLowerCase(); break; case "bloom": - aValue = a.bloom.toLowerCase(); - bValue = b.bloom.toLowerCase(); + aValue = (a.bloom || "").toLowerCase(); + bValue = (b.bloom || "").toLowerCase(); + break; + case "questionType": + aValue = (a.questionType || QUESTION_TYPES.MULTIPLE_CHOICE).toLowerCase(); + bValue = (b.questionType || QUESTION_TYPES.MULTIPLE_CHOICE).toLowerCase(); + break; + case "status": + aValue = (a.status || "Draft").toLowerCase(); + bValue = (b.status || "Draft").toLowerCase(); break; default: - aValue = a.title.toLowerCase(); - bValue = b.title.toLowerCase(); + aValue = (a.title || "").toLowerCase(); + bValue = (b.title || "").toLowerCase(); } if (dir === "asc") { @@ -2565,49 +2610,106 @@ class QuestionBankPage { // Check if user can edit this question const canEdit = this.canEditQuestion(questionId); - // Options are always objects with keys A, B, C, D - convert to array for display - const optionKeys = ['A', 'B', 'C', 'D']; - let normalizedOptions = []; - if (question.options && typeof question.options === 'object') { - normalizedOptions = optionKeys.map((key) => { - const opt = question.options[key]; - if (typeof opt === 'string') { - return { id: key, text: opt }; - } else if (opt && typeof opt === 'object') { - return { id: opt.id || key, text: opt.text || opt }; - } else { - return { id: key, text: String(opt || '') }; - } - }); - } + const qType = normalizeQuestionTypeKey(question.questionType || question.type); - // Ensure we have at least 4 options - while (normalizedOptions.length < 4) { - normalizedOptions.push({ - id: String.fromCharCode(65 + normalizedOptions.length), - text: '' - }); - } - - // Store original question for comparison - // Convert correctAnswer to letter format (A, B, C, D) if it's a number - let correctAnswerLetter = question.correctAnswer; - if (typeof question.correctAnswer === 'number') { - correctAnswerLetter = ['A', 'B', 'C', 'D'][question.correctAnswer] || 'A'; - } else if (typeof question.correctAnswer === 'string') { - correctAnswerLetter = question.correctAnswer.toUpperCase(); + if (qType === QUESTION_TYPES.FILL_IN_THE_BLANK) { + const canonical = + question.correctAnswer != null ? String(question.correctAnswer).trim() : ""; + let acceptable = Array.isArray(question.acceptableAnswers) + ? question.acceptableAnswers.map((a) => String(a).trim()).filter(Boolean) + : []; + if (acceptable.length === 0 && canonical) { + acceptable = [canonical]; + } + this.currentEditingQuestion = { + id: questionId, + title: question.title || question.stem || "", + stem: question.stem || question.title || "", + questionType: QUESTION_TYPES.FILL_IN_THE_BLANK, + correctAnswer: canonical, + acceptableAnswers: acceptable, + canEdit, + learningObjectiveId: question.learningObjectiveId, + granularObjectiveId: question.granularObjectiveId, + }; + } else if (qType === QUESTION_TYPES.CALCULATION) { + const vars = Array.isArray(question.calculationVariables) ? question.calculationVariables : []; + const dec = + question.calculationAnswerDecimals !== undefined && question.calculationAnswerDecimals !== null + ? parseInt(question.calculationAnswerDecimals, 10) + : 2; + const rawTol = parseFloat(question.calculationAnswerTolerancePercent); + const tol = Number.isFinite(rawTol) ? Math.max(0, Math.min(100, rawTol)) : null; + this.currentEditingQuestion = { + id: questionId, + title: question.title || "", + stem: question.stem || "", + questionType: QUESTION_TYPES.CALCULATION, + calculationFormula: (question.calculationFormula || "").trim(), + calculationVariables: vars, + calculationAnswerDecimals: Number.isFinite(dec) ? dec : 2, + calculationAnswerTolerancePercent: tol, + canEdit, + learningObjectiveId: question.learningObjectiveId, + granularObjectiveId: question.granularObjectiveId, + }; + } else if (qType === QUESTION_TYPES.OPEN_ENDED) { + this.currentEditingQuestion = { + id: questionId, + title: question.title || "", + stem: question.stem || question.title || "", + questionType: QUESTION_TYPES.OPEN_ENDED, + openEndedSampleAnswer: String(question.openEndedSampleAnswer || "").trim(), + openEndedGradingCriteria: String(question.openEndedGradingCriteria || "").trim(), + canEdit, + learningObjectiveId: question.learningObjectiveId, + granularObjectiveId: question.granularObjectiveId, + }; } else { - correctAnswerLetter = 'A'; - } + // Multiple-choice: options are objects with keys A, B, C, D - convert to array for display + const optionKeys = ["A", "B", "C", "D"]; + let normalizedOptions = []; + if (question.options && typeof question.options === "object") { + normalizedOptions = optionKeys.map((key) => { + const opt = question.options[key]; + if (typeof opt === "string") { + return { id: key, text: opt }; + } else if (opt && typeof opt === "object") { + return { id: opt.id || key, text: opt.text || opt }; + } else { + return { id: key, text: String(opt || "") }; + } + }); + } - this.currentEditingQuestion = { - id: questionId, - title: question.title || question.stem || "", - stem: question.stem || question.title || "", - options: normalizedOptions, - correctAnswer: correctAnswerLetter, - canEdit: canEdit, - }; + while (normalizedOptions.length < 4) { + normalizedOptions.push({ + id: String.fromCharCode(65 + normalizedOptions.length), + text: "", + }); + } + + let correctAnswerLetter = question.correctAnswer; + if (typeof question.correctAnswer === "number") { + correctAnswerLetter = ["A", "B", "C", "D"][question.correctAnswer] || "A"; + } else if (typeof question.correctAnswer === "string") { + correctAnswerLetter = question.correctAnswer.toUpperCase(); + } else { + correctAnswerLetter = "A"; + } + + this.currentEditingQuestion = { + id: questionId, + title: question.title || question.stem || "", + stem: question.stem || question.title || "", + questionType: QUESTION_TYPES.MULTIPLE_CHOICE, + options: normalizedOptions, + correctAnswer: correctAnswerLetter, + canEdit, + learningObjectiveId: question.learningObjectiveId, + granularObjectiveId: question.granularObjectiveId, + }; + } // Render question in modal (uses this.currentEditingQuestion) this.renderQuestionInModal(); @@ -2628,7 +2730,6 @@ class QuestionBankPage { } renderQuestionInModal() { - // Use currentEditingQuestion which has normalized data (correctAnswer as letter, options as array) if (!this.currentEditingQuestion) { console.error("currentEditingQuestion not set"); return; @@ -2637,17 +2738,253 @@ class QuestionBankPage { const question = this.currentEditingQuestion; const modalBody = document.getElementById("question-modal-body"); const saveBtn = document.getElementById("question-modal-save"); + const modalTitleEl = document.getElementById("question-modal-title"); if (!modalBody) return; - // Get canEdit from currentEditingQuestion const canEdit = question.canEdit !== undefined ? question.canEdit : this.canEditQuestion(question.id); - // Get objective name if available - const objectiveName = question.learningObjectiveId - ? (this.objectivesMap.get(question.learningObjectiveId.toString()) || "Unknown Objective") - : (question.granularObjectiveId ? "Granular Objective" : "No Objective"); + if (modalTitleEl) { + modalTitleEl.textContent = canEdit ? "Edit question" : "View question"; + } + + const isCalc = question.questionType === QUESTION_TYPES.CALCULATION; + const isFib = question.questionType === QUESTION_TYPES.FILL_IN_THE_BLANK; + const isOpen = question.questionType === QUESTION_TYPES.OPEN_ENDED; + + if (isOpen) { + const isReadOnly = !canEdit; + const readonlyAttr = isReadOnly ? "readonly" : ""; + const readonlyClass = isReadOnly ? "readonly" : ""; + const readonlyStyle = isReadOnly ? "background-color: #f5f5f5; cursor: not-allowed;" : ""; + + const warningHtml = isReadOnly + ? '
This question is approved and cannot be edited.
' + : ""; + + const escapedTitle = (question.title || "").replace(/"/g, """).replace(/'/g, "'"); + const stemContent = escapeForTextareaContent(question.stem || ""); + const sampleContent = escapeForTextareaContent(question.openEndedSampleAnswer || ""); + const criteriaContent = escapeForTextareaContent(question.openEndedGradingCriteria || ""); + + modalBody.innerHTML = ` +
+ ${warningHtml} +
+ Open-ended +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ `; + + if (saveBtn) { + saveBtn.style.display = isReadOnly ? "none" : "inline-block"; + } + return; + } + + if (isCalc) { + const isReadOnly = !canEdit; + const readonlyAttr = isReadOnly ? "readonly" : ""; + const readonlyClass = isReadOnly ? "readonly" : ""; + const readonlyStyle = isReadOnly ? "background-color: #f5f5f5; cursor: not-allowed;" : ""; + + const warningHtml = isReadOnly + ? '
This question is approved and cannot be edited.
' + : ""; + + const escapedTitle = (question.title || "").replace(/"/g, """).replace(/'/g, "'"); + const stemContent = escapeForTextareaContent(question.stem || ""); + const formulaContent = escapeForTextareaContent(question.calculationFormula || ""); + const varsJson = escapeForTextareaContent( + JSON.stringify(question.calculationVariables || [], null, 2) + ); + const decVal = + question.calculationAnswerDecimals !== undefined && question.calculationAnswerDecimals !== null + ? String(question.calculationAnswerDecimals) + : "2"; + const tolRaw = parseFloat(question.calculationAnswerTolerancePercent); + const tolVal = Number.isFinite(tolRaw) ? String(Math.max(0, Math.min(100, tolRaw))) : ""; + + modalBody.innerHTML = ` +
+ ${warningHtml} +
+ Calculation +
+
+ + +
+
+ + +

Use {{variableName}} placeholders matching the variables JSON below.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ `; + + if (saveBtn) { + saveBtn.style.display = isReadOnly ? "none" : "inline-block"; + } + return; + } + + if (isFib) { + const isReadOnly = !canEdit; + const readonlyAttr = isReadOnly ? "readonly" : ""; + const readonlyClass = isReadOnly ? "readonly" : ""; + const readonlyStyle = isReadOnly ? "background-color: #f5f5f5; cursor: not-allowed;" : ""; + + const warningHtml = isReadOnly + ? '
This question is approved and cannot be edited.
' + : ""; + + const escapedTitle = (question.title || "").replace(/"/g, """).replace(/'/g, "'"); + const stemContent = escapeForTextareaContent(question.stem || question.title || ""); + const acceptableContent = escapeForTextareaContent( + (question.acceptableAnswers || []).join("\n") + ); + const escapedCorrectAttr = (question.correctAnswer || "") + .replace(/"/g, """) + .replace(/'/g, "'"); + + modalBody.innerHTML = ` +
+ ${warningHtml} +
+ Fill-in-the-blank +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ `; + + if (saveBtn) { + saveBtn.style.display = isReadOnly ? "none" : "inline-block"; + } + return; + } + // --- Multiple-choice layout --- // Options are already normalized to array format in currentEditingQuestion let optionsArray = question.options || []; if (!Array.isArray(optionsArray) || optionsArray.length === 0) { @@ -2814,10 +3151,235 @@ class QuestionBankPage { try { const titleInput = document.getElementById("question-modal-title-input"); const stemInput = document.getElementById("question-modal-stem-input"); - const optionInputs = document.querySelectorAll(".question-modal-option-input"); const title = titleInput ? titleInput.value.trim() : ""; const stem = stemInput ? stemInput.value.trim() : ""; + + if (this.currentEditingQuestion.questionType === QUESTION_TYPES.CALCULATION) { + if (!title) { + this.showNotification("Topic title is required", "error"); + if (saveBtn) saveBtn.disabled = false; + return; + } + if (!stem) { + this.showNotification("Question template is required", "error"); + if (saveBtn) saveBtn.disabled = false; + return; + } + const formulaEl = document.getElementById("question-modal-calc-formula"); + const varsEl = document.getElementById("question-modal-calc-vars"); + const decEl = document.getElementById("question-modal-calc-decimals"); + const tolEl = document.getElementById("question-modal-calc-tolerance"); + const formula = formulaEl ? formulaEl.value.trim() : ""; + if (!formula) { + this.showNotification("Formula is required", "error"); + if (saveBtn) saveBtn.disabled = false; + return; + } + let variables; + try { + variables = JSON.parse(varsEl && varsEl.value.trim() ? varsEl.value.trim() : "[]"); + } catch { + this.showNotification("Variables must be valid JSON", "error"); + if (saveBtn) saveBtn.disabled = false; + return; + } + if (!Array.isArray(variables) || variables.length === 0) { + this.showNotification("Add at least one variable in the JSON array", "error"); + if (saveBtn) saveBtn.disabled = false; + return; + } + for (const v of variables) { + if (!v || typeof v.name !== "string" || !v.name.trim()) { + this.showNotification('Each variable needs a "name" string', "error"); + if (saveBtn) saveBtn.disabled = false; + return; + } + const mn = Number(v.min); + const mx = Number(v.max); + if (!Number.isFinite(mn) || !Number.isFinite(mx) || mn > mx) { + this.showNotification(`Invalid min/max for variable "${v.name}"`, "error"); + if (saveBtn) saveBtn.disabled = false; + return; + } + } + let dec = parseInt(decEl ? decEl.value : "2", 10); + if (!Number.isFinite(dec)) dec = 2; + dec = Math.max(0, Math.min(12, dec)); + let tolPct = parseFloat(tolEl ? tolEl.value : ""); + tolPct = Number.isFinite(tolPct) ? Math.max(0, Math.min(100, tolPct)) : null; + + const updateData = { + title, + stem, + questionType: QUESTION_TYPES.CALCULATION, + calculationFormula: formula, + calculationVariables: variables, + calculationAnswerDecimals: dec, + calculationAnswerTolerancePercent: tolPct, + options: {}, + acceptableAnswers: [], + }; + + const response = await fetch(`${API_ENDPOINTS.question}/${this.currentEditingQuestion.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updateData), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to update question"); + } + + const q = this.questions.find((x) => toStringId(x.id) === toStringId(this.currentEditingQuestion.id)); + if (q) { + q.title = updateData.title; + q.stem = updateData.stem; + q.questionType = QUESTION_TYPES.CALCULATION; + } + + this.closeQuestionModal(); + this.renderQuestionsTable(); + this.showNotification("Question updated successfully", "success"); + if (saveBtn) saveBtn.disabled = false; + return; + } + + if (this.currentEditingQuestion.questionType === QUESTION_TYPES.OPEN_ENDED) { + if (!title) { + this.showNotification("Topic title is required", "error"); + if (saveBtn) saveBtn.disabled = false; + return; + } + if (!stem) { + this.showNotification("Question prompt is required", "error"); + if (saveBtn) saveBtn.disabled = false; + return; + } + const sampleEl = document.getElementById("question-modal-open-sample"); + const critEl = document.getElementById("question-modal-open-criteria"); + const openEndedSampleAnswer = sampleEl ? sampleEl.value.trim() : ""; + const openEndedGradingCriteria = critEl ? critEl.value.trim() : ""; + if (!openEndedSampleAnswer) { + this.showNotification("Sample answer is required for open-ended questions", "error"); + if (saveBtn) saveBtn.disabled = false; + return; + } + if (!openEndedGradingCriteria) { + this.showNotification("Grading criteria are required for open-ended questions", "error"); + if (saveBtn) saveBtn.disabled = false; + return; + } + + const updateData = { + title, + stem, + questionType: QUESTION_TYPES.OPEN_ENDED, + openEndedSampleAnswer, + openEndedGradingCriteria, + options: {}, + acceptableAnswers: [], + correctAnswer: "", + }; + + const response = await fetch(`${API_ENDPOINTS.question}/${this.currentEditingQuestion.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updateData), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to update question"); + } + + const q = this.questions.find((x) => toStringId(x.id) === toStringId(this.currentEditingQuestion.id)); + if (q) { + q.title = updateData.title; + q.stem = updateData.stem; + q.questionType = QUESTION_TYPES.OPEN_ENDED; + } + + this.closeQuestionModal(); + this.renderQuestionsTable(); + this.showNotification("Question updated successfully", "success"); + if (saveBtn) saveBtn.disabled = false; + return; + } + + if (this.currentEditingQuestion.questionType === QUESTION_TYPES.FILL_IN_THE_BLANK) { + if (!title) { + this.showNotification("Topic title is required", "error"); + if (saveBtn) saveBtn.disabled = false; + return; + } + if (!stem) { + this.showNotification("Question stem is required", "error"); + if (saveBtn) saveBtn.disabled = false; + return; + } + if (!stem.includes("_________")) { + this.showNotification('Stem must include exactly one blank: _________ (nine underscores)', "error"); + if (saveBtn) saveBtn.disabled = false; + return; + } + + const correctInput = document.getElementById("question-modal-fib-correct"); + const acceptableInput = document.getElementById("question-modal-fib-acceptable"); + const correct = correctInput ? correctInput.value.trim() : ""; + if (!correct) { + this.showNotification("Correct answer is required", "error"); + if (saveBtn) saveBtn.disabled = false; + return; + } + + let acceptable = []; + if (acceptableInput && acceptableInput.value.trim()) { + acceptable = acceptableInput.value + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); + } + if (acceptable.length === 0) { + acceptable = [correct]; + } + + const updateData = { + title, + stem, + questionType: QUESTION_TYPES.FILL_IN_THE_BLANK, + correctAnswer: correct, + acceptableAnswers: acceptable, + options: {}, + }; + + const response = await fetch(`${API_ENDPOINTS.question}/${this.currentEditingQuestion.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updateData), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to update question"); + } + + const q = this.questions.find((x) => toStringId(x.id) === toStringId(this.currentEditingQuestion.id)); + if (q) { + q.title = updateData.title; + q.stem = updateData.stem; + q.questionType = QUESTION_TYPES.FILL_IN_THE_BLANK; + } + + this.closeQuestionModal(); + this.renderQuestionsTable(); + this.showNotification("Question updated successfully", "success"); + return; + } + + const optionInputs = document.querySelectorAll(".question-modal-option-input"); + const options = Array.from(optionInputs).map((input, index) => { const optionId = String.fromCharCode(65 + parseInt(input.dataset.optionIndex || index)); return { @@ -2869,6 +3431,7 @@ class QuestionBankPage { const updateData = { title: title || stem, stem: stem || title, + questionType: QUESTION_TYPES.MULTIPLE_CHOICE, options: optionsObject, correctAnswer: correctAnswerLetter, }; diff --git a/public/scripts/question-generation.js b/public/scripts/question-generation.js index 59ad120..31cea8c 100644 --- a/public/scripts/question-generation.js +++ b/public/scripts/question-generation.js @@ -87,7 +87,7 @@ document.addEventListener('DOMContentLoaded', async function () { initializeNavigation(); initializeEventListeners(); - initializeModules(); + await initializeModules(); await loadCourseData(); await checkCourseMaterials(); updateUI(); @@ -151,14 +151,30 @@ async function checkCourseMaterials() { } } -function initializeModules() { +async function fetchBloomTypePreferences() { + try { + const selectedCourse = JSON.parse(sessionStorage.getItem('grasp-selected-course') || '{}'); + const courseId = selectedCourse.id; + if (!courseId) return null; + const response = await fetch(`/api/courses/${courseId}/settings`); + const data = await response.json(); + return data.success && data.settings && data.settings.bloomTypePreferences + ? data.settings.bloomTypePreferences + : null; + } catch { + return null; + } +} + +async function initializeModules() { try { if (window.ContentGenerator) { contentGenerator = new window.ContentGenerator(); } if (window.QuestionGenerator && contentGenerator) { - questionGenerator = new window.QuestionGenerator(contentGenerator); + const bloomTypePreferences = await fetchBloomTypePreferences(); + questionGenerator = new window.QuestionGenerator(contentGenerator, { bloomTypePreferences }); } } catch (error) { console.error('Error initializing modules:', error); @@ -2901,7 +2917,8 @@ async function generateQuestionsFromContent() { if (!contentGenerator) { contentGenerator = new window.ContentGenerator(); } - questionGenerator = new window.QuestionGenerator(contentGenerator); + const bloomTypePreferences = await fetchBloomTypePreferences(); + questionGenerator = new window.QuestionGenerator(contentGenerator, { bloomTypePreferences }); } catch (error) { console.error('Failed to initialize QuestionGenerator:', error); setGenerationUI(false); @@ -3013,53 +3030,174 @@ function convertQuestionsToGroups(questions) { id: index + 1, title: metaCode, isOpen: true, // Open all panels by default when generating for multiple learning objectives - los: groupQuestions.map((question, itemIndex) => ({ - id: `lo-${index + 1}-${itemIndex + 1}`, - code: `LO ${index + 1}.${itemIndex + 1}`, - generated: question.count || 1, - min: 1, - badges: [], - questions: [ - { - id: question.id, - title: question.text, - stem: "Select the best answer:", - options: { - A: { - id: "A", - text: question.options?.A || "Option A", - feedback: `${question.correctAnswer === 'A' ? "Correct" : "Incorrect"} - ${question.explanation}`, - }, - B: { - id: "B", - text: question.options?.B || "Option B", - feedback: `${question.correctAnswer === 'B' ? "Correct" : "Incorrect"} - ${question.explanation}`, - }, - C: { - id: "C", - text: question.options?.C || "Option C", - feedback: `${question.correctAnswer === 'C' ? "Correct" : "Incorrect"} - ${question.explanation}`, - }, - D: { - id: "D", - text: question.options?.D || "Option D", - feedback: `${question.correctAnswer === 'D' ? "Correct" : "Incorrect"} - ${question.explanation}`, - }, + los: groupQuestions.map((question, itemIndex) => { + const qType = + question.type || question.questionType || QUESTION_TYPES.MULTIPLE_CHOICE; + const isFib = qType === QUESTION_TYPES.FILL_IN_THE_BLANK; + const isCalc = qType === QUESTION_TYPES.CALCULATION; + const isOpen = qType === QUESTION_TYPES.OPEN_ENDED; + const acceptable = + isFib && + Array.isArray(question.acceptableAnswers) && + question.acceptableAnswers.length + ? question.acceptableAnswers + : isFib && question.correctAnswer != null + ? [String(question.correctAnswer)] + : []; + + const mcCard = { + id: question.id, + title: question.text, + stem: "Select the best answer:", + questionType: QUESTION_TYPES.MULTIPLE_CHOICE, + options: { + A: { + id: "A", + text: question.options?.A || "Option A", + feedback: `${question.correctAnswer === "A" ? "Correct" : "Incorrect"} - ${question.explanation}`, + }, + B: { + id: "B", + text: question.options?.B || "Option B", + feedback: `${question.correctAnswer === "B" ? "Correct" : "Incorrect"} - ${question.explanation}`, + }, + C: { + id: "C", + text: question.options?.C || "Option C", + feedback: `${question.correctAnswer === "C" ? "Correct" : "Incorrect"} - ${question.explanation}`, + }, + D: { + id: "D", + text: question.options?.D || "Option D", + feedback: `${question.correctAnswer === "D" ? "Correct" : "Incorrect"} - ${question.explanation}`, }, - correctAnswer: question.correctAnswer, - bloom: question.bloomLevel || "Understand", - difficulty: question.difficulty || "Medium", - status: "Draft", - lastEdited: - question.lastEdited || - new Date().toISOString().slice(0, 16).replace("T", " "), - by: question.by || "System", - metaCode: question.metaCode || metaCode, - loCode: question.loCode || question.text, - granularObjectiveId: question.granularObjectiveId, }, - ], - })), + correctAnswer: question.correctAnswer, + acceptableAnswers: [], + bloom: question.bloomLevel || "Understand", + difficulty: question.difficulty || "Medium", + status: "Draft", + lastEdited: + question.lastEdited || + new Date().toISOString().slice(0, 16).replace("T", " "), + by: question.by || "System", + metaCode: question.metaCode || metaCode, + loCode: question.loCode || question.text, + granularObjectiveId: question.granularObjectiveId, + }; + + const fibCard = { + id: question.id, + title: + (question.topicTitle && String(question.topicTitle).trim()) || + (() => { + const before = String(question.text || "") + .split("_________")[0] + .trim(); + const words = before.split(/\s+/).filter(Boolean); + return words.slice(0, 10).join(" ") || "Fill-in-the-blank"; + })(), + stem: question.text, + questionType: QUESTION_TYPES.FILL_IN_THE_BLANK, + options: {}, + correctAnswer: question.correctAnswer, + acceptableAnswers: acceptable, + bloom: question.bloomLevel || "Understand", + difficulty: question.difficulty || "Medium", + status: "Draft", + lastEdited: + question.lastEdited || + new Date().toISOString().slice(0, 16).replace("T", " "), + by: question.by || "System", + metaCode: question.metaCode || metaCode, + loCode: question.loCode || question.text, + granularObjectiveId: question.granularObjectiveId, + explanation: question.explanation, + }; + + const stemCalc = String(question.text || question.stem || "").trim(); + const calcCard = { + id: question.id, + title: + (question.topicTitle && String(question.topicTitle).trim()) || + (() => { + const before = stemCalc.split("{{")[0].trim(); + const words = before.split(/\s+/).filter(Boolean); + return words.slice(0, 10).join(" ") || "Calculation"; + })(), + stem: stemCalc, + questionType: QUESTION_TYPES.CALCULATION, + options: {}, + correctAnswer: "", + acceptableAnswers: [], + calculationFormula: question.calculationFormula || "", + calculationVariables: Array.isArray(question.calculationVariables) + ? question.calculationVariables + : [], + calculationAnswerDecimals: + question.calculationAnswerDecimals != null + ? question.calculationAnswerDecimals + : 2, + bloom: question.bloomLevel || "Understand", + difficulty: question.difficulty || "Medium", + status: "Draft", + lastEdited: + question.lastEdited || + new Date().toISOString().slice(0, 16).replace("T", " "), + by: question.by || "System", + metaCode: question.metaCode || metaCode, + loCode: question.loCode || question.text, + granularObjectiveId: question.granularObjectiveId, + explanation: question.explanation, + }; + + const stemOpen = String(question.text || question.stem || "").trim(); + const openCard = { + id: question.id, + title: + (question.topicTitle && String(question.topicTitle).trim()) || + (() => { + const words = stemOpen.split(/\s+/).filter(Boolean); + return words.slice(0, 10).join(" ") || "Open-ended"; + })(), + stem: stemOpen, + questionType: QUESTION_TYPES.OPEN_ENDED, + options: {}, + correctAnswer: "", + acceptableAnswers: [], + openEndedSampleAnswer: String( + question.openEndedSampleAnswer || "" + ).trim(), + openEndedGradingCriteria: String( + question.openEndedGradingCriteria || "" + ).trim(), + bloom: question.bloomLevel || "Understand", + difficulty: question.difficulty || "Medium", + status: "Draft", + lastEdited: + question.lastEdited || + new Date().toISOString().slice(0, 16).replace("T", " "), + by: question.by || "System", + metaCode: question.metaCode || metaCode, + loCode: question.loCode || question.text, + granularObjectiveId: question.granularObjectiveId, + explanation: question.explanation, + }; + + let card = mcCard; + if (isFib) card = fibCard; + else if (isCalc) card = calcCard; + else if (isOpen) card = openCard; + + return { + id: `lo-${index + 1}-${itemIndex + 1}`, + code: `LO ${index + 1}.${itemIndex + 1}`, + generated: question.count || 1, + min: 1, + badges: [], + questions: [card], + }; + }), }; groups.push(group); @@ -3171,6 +3309,20 @@ function toggleMetaLoGroup(groupId) { // Make function available globally for onclick handlers window.toggleMetaLoGroup = toggleMetaLoGroup; +function escapeQuestionHtml(str) { + return String(str ?? "") + .replace(/&/g, "&") + .replace(//g, ">"); +} + +function escapeQuestionAttr(str) { + return String(str ?? "") + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + function renderGranularLoSection(lo, group) { return `
@@ -3199,44 +3351,165 @@ function renderGranularLoSection(lo, group) { function renderQuestionCard(question, group) { const isEditing = question.isEditing || false; - - return ` -
-
-
- ${isEditing - ? `` - : `
${question.title}
` - } + const isFib = + (question.questionType || question.type) === QUESTION_TYPES.FILL_IN_THE_BLANK; + const isCalc = + (question.questionType || question.type) === QUESTION_TYPES.CALCULATION; + const isOpen = + (question.questionType || question.type) === QUESTION_TYPES.OPEN_ENDED; + + const titleEditingHtml = + (isFib || isCalc || isOpen) && isEditing + ? `` + : isEditing + ? `` + : `
${escapeQuestionHtml(question.title)}
`; + + const chipsHtml = `
- ${question.metaCode - } - ${question.loCode - } - Bloom: ${question.bloom - } + ${isFib ? `Fill-in-the-blank` : ""} + ${isCalc ? `Calculation` : ""} + ${isOpen ? `Open-ended` : ""} + ${question.metaCode} + ${question.loCode} + Bloom: ${question.bloom} +
`; + + let bodyHtml; + if (isFib) { + const acc = Array.isArray(question.acceptableAnswers) + ? question.acceptableAnswers.map((a) => String(a).trim()).filter(Boolean) + : []; + const canonical = String(question.correctAnswer ?? "").trim(); + const altAccepted = acc.filter( + (a) => a.toLowerCase() !== canonical.toLowerCase() + ); + if (isEditing) { + const accTextarea = acc.length ? acc.join("\n") : canonical; + bodyHtml = ` +
+ + + + + + +
`; + } else { + bodyHtml = ` +
+
+ Question stem +

${escapeQuestionHtml(question.stem || "")}

-
- -
+ ${altAccepted.length + ? `
+ Also accepted +

${escapeQuestionHtml(altAccepted.join(", "))}

+
` + : "" + } +
`; + } + } else if (isCalc) { + const vars = Array.isArray(question.calculationVariables) + ? question.calculationVariables + : []; + const varsJson = JSON.stringify(vars, null, 2); + const dec = + question.calculationAnswerDecimals != null + ? question.calculationAnswerDecimals + : 2; + const varsSummary = vars + .map((v) => { + if (!v || typeof v !== "object") return ""; + const n = escapeQuestionHtml(String(v.name ?? "")); + const range = `${escapeQuestionHtml(String(v.min))}–${escapeQuestionHtml(String(v.max))}`; + const mode = v.integerOnly + ? " (integers)" + : ` (decimals: ${escapeQuestionHtml(String(v.decimals ?? 0))})`; + return `
  • ${n}: ${range}${mode}
  • `; + }) + .filter(Boolean) + .join(""); + if (isEditing) { + bodyHtml = ` +
    + + + + + + + + +
    `; + } else { + bodyHtml = ` +
    +
    + Template +

    ${escapeQuestionHtml(question.stem || "")}

    +
    +
    + Formula +

    ${escapeQuestionHtml(question.calculationFormula || "")}

    +
    +
    + Variables + ${varsSummary ? `
      ${varsSummary}
    ` : `

    `} +
    +
    + Answer decimal places +

    ${escapeQuestionHtml(String(dec))}

    +
    +
    `; + } + } else if (isOpen) { + if (isEditing) { + bodyHtml = ` +
    + + + + + + +
    `; + } else { + bodyHtml = ` +
    +
    + Prompt +

    ${escapeQuestionHtml(question.stem || "")}

    +
    +
    + Sample answer +

    ${escapeQuestionHtml(question.openEndedSampleAnswer || "")}

    +
    +
    + Grading criteria +

    ${escapeQuestionHtml(question.openEndedGradingCriteria || "")}

    +
    +
    `; + } + } else { + bodyHtml = ` ${isEditing - ? `` + ? `` : `

    ${question.stem}

    ` }
    - ${Object.values(question.options).map( + ${Object.values(question.options || {}).map( (option, index) => { - // correctAnswer is now a letter (A, B, C, D), compare with option.id - const isCorrect = option.id === question.correctAnswer || - (typeof question.correctAnswer === 'number' && index === question.correctAnswer); + const isCorrect = + option.id === question.correctAnswer || + (typeof question.correctAnswer === "number" && + index === question.correctAnswer); return `
    @@ -3244,7 +3517,7 @@ function renderQuestionCard(question, group) { }" value="${option.id}" ${isCorrect ? "checked" : "" } disabled> ${isEditing - ? `` + ? `/g, ">").replace(/"/g, """).replace(/'/g, "'")}" onblur="saveOptionEdit('${question.id}', '${option.id}', this.value)">` : `` }
    @@ -3253,7 +3526,26 @@ function renderQuestionCard(question, group) { } ) .join("")} +
    `; + } + + return ` +
    +
    +
    + ${titleEditingHtml} + ${chipsHtml}
    + +
    +
    + ${bodyHtml}
    @@ -99,6 +191,7 @@

    LLM Prompts

    + \ No newline at end of file diff --git a/public/styles/question-bank.css b/public/styles/question-bank.css index babaca6..1d66613 100644 --- a/public/styles/question-bank.css +++ b/public/styles/question-bank.css @@ -1265,6 +1265,35 @@ text-align: center; } +.question-type-cell { + text-align: center; + white-space: nowrap; +} + +.question-type-chip { + display: inline-block; + padding: 4px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + font-family: 'Open Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; +} + +.question-type-chip--multiple-choice { + background: #f3e5f5; + color: #7b1fa2; +} + +.question-type-chip--fill-in-the-blank { + background: #e0f2f1; + color: #00796b; +} + +.question-type-chip--open-ended { + background: #e3f2fd; + color: #1565c0; +} + /* Edit Mode */ .question-title-input { diff --git a/public/styles/question-generation.css b/public/styles/question-generation.css index ed2fa3a..3fe1152 100644 --- a/public/styles/question-generation.css +++ b/public/styles/question-generation.css @@ -1243,6 +1243,85 @@ border-color: #388e3c; } +.question-card__chip--fib { + background: #e0f2f1; + color: #00796b; + border-color: #00796b; +} + +.question-card__title-slot--fib { + margin-bottom: 4px; +} + +.question-card__fib-card-heading { + font-size: 15px; + font-weight: 600; + color: #00796b; + font-family: 'Open Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; +} + +.question-card__fib-display { + display: flex; + flex-direction: column; + gap: 14px; +} + +.question-card__fib-block { + display: flex; + flex-direction: column; + gap: 6px; +} + +.question-card__fib-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #6c757d; + font-family: 'Open Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; +} + +.question-card__fib-value { + font-size: 14px; + color: #2c3e50; + margin: 0; + line-height: 1.5; + font-family: 'Open Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; +} + +.question-card__fib-edit { + display: flex; + flex-direction: column; + gap: 10px; +} + +.question-card__fib-edit .question-card__fib-label { + margin-top: 4px; +} + +.question-card__fib-hint { + font-weight: 400; + text-transform: none; + letter-spacing: normal; + color: #95a5a6; + font-size: 12px; +} + +.question-card__fib-input { + width: 100%; + padding: 8px 10px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 14px; + font-family: 'Open Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + box-sizing: border-box; +} + +.question-card__fib-question-input { + min-height: 100px; + margin-bottom: 4px; +} + .question-card__metadata { display: flex; flex-direction: column; diff --git a/public/styles/quiz.css b/public/styles/quiz.css index b0e35c7..9500cc4 100644 --- a/public/styles/quiz.css +++ b/public/styles/quiz.css @@ -449,7 +449,8 @@ .feedback-message { display: flex; - align-items: center; + flex-direction: column; + align-items: flex-start; gap: 10px; font-size: 18px; font-weight: 600; @@ -458,6 +459,20 @@ margin-bottom: 15px; } +.feedback-message__title { + display: flex; + align-items: center; + gap: 10px; +} + +.feedback-message__body { + font-weight: 400; + font-size: 0.9em; + color: #555; + width: 100%; + line-height: 1.45; +} + .feedback-correct { background: #d4edda; color: #155724; @@ -470,7 +485,29 @@ border: 2px solid #dc3545; } -.feedback-message i { +.feedback-open-ended { + background: #e8f4fc; + color: #0c5460; + border: 2px solid #3498db; +} + +.feedback-message__subtitle { + font-weight: 600; + font-size: 0.95em; + margin: 12px 0 6px; + color: #2c3e50; +} + +.feedback-message__section:first-of-type .feedback-message__subtitle { + margin-top: 8px; +} + +.feedback-message__pre { + white-space: pre-wrap; + word-break: break-word; +} + +.feedback-message__title i { font-size: 24px; } @@ -560,6 +597,10 @@ background: #dc3545; } +.question-indicator.submitted { + background: #17a2b8; +} + /* Completion Section */ .completion-section { padding: 60px 40px; @@ -680,6 +721,110 @@ background: #229954; } +/* Fill-in-the-blank (student quiz) */ +.fill-in-blank-answer { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 560px; + margin-top: 8px; +} + +.fill-in-blank-label { + font-weight: 600; + color: #2c3e50; + font-size: 0.95rem; +} + +.fill-in-blank-input { + width: 100%; + padding: 12px 14px; + font-size: 1rem; + border: 2px solid #dfe6e9; + border-radius: 8px; + box-sizing: border-box; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.fill-in-blank-input:focus:not(:disabled) { + outline: none; + border-color: #3498db; + box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2); +} + +.fill-in-blank-input:disabled { + background: #f8f9fa; + color: #2c3e50; +} + +.fill-in-blank-submit { + align-self: flex-start; + padding: 12px 22px; + background: #3498db; + color: white; + border: none; + border-radius: 8px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.fill-in-blank-submit:hover:not(:disabled) { + background: #2980b9; +} + +.fill-in-blank-submit:disabled { + cursor: not-allowed; + opacity: 0.75; +} + +.fill-in-blank-answer--correct .fill-in-blank-input { + border-color: #27ae60; + background: #eafaf1; +} + +.fill-in-blank-answer--incorrect .fill-in-blank-input { + border-color: #e74c3c; + background: #fdedec; +} + +.calc-load-error { + color: #721c24; + font-size: 15px; + margin: 0; +} + +.open-ended-textarea { + width: 100%; + min-height: 120px; + padding: 12px 14px; + font-size: 1rem; + font-family: inherit; + line-height: 1.45; + border: 2px solid #dfe6e9; + border-radius: 8px; + box-sizing: border-box; + resize: vertical; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.open-ended-textarea:focus:not(:disabled) { + outline: none; + border-color: #3498db; + box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2); +} + +.open-ended-textarea:disabled { + background: #f8f9fa; + color: #2c3e50; +} + +.open-ended-answer--submitted .open-ended-textarea { + border-color: #17a2b8; + background: #e8f6f8; +} + /* Responsive Design */ @media (max-width: 768px) { .quiz-grid { diff --git a/public/styles/settings.css b/public/styles/settings.css index 8a59f61..3750cf3 100644 --- a/public/styles/settings.css +++ b/public/styles/settings.css @@ -265,4 +265,111 @@ code { @keyframes slideIn { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } +} + +/* Bloom Type Preferences Table */ +.bloom-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1.5rem; + font-size: 0.95rem; +} + +.bloom-table thead th { + text-align: left; + padding: 0.75rem 1rem; + font-weight: 600; + color: #64748b; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 2px solid #f0f4f8; +} + +.bloom-table tbody tr { + border-bottom: 1px solid #f1f5f9; + transition: background 0.15s; +} + +.bloom-table tbody tr:last-child { + border-bottom: none; +} + +.bloom-table tbody tr:hover { + background: #f8fafc; +} + +.bloom-table td { + padding: 0.85rem 1rem; + vertical-align: middle; +} + +.bloom-default { + color: #94a3b8; + font-size: 0.875rem; +} + +/* Bloom level badges */ +.bloom-badge { + display: inline-block; + padding: 0.3rem 0.75rem; + border-radius: 20px; + font-size: 0.82rem; + font-weight: 600; +} + +.bloom-remember { background: #ede9fe; color: #6d28d9; } +.bloom-understand { background: #dbeafe; color: #1d4ed8; } +.bloom-apply { background: #d1fae5; color: #065f46; } +.bloom-analyze { background: #fef3c7; color: #92400e; } +.bloom-evaluate { background: #ffe4e6; color: #be123c; } +.bloom-create { background: #fce7f3; color: #9d174d; } + +/* Bloom dropdown */ +.bloom-select { + padding: 0.5rem 0.75rem; + border: 1.5px solid #e2e8f0; + border-radius: 7px; + font-family: inherit; + font-size: 0.9rem; + color: #334155; + background: white; + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s; + min-width: 190px; +} + +.bloom-select:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +/* Bloom actions row */ +.bloom-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding-top: 0.5rem; +} + +.secondary-btn { + background: white; + color: #475569; + border: 1.5px solid #e2e8f0; + padding: 0.65rem 1.25rem; + border-radius: 8px; + font-weight: 600; + font-size: 0.9rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + transition: all 0.2s; +} + +.secondary-btn:hover { + border-color: #94a3b8; + background: #f8fafc; + color: #1e293b; } \ No newline at end of file diff --git a/src/constants/app-constants.js b/src/constants/app-constants.js index b641cb8..8a5277e 100644 --- a/src/constants/app-constants.js +++ b/src/constants/app-constants.js @@ -2,41 +2,197 @@ * Application-wide default prompt constants */ -const QUESTION_GENERATION_PROMPT = `You are an university instructor. Generate a high-quality multiple-choice question based on the provided content that effectively test students' understanding of the course learning objective. +const SUBTOPIC_DETERMINATION_PROMPT = `Identify the single most specific sub-topic to target for a question about the following learning objective. +Course: {courseName} +Learning Objective: {learningObjectiveText} +Granular Learning Objective: {granularLearningObjectiveText} + +Return ONLY this JSON with no other text: +{"subtopic": "the specific concept directly named or implied by the granular objective (e.g. arithmetic sequences, geometric series, Newton's second law)"}`; + +const QUESTION_GENERATION_PROMPT = `You are an university instructor. Generate a high-quality question based on the provided content that effectively test students' understanding of the course learning objective. + +Course: {courseName} Learning Objective: {learningObjectiveText} Granular Learning Objective: {granularLearningObjectiveText} Bloom's Taxonomy Level(s): {bloomLevel} +Question Type: {questionType} +Sub-topic: {subtopic} -Task: Create a multiple-choice question based on the provided content that effectively test students' understanding of the course learning objective. +Bloom's level guidance: +- Remember: recall a definition or fact +- Understand: explain, paraphrase, or classify in own words +- Apply: use a concept or formula in a novel scenario +- Analyze: compare, differentiate, or break down components +- Evaluate: justify, critique, or defend a choice +- Create: design, construct, or propose something new -PROCEDURE: -1. Create the question content -2. Generate 4 plausible answer options, placing the CORRECT answer text in one of the positions (A, B, C, or D). -3. Set correctAnswer to the letter corresponding to the correct option (e.g. "C"). -4. Write the explanation +BACKGROUND COURSE MATERIAL: +{ragContext} +--- END OF MATERIAL --- + +The question MUST be specifically about the sub-topic above — ignore unrelated content in the materials. Where applicable, draw on exercises or worked examples from the materials that relate to that sub-topic. + +Use ONLY the schema for the Question Type specified above. -The response format must be a valid JSON with the exact structure as follows: +--- If Question Type is "multiple-choice" --- +INSTRUCTIONS: +1. Write a question stem aligned to the learning objective and Bloom's level. + If you have already generated questions in this conversation, the new stem + must approach the concept from a structurally different angle. +2. Generate 4 answer options (A-D). Every option must be unique — no two options + may describe the same concept in different words. +3. Every distractor must represent a genuine misconception a student might hold, + not an obviously wrong or trivially absurd option. +4. Set correctAnswer to the letter of the correct option. +5. For each incorrect option, write feedback that explains the specific + misconception in that option only. Feedback must NOT hint at the correct + answer, must NOT use comparative language ("partially right", "too large"), + and must NOT restate the correct reasoning. + +Example structure (do NOT copy — generate content from the material above): { - "question": "Your specific question here", + "question": "A student applies [concept] to [scenario]. What is the result?", "options": { - "A": "First option text", - "B": "Second option text", - "C": "Third option text", - "D": "Fourth option text" + "A": { "text": "Incorrect option based on misconception X", "feedback": "This confuses [concept A] with [concept B]." }, + "B": { "text": "Correct option", "feedback": "" }, + "C": { "text": "Incorrect option based on misconception Y", "feedback": "This applies the right method to the wrong quantity." }, + "D": { "text": "Incorrect option based on misconception Z", "feedback": "This reverses the relationship between the two variables." } }, - "correctAnswer": "C", - "explanation": "Why this answer is correct based on the content" + "correctAnswer": "B" +} + +--- If Question Type is "fill-in-the-blank" --- +INSTRUCTIONS: +1. "topicTitle": a short neutral label (3-10 words) naming the topic. Not a question, no "?", must not reveal the answer. +2. "question": one unfinished DECLARATIVE sentence with exactly ONE blank written as _________ (nine underscores). Forbidden openings: "What is", "Which", "How", "Define", any question ending in "?". +3. "correctAnswer": the canonical text that fills the blank. Use LaTeX \\( ... \\) for math (backslashes escaped for JSON). +4. "acceptableAnswers": array including correctAnswer plus reasonable equivalents (alternate notation, synonyms). +5. Do NOT include an "options" object. + +Example: +{ + "topicTitle": "Volume of a cone", + "question": "The formula for the volume of a cone is _________.", + "correctAnswer": "\\\\( \\\\frac{1}{3}\\\\pi r^2 h \\\\)", + "acceptableAnswers": ["\\\\( \\\\frac{1}{3}\\\\pi r^2 h \\\\)", "1/3πr^2h"], + "explanation": "Brief justification from the content." +} + +--- If Question Type is "calculation" --- +You are authoring a parameterised question. The server samples random values for +each variable, substitutes {{name}} in the stem, and evaluates calculationFormula +to compute the correct answer. + +VARIABLE NAMING RULE (most common cause of rejection): +Use ONLY single-letter variable names: a, b, c, d, m, n, r, t, v, x, y, z. +Do NOT use words like "mass", "velocity", "radius", "time". The formula evaluator +requires the name in calculationFormula to be byte-for-byte identical to the name +in calculationVariables and in the {{name}} placeholder in stem. + +RULES (violations cause immediate rejection): +1. Use at most 3 variables. Fewer is better. +2. Every variable must appear in "stem" as {{name}} (double curly braces, exact + match to its "name" in calculationVariables). No other placeholder style + ({name}, [name], (name)) is accepted. +3. calculationFormula must be ONE pure ASCII arithmetic expression using only: + + - * / ^ ( ) digits, variable names, functions sin cos tan sqrt log exp, + constants PI E. No LaTeX, no Unicode math symbols (∫ ∑ π ℯ), no = sign. +4. Every declared variable must appear in BOTH stem (as {{name}}) AND in + calculationFormula by the exact same single-letter name. Conversely, every + {{name}} placeholder in the stem must be a declared variable — never put + expressions inside braces ({{n^2}}, {{2a}}, {{r^n}} are all invalid). +5. Prefer integerOnly: true for variables unless the domain genuinely requires + decimals (e.g. concentrations, probabilities). Integer ranges avoid rounding + ambiguity and produce cleaner random values. +6. Choose min/max so the formula never produces division by zero or sqrt of a + negative. If the formula contains 1/x, set min to 1 or higher. If it contains + sqrt(x), set min to 0 or higher. +7. For calculus objectives (Apply / Analyze / Evaluate): pre-solve the symbolic + math yourself and encode only the closed-form arithmetic result in + calculationFormula. Show the original problem in the stem using LaTeX for + display; the formula must be pure arithmetic. + - Derivative of ax^2+bx at x: formula "2*a*x + b" + - Definite integral ∫₀ᵇ ax² dx: formula "a * b^3 / 3" + - ODE y(t)=y₀e^(kt) at t: formula "y0 * E^(k*t)" with tolerancePercent 1 + If no simple closed form exists, reformulate to a directly computable sub-skill + (evaluate the integrand at a point, apply the power rule to one term). +8. The stem must ask for a specific numeric value. NEVER frame a calculation + question as convergent/divergent, yes/no, or true/false — those belong in + multiple-choice or fill-in-the-blank. For limit-based topics, ask "What is + the value of lim..." or "Evaluate..." and provide the closed-form numeric answer. +9. Do NOT use a variable to approximate a limit index (e.g. setting n to a large + range to simulate n→∞). Instead, express the limit's closed-form result as the + formula and randomize concrete domain parameters (first term, common ratio, etc.). +10. The formula must be algebraically non-trivial: every declared variable must + survive simplification and affect the computed result. If any variable cancels + out entirely (e.g. "a*(1-r)/(1-r)" reduces to just "a", losing r), the formula + is mathematically wrong — derive the correct expression before submitting. + +PROCEDURE: +1. "topicTitle": short neutral label (3-10 words), no "?", must not reveal the answer. +2. "stem": question text. Every variable appears as {{name}} (double braces). + Do NOT write numeric values inline — use the {{name}} placeholder instead. +3. "calculationFormula": ONE ASCII expression. References every declared variable. +4. "calculationVariables": 1-3 entries, each {"name": single letter, "min": number, + "max": number, "integerOnly": true} or {"name": single letter, "min": number, + "max": number, "decimals": 0-8}. Forbidden names: "pi", "PI", "e", "E". +5. "calculationAnswerDecimals": integer 0-12 (decimal places shown to the student). +6. "calculationAnswerTolerancePercent" (optional): 0-100 for percentage-band grading + (e.g. 2 for chemistry, 5 for engineering). Omit for exact decimal rounding. +7. "explanation": brief explanation of the formula. + +SELF-CHECK before returning JSON: +- Every name in calculationVariables appears in stem as {{name}} (double braces). +- Every {{name}} in the stem is a declared variable — no expressions inside braces. +- Every name in calculationVariables appears in calculationFormula by the exact same name. +- calculationFormula contains no LaTeX, no ∫ ∑, no = sign, no word-length names. +- min/max are numbers (not null). Formula stays finite across the declared ranges. +- The formula is non-trivial: mentally substitute mid-range values and confirm every variable affects the result (no variable cancels out). +- The stem asks for a computable number, not a convergence/divergence label. + +Example (structure only — derive your own formula and variables from the course content): +{ + "topicTitle": "Kinetic energy of a moving object", + "stem": "An object of mass {{m}} kg moves at {{v}} m/s. What is its kinetic energy in joules?", + "calculationFormula": "0.5 * m * v^2", + "calculationVariables": [ + { "name": "m", "min": 1, "max": 20, "integerOnly": true }, + { "name": "v", "min": 1, "max": 15, "integerOnly": true } + ], + "calculationAnswerDecimals": 1, + "explanation": "Kinetic energy is KE = 0.5 mv², where m is mass and v is speed." +} + +Do NOT include "options" or a multiple-choice "correctAnswer". + +--- If Question Type is "open-ended" --- +INSTRUCTIONS: +1. "topicTitle": short neutral label (3-10 words), not a question. +2. "question": the prompt students respond to. May be paragraph-length. Do NOT use _________. +3. "openEndedSampleAnswer": a strong example response shown to students after submission (not used for auto-grading). +4. "openEndedGradingCriteria": clear criteria or a short rubric students can use to self-assess. +5. Do NOT include "options", "correctAnswer", or calculation fields. + +Example: +{ + "topicTitle": "Design trade-offs", + "question": "Explain two trade-offs between caching and freshness in a web application.", + "openEndedSampleAnswer": "Caching improves latency and reduces load, but stale data can confuse users unless TTLs or invalidation are chosen carefully.", + "openEndedGradingCriteria": "Full credit: two distinct trade-offs with reasoning. Partial: one trade-off or vague reasoning. No credit: off-topic.", + "explanation": "Brief justification from the content." } CRITICAL FORMATTING REQUIREMENTS: -- Return ONLY valid JSON. -- Do NOT wrap the JSON in markdown code blocks. +- Return ONLY valid JSON. Do NOT wrap in markdown code blocks. - Do NOT include any text before or after the JSON object. -- CRITICAL JSON ESCAPING: If your response includes LaTeX mathematical notation, you MUST properly escape all backslashes in the JSON string as \\\\\\\\ (double backslash). -- CRITICAL: Do NOT include letter prefixes (A), B), etc.) in the option text. - -CONTENT: {ragContext}`; +- CRITICAL LaTeX FORMATTING: Enclose all mathematical notation in \\\\( and \\\\) for inline math + (e.g., \\\\( x^2 \\\\)). Do NOT use () or $ as math delimiters. +- CRITICAL SMILES FORMATTING: Wrap SMILES strings in [SMILES][/SMILES] tags + (e.g., [SMILES]O[/SMILES]). +- CRITICAL JSON ESCAPING: Ensure all LaTeX backslashes are properly escaped. +- Do NOT include letter prefixes (A), B), etc.) inside option text values.`; const OBJECTIVE_GENERATION_AUTO_PROMPT = `You are an expert educational content designer. Based on the following course materials, generate learning objectives that are clear, measurable, and aligned with educational best practices. @@ -109,16 +265,37 @@ IMPORTANT: const BLOOM_LEVELS = ["Remember", "Understand", "Apply", "Analyze", "Evaluate", "Create"]; +const QUESTION_TYPES = { + MULTIPLE_CHOICE: "multiple-choice", + FILL_IN_THE_BLANK: "fill-in-the-blank", + CALCULATION: "calculation", + OPEN_ENDED: "open-ended", +}; + const DEFAULT_PROMPTS = { questionGeneration: QUESTION_GENERATION_PROMPT, objectiveGenerationAuto: OBJECTIVE_GENERATION_AUTO_PROMPT, objectiveGenerationManual: OBJECTIVE_GENERATION_MANUAL_PROMPT }; +// Default mapping from Bloom's level to ordered question-type preferences. +// The first entry is what auto-generation picks; the rest are fallbacks. +const DEFAULT_BLOOM_TYPE_PREFERENCES = { + Remember: [QUESTION_TYPES.FILL_IN_THE_BLANK, QUESTION_TYPES.MULTIPLE_CHOICE], + Understand: [QUESTION_TYPES.MULTIPLE_CHOICE, QUESTION_TYPES.FILL_IN_THE_BLANK], + Apply: [QUESTION_TYPES.MULTIPLE_CHOICE, QUESTION_TYPES.FILL_IN_THE_BLANK], + Analyze: [QUESTION_TYPES.MULTIPLE_CHOICE, QUESTION_TYPES.FILL_IN_THE_BLANK], + Evaluate: [QUESTION_TYPES.CALCULATION, QUESTION_TYPES.MULTIPLE_CHOICE], + Create: [QUESTION_TYPES.OPEN_ENDED, QUESTION_TYPES.MULTIPLE_CHOICE], +}; + module.exports = { + SUBTOPIC_DETERMINATION_PROMPT, QUESTION_GENERATION_PROMPT, OBJECTIVE_GENERATION_AUTO_PROMPT, OBJECTIVE_GENERATION_MANUAL_PROMPT, BLOOM_LEVELS, - DEFAULT_PROMPTS + QUESTION_TYPES, + DEFAULT_PROMPTS, + DEFAULT_BLOOM_TYPE_PREFERENCES, }; diff --git a/src/controllers/quiz.js b/src/controllers/quiz.js index b0b95e1..cef469b 100644 --- a/src/controllers/quiz.js +++ b/src/controllers/quiz.js @@ -1,6 +1,8 @@ const quizService = require("../services/quiz"); const questionService = require("../services/question"); +const calculationQuestion = require("../services/calculation-question"); const { isFaculty } = require("../utils/auth"); +const { QUESTION_TYPES } = require("../constants/app-constants"); /** * Get all quizzes for a course @@ -204,6 +206,22 @@ function shuffleArray(array) { return shuffled; } +function resolveQuestionType(q) { + const t = String(q.questionType || q.type || "") + .trim() + .toLowerCase(); + if (t === QUESTION_TYPES.FILL_IN_THE_BLANK) { + return QUESTION_TYPES.FILL_IN_THE_BLANK; + } + if (t === QUESTION_TYPES.CALCULATION) { + return QUESTION_TYPES.CALCULATION; + } + if (t === QUESTION_TYPES.OPEN_ENDED) { + return QUESTION_TYPES.OPEN_ENDED; + } + return QUESTION_TYPES.MULTIPLE_CHOICE; +} + // Helper function to generate a shuffled order of option indices function shuffleQuestionOptions(question) { const optionIndices = [0, 1, 2, 3]; @@ -238,6 +256,123 @@ const getQuizQuestionsHandler = async (req, res) => { } const transformedQuestions = questions.map((q, index) => { + const questionType = resolveQuestionType(q); + const questionText = (q.title || q.stem || "").trim(); + + if (questionType === QUESTION_TYPES.FILL_IN_THE_BLANK) { + const fibMainText = (q.stem || q.title || "").trim(); + const formattedQuestion = { + ...q, + id: q._id ? (q._id.toString ? q._id.toString() : String(q._id)) : String(q.id || index + 1), + question: fibMainText || questionText || "Question text not available", + questionType: QUESTION_TYPES.FILL_IN_THE_BLANK, + options: {}, + }; + let finalQuestion = formattedQuestion; + if (approvedOnlyBool) { + delete finalQuestion.correctAnswer; + delete finalQuestion.acceptableAnswers; + // Avoid duplicating the same stem under `question` and `stem` in the student UI + delete finalQuestion.stem; + } + return finalQuestion; + } + + if (questionType === QUESTION_TYPES.OPEN_ENDED) { + const fibMainText = (q.stem || q.title || "").trim(); + const qid = q._id ? (q._id.toString ? q._id.toString() : String(q._id)) : String(q.id || index + 1); + const formattedQuestion = { + ...q, + id: qid, + question: fibMainText || questionText || "Question text not available", + questionType: QUESTION_TYPES.OPEN_ENDED, + options: {}, + learningObjectiveId: q.learningObjectiveId, + granularObjectiveId: q.granularObjectiveId, + bloom: q.bloom, + }; + if (approvedOnlyBool) { + delete formattedQuestion.openEndedSampleAnswer; + delete formattedQuestion.openEndedGradingCriteria; + delete formattedQuestion.stem; + } + return formattedQuestion; + } + + if (questionType === QUESTION_TYPES.CALCULATION) { + const vars = q.calculationVariables; + const template = calculationQuestion.resolveCalculationDisplayTemplate( + q.stem, + q.title, + vars + ); + const formula = (q.calculationFormula || "").trim(); + const answerDec = + q.calculationAnswerDecimals !== undefined && q.calculationAnswerDecimals !== null + ? Math.max(0, Math.min(12, parseInt(q.calculationAnswerDecimals, 10) || 2)) + : 2; + const tolerancePercent = + q.calculationAnswerTolerancePercent != null && + Number.isFinite(Number(q.calculationAnswerTolerancePercent)) + ? Number(q.calculationAnswerTolerancePercent) + : null; + const qid = q._id ? (q._id.toString ? q._id.toString() : String(q._id)) : String(q.id || index + 1); + + if (approvedOnlyBool) { + const built = calculationQuestion.buildStudentCalculationInstance({ + template, + formula, + variableSpecs: vars, + qid, + answerDec, + }); + if (built.ok) { + return { + id: qid, + question: built.rendered, + questionType: QUESTION_TYPES.CALCULATION, + calculationToken: built.token, + answerDecimalPlaces: built.answerDecimalPlaces, + calculationAnswerTolerancePercent: tolerancePercent, + options: {}, + learningObjectiveId: q.learningObjectiveId, + granularObjectiveId: q.granularObjectiveId, + bloom: q.bloom, + }; + } + console.error( + "Calculation question instance failed:", + qid, + built.error && built.error.message + ); + return { + id: qid, + question: + template || + "This calculation question could not be loaded. Please contact your instructor.", + questionType: QUESTION_TYPES.CALCULATION, + calculationToken: null, + answerDecimalPlaces: answerDec, + calculationLoadError: true, + options: {}, + learningObjectiveId: q.learningObjectiveId, + granularObjectiveId: q.granularObjectiveId, + bloom: q.bloom, + }; + } + + return { + ...q, + id: qid, + question: template || "Question text not available", + questionType: QUESTION_TYPES.CALCULATION, + options: {}, + calculationFormula: formula, + calculationVariables: vars, + calculationAnswerDecimals: answerDec, + }; + } + let optionsObj = {}; if (q.options && typeof q.options === 'object') { if (!Array.isArray(q.options)) { @@ -256,34 +391,29 @@ const getQuizQuestionsHandler = async (req, res) => { }; } } - // If approvedOnlyBool is true, this is the student view. We MUST strip secure answers. if (approvedOnlyBool) { ['A', 'B', 'C', 'D'].forEach(key => { if (optionsObj[key] && typeof optionsObj[key] === 'object') { - // Strip out feedback before sending to the client browser delete optionsObj[key].feedback; } }); } - const questionText = (q.title || q.stem || "").trim(); - const formattedQuestion = { ...q, id: q._id ? (q._id.toString ? q._id.toString() : String(q._id)) : String(q.id || index + 1), question: questionText || "Question text not available", + questionType: QUESTION_TYPES.MULTIPLE_CHOICE, options: optionsObj, correctAnswer: (q.correctAnswer || "A").toString().toUpperCase() }; - - // Shuffle options for students + let finalQuestion = approvedOnlyBool ? shuffleQuestionOptions(formattedQuestion) : formattedQuestion; - - // Completely strip out the correct answer if this is for the student + if (approvedOnlyBool) { delete finalQuestion.correctAnswer; } - + return finalQuestion; }); @@ -332,11 +462,7 @@ const recordPerformanceHandler = async (req, res) => { const checkQuestionAnswerHandler = async (req, res) => { try { const { questionId } = req.params; - const { selectedIndex } = req.body; - - if (selectedIndex === undefined || selectedIndex === null) { - return res.status(400).json({ success: false, error: "selectedIndex is required" }); - } + const { selectedIndex, answerText } = req.body; const { getQuestion } = require('../services/question'); const question = await getQuestion(questionId); @@ -344,8 +470,160 @@ const checkQuestionAnswerHandler = async (req, res) => { if (!question) { return res.status(404).json({ success: false, error: "Question not found" }); } - - // Convert the numeric index sent by the frontend back to the original DB key mapping + + const questionType = resolveQuestionType(question); + const calculationToken = + req.body.calculationToken && typeof req.body.calculationToken === "string" + ? req.body.calculationToken + : null; + const hasCalculationFormula = + typeof question.calculationFormula === "string" && + question.calculationFormula.trim().length > 0; + + const treatAsCalculation = + questionType === QUESTION_TYPES.CALCULATION || + (calculationToken && hasCalculationFormula); + + if (treatAsCalculation) { + const { answerText } = req.body; + if (!calculationToken) { + return res.status(400).json({ + success: false, + error: "calculationToken is required for calculation questions", + }); + } + if (answerText === undefined || answerText === null || String(answerText).trim() === "") { + return res.status(400).json({ + success: false, + error: "answerText is required for calculation questions", + }); + } + const verified = calculationQuestion.verifyCalculationToken(calculationToken); + if (!verified || String(verified.questionId) !== String(questionId)) { + return res.status(400).json({ + success: false, + error: "Invalid or expired calculation token — try reloading the quiz", + }); + } + const formula = (question.calculationFormula || "").trim(); + const vars = question.calculationVariables; + const answerDec = + question.calculationAnswerDecimals !== undefined && + question.calculationAnswerDecimals !== null + ? Math.max(0, Math.min(12, parseInt(question.calculationAnswerDecimals, 10) || 2)) + : 2; + const tolerancePercent = + question.calculationAnswerTolerancePercent != null && + Number.isFinite(Number(question.calculationAnswerTolerancePercent)) + ? Number(question.calculationAnswerTolerancePercent) + : null; + let expected; + try { + expected = calculationQuestion.evaluateCalculationFormula(formula, verified.values); + } catch (e) { + console.error("Calculation check evaluate failed:", e); + const msg = e && e.message ? String(e.message) : ""; + const clientError = + msg.startsWith("Formula ") || + msg.startsWith("Invalid formula") || + msg.includes("plain ASCII") || + msg.includes("unsupported characters") || + msg.includes("Reload the quiz") || + msg.includes("calculationVariables") || + msg.includes("this attempt's values"); + return res.status(clientError ? 400 : 500).json({ + success: false, + error: clientError ? msg : "Could not grade this calculation question", + }); + } + const studentNum = calculationQuestion.parseStudentNumericAnswer(answerText); + const isCorrect = calculationQuestion.numericAnswersMatch(studentNum, expected, answerDec, tolerancePercent); + const displayCorrect = calculationQuestion.formatAnswerForDisplay(expected, answerDec); + res.json({ + success: true, + isCorrect, + feedback: isCorrect ? "Correct." : "", + correctAnswer: isCorrect ? displayCorrect : null, + correctOptionText: displayCorrect, + }); + return; + } + + if (questionType === QUESTION_TYPES.OPEN_ENDED) { + if (answerText === undefined || answerText === null || String(answerText).trim() === "") { + return res.status(400).json({ + success: false, + error: "answerText is required for open-ended questions", + }); + } + const sample = String(question.openEndedSampleAnswer || "").trim(); + const criteria = String(question.openEndedGradingCriteria || "").trim(); + if (!sample) { + return res.status(500).json({ + success: false, + error: "This open-ended question is missing a sample answer. Ask your instructor to fix it in the question bank.", + }); + } + res.json({ + success: true, + isCorrect: null, + autoGraded: false, + feedback: "", + openEnded: true, + sampleAnswer: sample, + gradingCriteria: criteria || null, + correctAnswer: null, + correctOptionText: null, + }); + return; + } + + if (questionType === QUESTION_TYPES.FILL_IN_THE_BLANK) { + if (answerText === undefined || answerText === null) { + return res.status(400).json({ + success: false, + error: "answerText is required for fill-in-the-blank questions", + }); + } + // Always grade against canonical correctAnswer plus any acceptableAnswers. + // Previously, a non-empty acceptableAnswers array replaced correctAnswer entirely, + // so typing the canonical answer could incorrectly mark wrong. + const normalizeFib = (s) => + String(s) + .normalize("NFKC") + .trim() + .toLowerCase() + .replace(/\s+/g, " "); + const given = normalizeFib(answerText); + const trimmedCanonical = + question.correctAnswer != null ? String(question.correctAnswer).trim() : ""; + const variants = new Set(); + if (trimmedCanonical) variants.add(trimmedCanonical); + if (Array.isArray(question.acceptableAnswers)) { + for (const a of question.acceptableAnswers) { + if (a == null) continue; + const t = String(a).trim(); + if (t) variants.add(t); + } + } + const normalizedAcceptable = [...variants].map((a) => normalizeFib(a)).filter(Boolean); + const isCorrect = normalizedAcceptable.length > 0 && normalizedAcceptable.some((a) => a === given); + const canonical = trimmedCanonical; + res.json({ + success: true, + isCorrect, + feedback: isCorrect ? "Correct." : "", + correctAnswer: isCorrect ? canonical : null, + // Reveal canonical text when wrong so the client can show "The correct answer is …" + correctOptionText: canonical || null, + }); + return; + } + + if (selectedIndex === undefined || selectedIndex === null) { + return res.status(400).json({ success: false, error: "selectedIndex is required" }); + } + const optionKeys = ['A', 'B', 'C', 'D']; const selectedKey = optionKeys[selectedIndex]; @@ -353,7 +631,6 @@ const checkQuestionAnswerHandler = async (req, res) => { return res.status(400).json({ success: false, error: "Invalid selectedIndex provided" }); } - // Normalize correct answer identifier from DB let correctAnswerLetter = question.correctAnswer || 'A'; if (typeof correctAnswerLetter === 'number') { correctAnswerLetter = optionKeys[correctAnswerLetter] || 'A'; @@ -363,13 +640,11 @@ const checkQuestionAnswerHandler = async (req, res) => { const isCorrect = selectedKey === correctAnswerLetter; - // Retrieve feedback specific to the selected option const selectedOptionObj = question.options[selectedKey]; const feedback = typeof selectedOptionObj === 'object' && selectedOptionObj !== null ? (selectedOptionObj.feedback || "") : ""; - // Safely retrieve the text of the actual correct option to send back const correctOptionObj = question.options[correctAnswerLetter]; const correctOptionText = typeof correctOptionObj === 'object' && correctOptionObj !== null ? (correctOptionObj.text || "") diff --git a/src/controllers/rag-llm.js b/src/controllers/rag-llm.js index de96ae9..5383516 100644 --- a/src/controllers/rag-llm.js +++ b/src/controllers/rag-llm.js @@ -11,7 +11,8 @@ const llmService = require('../services/llm'); const { getMaterialCourseId } = require('../services/material'); const { isUserInCourse } = require('../services/user-course'); const settingsService = require('../services/settings'); -const { DEFAULT_PROMPTS, BLOOM_LEVELS } = require('../constants/app-constants'); +const calculationQuestionService = require('../services/calculation-question'); +const { DEFAULT_PROMPTS, BLOOM_LEVELS, QUESTION_TYPES, SUBTOPIC_DETERMINATION_PROMPT } = require('../constants/app-constants'); // Simple error response function function returnErrorResponse(res, error, details = null) { @@ -30,6 +31,462 @@ function returnErrorResponse(res, error, details = null) { * @param {string|Object} jsonInput - The JSON string to parse, or already parsed object * @returns {Object} Parsed JSON object */ +function resolveQuestionTypeFromPayload(data, requestedType) { + const t = data?.type || data?.questionType; + if ( + t === QUESTION_TYPES.FILL_IN_THE_BLANK || + t === QUESTION_TYPES.MULTIPLE_CHOICE || + t === QUESTION_TYPES.CALCULATION || + t === QUESTION_TYPES.OPEN_ENDED + ) { + return t; + } + return requestedType; +} + +/** Map array shapes and letter-only `answer` to MC fields when possible. */ +function normalizeMultipleChoiceAliases(data) { + const d = { ...data }; + if (Array.isArray(d.choices) && d.choices.length >= 4) { + d.options = { + A: String(d.choices[0]), + B: String(d.choices[1]), + C: String(d.choices[2]), + D: String(d.choices[3]), + }; + } + if (Array.isArray(d.options) && d.options.length >= 4) { + d.options = { + A: String(d.options[0]), + B: String(d.options[1]), + C: String(d.options[2]), + D: String(d.options[3]), + }; + } + if ( + (d.correctAnswer == null || String(d.correctAnswer).trim() === "") && + typeof d.answer === "string" && + /^[ABCD]$/i.test(d.answer.trim()) + ) { + d.correctAnswer = d.answer.trim().toUpperCase(); + } + return d; +} + +/** + * Models often return { question, answer, explanation } for MC. If `answer` is the correct + * option text (not A–D), synthesize A–D options and a matching correctAnswer letter. + */ +function repairLooseMultipleChoiceShape(data) { + let d = normalizeMultipleChoiceAliases(data); + const hasFullOptions = + d.options && + typeof d.options === "object" && + !Array.isArray(d.options) && + ["A", "B", "C", "D"].every( + (k) => d.options[k] != null && String(d.options[k]).trim() + ); + if (hasFullOptions) { + return d; + } + if (typeof d.answer !== "string" || !d.answer.trim()) { + return d; + } + const correctText = d.answer.trim(); + if (/^[ABCD]$/i.test(correctText)) { + return d; + } + const distractors = [ + "Divergent.", + "Convergent only if extra conditions hold that are not stated.", + "The usual convergence test does not apply to this series.", + "The partial sums do not approach a finite limit.", + ] + .filter((x) => x.toLowerCase() !== correctText.toLowerCase()) + .slice(0, 3); + while (distractors.length < 3) { + distractors.push(`Incorrect alternative ${distractors.length + 1}.`); + } + const letters = ["A", "B", "C", "D"]; + const correctIdx = Math.floor(Math.random() * 4); + const options = {}; + let u = 0; + for (let i = 0; i < 4; i++) { + options[letters[i]] = i === correctIdx ? correctText : distractors[u++]; + } + return { + ...d, + type: d.type || QUESTION_TYPES.MULTIPLE_CHOICE, + options, + correctAnswer: letters[correctIdx], + }; +} + +/** + * Validate LLM JSON for the requested question type and return a normalized object for the API. + */ +function validateAndNormalizeQuestionData(data, requestedType) { + if (!data || typeof data !== "object") { + throw new Error("Invalid question payload"); + } + + // Detect prose-refusal shape: { "text": "..." } with no recognized question fields. + // llama3.1:8b sometimes returns this when it thinks the topic can't fit the type. + if ( + typeof data.text === "string" && + !data.question && !data.stem && !data.type && + !data.options && !data.calculationFormula + ) { + throw new Error( + "Model returned a prose response instead of a question. " + + "If the topic involves differential equations, integrals, or symbolic calculus, " + + "re-scope to a direct arithmetic calculation (e.g. evaluate a polynomial, apply a physics formula, compute a rate)." + ); + } + + const resolvedType = resolveQuestionTypeFromPayload(data, requestedType); + if (resolvedType !== requestedType) { + throw new Error( + `Response type "${resolvedType}" does not match requested questionType "${requestedType}"` + ); + } + + if (resolvedType === QUESTION_TYPES.CALCULATION) { + const merged = { ...data }; + const stemText = String(merged.stem || merged.question || "").trim(); + if (!stemText) { + throw new Error( + "Missing required field: stem (or question template) for calculation" + ); + } + + // Strip assignment LHS if the model writes "answer = expr" instead of just "expr". + // E.g. "r = -1 / m" → "-1 / m". Keep "==" untouched (comparison, not assignment). + let rawFormula = String(merged.calculationFormula || "").trim(); + const assignMatch = rawFormula.match(/^[A-Za-z_][A-Za-z0-9_]*\s*=(?!=)/); + if (assignMatch) { + rawFormula = rawFormula.slice(assignMatch[0].length).trim(); + merged.calculationFormula = rawFormula; + } + + const formula = rawFormula; + if (!formula) { + throw new Error("Missing required field: calculationFormula"); + } + const vars = merged.calculationVariables; + if (!Array.isArray(vars) || vars.length === 0) { + throw new Error("calculationVariables must be a non-empty array"); + } + const normalizedVars = vars.map((v, i) => { + if (!v || typeof v !== "object") { + throw new Error(`calculationVariables[${i}] must be an object`); + } + const name = String(v.name || "") + .trim() + .replace(/[^a-zA-Z0-9_]/g, ""); + if (!name) { + throw new Error(`calculationVariables[${i}] needs a valid "name"`); + } + const min = Number(v.min); + const max = Number(v.max); + if (!Number.isFinite(min) || !Number.isFinite(max) || min > max) { + throw new Error(`Invalid min/max for variable "${name}"`); + } + const out = { name, min, max }; + if (v.integerOnly === true) { + out.integerOnly = true; + } else { + const d = parseInt(v.decimals, 10); + out.decimals = Number.isFinite(d) ? Math.max(0, Math.min(8, d)) : 0; + } + return out; + }); + let answerDec = parseInt(merged.calculationAnswerDecimals, 10); + if (!Number.isFinite(answerDec)) answerDec = 2; + answerDec = Math.max(0, Math.min(12, answerDec)); + + const tolRaw = parseFloat(merged.calculationAnswerTolerancePercent); + const answerTolerance = Number.isFinite(tolRaw) + ? Math.max(0, Math.min(100, tolRaw)) + : null; + let topicTitle = (merged.topicTitle || merged.topic || merged.shortTitle || "") + .trim() + .replace(/\?+$/, ""); + if (!topicTitle) { + const before = stemText.split("{{")[0].trim(); + const words = before.split(/\s+/).filter(Boolean); + topicTitle = words.slice(0, 10).join(" ") || "Calculation"; + } + calculationQuestionService.validateNoReservedVariableNames(normalizedVars); + calculationQuestionService.validateFormulaAgainstVariableSpecs( + formula, + normalizedVars + ); + calculationQuestionService.validateFormulaReferencesAllVariables( + formula, + normalizedVars + ); + calculationQuestionService.validateStemReferencesAllVariables( + stemText, + normalizedVars + ); + const formulaCanonical = + calculationQuestionService.prepareCalculationFormula( + formula, + normalizedVars + ); + return { + type: QUESTION_TYPES.CALCULATION, + questionType: QUESTION_TYPES.CALCULATION, + topicTitle, + question: stemText, + stem: stemText, + calculationFormula: formulaCanonical, + calculationVariables: normalizedVars, + calculationAnswerDecimals: answerDec, + calculationAnswerTolerancePercent: answerTolerance, + explanation: merged.explanation != null ? String(merged.explanation) : "", + options: null, + }; + } + + if (resolvedType === QUESTION_TYPES.OPEN_ENDED) { + const merged = { ...data }; + const stemText = String(merged.stem || merged.question || "").trim(); + if (!stemText) { + throw new Error( + "Missing required field: stem or question for open-ended" + ); + } + const sample = String( + merged.openEndedSampleAnswer || merged.sampleAnswer || "" + ).trim(); + if (!sample) { + throw new Error("Missing required field: openEndedSampleAnswer"); + } + const criteria = String( + merged.openEndedGradingCriteria || merged.gradingCriteria || "" + ).trim(); + if (!criteria) { + throw new Error("Missing required field: openEndedGradingCriteria"); + } + let topicTitle = (merged.topicTitle || merged.topic || merged.shortTitle || "") + .trim() + .replace(/\?+$/, ""); + if (!topicTitle) { + const words = stemText.split(/\s+/).filter(Boolean); + topicTitle = words.slice(0, 10).join(" ") || "Open-ended"; + } + return { + type: QUESTION_TYPES.OPEN_ENDED, + questionType: QUESTION_TYPES.OPEN_ENDED, + topicTitle, + question: stemText, + stem: stemText, + openEndedSampleAnswer: sample, + openEndedGradingCriteria: criteria, + explanation: merged.explanation != null ? String(merged.explanation) : "", + options: null, + }; + } + + if (!data.question || typeof data.question !== "string" || !data.question.trim()) { + throw new Error("Missing required field: question"); + } + + if (resolvedType === QUESTION_TYPES.FILL_IN_THE_BLANK) { + const merged = { ...data }; + if ( + (merged.correctAnswer == null || String(merged.correctAnswer).trim() === "") && + typeof merged.answer === "string" && + merged.answer.trim() + ) { + merged.correctAnswer = merged.answer.trim(); + } + const ca = merged.correctAnswer; + if (ca === undefined || ca === null || String(ca).trim() === "") { + throw new Error( + "Missing required field: correctAnswer (expected short answer text for fill-in-the-blank)" + ); + } + const canonical = typeof ca === "string" ? ca.trim() : String(ca); + let acceptable = merged.acceptableAnswers; + if (!Array.isArray(acceptable) || acceptable.length === 0) { + acceptable = [canonical]; + } else { + acceptable = acceptable + .map((a) => (typeof a === "string" ? a.trim() : String(a))) + .filter(Boolean); + if (acceptable.length === 0) { + acceptable = [canonical]; + } + } + const qText = merged.question.trim(); + let topicTitle = (merged.topicTitle || merged.topic || merged.shortTitle || "") + .trim() + .replace(/\?+$/, ""); + if (!topicTitle) { + const beforeBlank = qText.split("_________")[0].trim(); + const words = beforeBlank.split(/\s+/).filter(Boolean); + topicTitle = words.slice(0, 10).join(" ") || "Fill-in-the-blank"; + } + return { + type: QUESTION_TYPES.FILL_IN_THE_BLANK, + questionType: QUESTION_TYPES.FILL_IN_THE_BLANK, + topicTitle, + question: qText, + correctAnswer: canonical, + acceptableAnswers: acceptable, + explanation: merged.explanation != null ? String(merged.explanation) : "", + options: null, + }; + } + + const mcData = repairLooseMultipleChoiceShape(data); + + if (!mcData.options || typeof mcData.options !== "object" || Array.isArray(mcData.options)) { + throw new Error( + 'Missing required field: options (object with keys A, B, C, D). For multiple-choice do not use a single "answer" string instead of four options and correctAnswer A–D.' + ); + } + for (const key of ["A", "B", "C", "D"]) { + const opt = mcData.options[key]; + if (opt === undefined || opt === null || String(opt).trim() === "") { + throw new Error(`Missing or empty option ${key}`); + } + } + let letter = mcData.correctAnswer; + if (typeof letter === "number") { + letter = ["A", "B", "C", "D"][letter]; + } + if (typeof letter !== "string" || !/^[ABCD]$/i.test(letter.trim())) { + throw new Error("correctAnswer must be A, B, C, or D for multiple-choice"); + } + letter = letter.trim().toUpperCase(); + return { + type: QUESTION_TYPES.MULTIPLE_CHOICE, + questionType: QUESTION_TYPES.MULTIPLE_CHOICE, + question: mcData.question.trim(), + options: { + A: String(mcData.options.A).trim(), + B: String(mcData.options.B).trim(), + C: String(mcData.options.C).trim(), + D: String(mcData.options.D).trim(), + }, + correctAnswer: letter, + explanation: mcData.explanation != null ? String(mcData.explanation) : "", + }; +} + +/** + * Balanced {...} from str[start] where str[start] === "{" (respects JSON strings). + */ +function extractBalancedFrom(str, start) { + if (str[start] !== "{") { + return null; + } + let depth = 0; + let inString = false; + let escape = false; + for (let i = start; i < str.length; i++) { + const c = str[i]; + if (escape) { + escape = false; + continue; + } + if (inString) { + if (c === "\\") { + escape = true; + continue; + } + if (c === '"') { + inString = false; + } + continue; + } + if (c === '"') { + inString = true; + continue; + } + if (c === "{") { + depth++; + } else if (c === "}") { + depth--; + if (depth === 0) { + return str.slice(start, i + 1); + } + } + } + return null; +} + +/** + * Try each `{` position: parse balanced span; accept first object with a non-empty "question" or + * "stem" string (calculation questions use "stem" as the primary field). + * Skips spurious `{` from LaTeX (e.g. \\boxed{0}) that are not full question JSON. + */ +function tryParseQuestionJsonFromLaxText(jsonString) { + let pos = 0; + while (pos < jsonString.length) { + const start = jsonString.indexOf("{", pos); + if (start === -1) { + break; + } + const balanced = extractBalancedFrom(jsonString, start); + if (balanced) { + try { + const obj = JSON.parse(balanced); + if (obj && typeof obj === "object") { + const hasQuestion = typeof obj.question === "string" && obj.question.trim(); + const hasStem = typeof obj.stem === "string" && obj.stem.trim(); + if (hasQuestion || hasStem) { + return obj; + } + } + } catch (_) { + /* try next { */ + } + } + pos = start + 1; + } + return null; +} + +/** + * Strip sections for other question types from the prompt so the model only sees + * the schema relevant to the requested type. Reduces prompt length significantly + * for small models like llama3.1:8b. + */ +function filterPromptToType(template, questionType) { + const allTypes = [QUESTION_TYPES.MULTIPLE_CHOICE, QUESTION_TYPES.FILL_IN_THE_BLANK, QUESTION_TYPES.CALCULATION, QUESTION_TYPES.OPEN_ENDED]; + const targetIdx = allTypes.indexOf(questionType); + if (targetIdx === -1) return template; + + const sectionHeaders = allTypes.map(t => `--- If Question Type is "${t}" ---`); + const footerMarker = "CRITICAL FORMATTING REQUIREMENTS"; + + const positions = sectionHeaders.map(h => template.indexOf(h)); + const footerPos = template.indexOf(footerMarker); + + const targetStart = positions[targetIdx]; + if (targetStart === -1) return template; + + let targetEnd = footerPos !== -1 ? footerPos : template.length; + for (let i = 0; i < positions.length; i++) { + if (i === targetIdx) continue; + const p = positions[i]; + if (p > targetStart && p < targetEnd) targetEnd = p; + } + + const validPositions = positions.filter(p => p !== -1); + const firstSectionPos = validPositions.length > 0 ? Math.min(...validPositions) : -1; + const preamble = firstSectionPos !== -1 ? template.slice(0, firstSectionPos) : ""; + const targetSection = template.slice(targetStart, targetEnd); + const footer = footerPos !== -1 ? template.slice(footerPos) : ""; + + return preamble + targetSection + footer; +} + function safeJsonParse(jsonInput) { // If it's already an object, return it if (typeof jsonInput === 'object' && jsonInput !== null && !Array.isArray(jsonInput)) { @@ -49,11 +506,10 @@ function safeJsonParse(jsonInput) { if (codeBlockMatch) { return JSON.parse(codeBlockMatch[1]); } - - // Try to extract JSON object from the string - const jsonMatch = jsonString.match(/\{[\s\S]*\}/); - if (jsonMatch) { - return JSON.parse(jsonMatch[0]); + + const fromLax = tryParseQuestionJsonFromLaxText(jsonString); + if (fromLax) { + return fromLax; } throw error; @@ -65,6 +521,65 @@ function safeJsonParse(jsonInput) { } } +function jsonOnlyRetrySuffix(attempt, questionType, lastError) { + const mcSchema = `For multiple-choice, required keys are exactly: "type":"multiple-choice", "question", "options" (object with four string values for keys "A","B","C","D" only), "correctAnswer" (one letter: A, B, C, or D), "explanation". Do NOT use a top-level "answer" field instead of "options" + "correctAnswer".`; + const fibSchema = `For fill-in-the-blank: include "topicTitle" (short topic label, not a question, must not reveal the answer or say "fill in the blank"). "question" must be one unfinished declarative sentence (not What/Which/How), with exactly one blank as _________ (nine underscores). Include "correctAnswer", "acceptableAnswers", "explanation". No "options".`; + + // Build targeted extra guidance from the last error message for calculation type. + let calcExtra = ""; + if (questionType === QUESTION_TYPES.CALCULATION && lastError) { + const msg = String(lastError.message || ""); + if (/prose response|text.*instead of|refused|Expected.*property|JSON at position|Unexpected token/i.test(msg)) { + calcExtra = "\nPrevious response was prose or malformed JSON, not a question object. Output ONLY a valid JSON calculation question — no commentary, no textbook summaries, no text outside the JSON object. "; + } + if (/∫|unsupported characters/i.test(msg)) { + calcExtra += + "\nThe formula contained ∫ (integral sign) which the engine cannot evaluate. You have TWO options — pick whichever gives a valid arithmetic formula:" + + "\n OPTION A — Pre-solve: if the integral has a simple closed form, write it. Example: ∫₀^b ax² dx → formula \"a * b^3 / 3\"." + + "\n OPTION B — Reformulate: if the integral has NO simple closed form (e.g. involves cos, sin, ln), change the question entirely. Test a simpler but related arithmetic sub-skill: evaluate the integrand at x={{x}}, compute the derivative of a term, or apply the power rule." + + "\nThe formula field must contain ONLY + - * / ^ ( ) sin cos sqrt log exp E PI and variable names — absolutely no ∫, no d/dt, no = sign."; + } else if (/d\/dt|d\/dx|∑|calculus|symbolic/i.test(msg) || + /not defined in calculationVariables/i.test(msg) || + /expected variable for assignment/i.test(msg)) { + calcExtra += + "\nThe previous formula used symbolic notation which the engine cannot evaluate. " + + "Pre-solve the calculus: differentiate or solve the ODE analytically, then encode the closed-form result as plain ASCII arithmetic. " + + "Examples: derivative of ax²+bx at x → formula \"2*a*x + b\"; ODE y(t)=y₀e^(kt) → formula \"y0 * E^(k*t)\". " + + "If no simple closed form exists, reformulate to a simpler arithmetic sub-question instead. " + + "The formula field must contain only + - * / ^ ( ) and variable names — no d/dt, no ∫, no = sign."; + } + } + + const calcSchema = `For calculation — return ONLY this JSON shape (no other text):${calcExtra} +{ + "type": "calculation", + "topicTitle": "short label based on the content", + "stem": "Question about {{a}} and {{b}} drawn from the content.", + "calculationFormula": "formula derived from the content (NOT a * b unless content is multiplication)", + "calculationVariables": [ + {"name": "a", "min": 1, "max": 10, "integerOnly": true}, + {"name": "b", "min": 1, "max": 5, "decimals": 1} + ], + "calculationAnswerDecimals": 2, + "explanation": "brief" +} +RULES: (1) stem MUST use {{name}} double curly braces for every variable. (2) calculationFormula uses ONLY: + - * / ^ ( ) sin cos tan sqrt log exp E PI and declared variable names — NO LaTeX, NO ∫ ∑, NO d/dt, NO = sign. (3) Every name in calculationVariables must appear in BOTH stem AND calculationFormula. (4) min/max must be numbers. No "options" field. (5) Derive the formula from the actual course content — do NOT copy the placeholder formula above.`; + const openSchema = `For open-ended: "type":"open-ended", "topicTitle", "question" (or "stem") as the prompt, "openEndedSampleAnswer" (model answer shown after submit), "openEndedGradingCriteria" (rubric / what earns full credit), "explanation". No "options" or auto-graded correctAnswer.`; + let schema; + if (questionType === QUESTION_TYPES.MULTIPLE_CHOICE) schema = mcSchema; + else if (questionType === QUESTION_TYPES.FILL_IN_THE_BLANK) schema = fibSchema; + else if (questionType === QUESTION_TYPES.CALCULATION) schema = calcSchema; + else if (questionType === QUESTION_TYPES.OPEN_ENDED) schema = openSchema; + else schema = mcSchema; + return ` + +--- +REGENERATION (attempt ${attempt}): Previous output was invalid JSON or the wrong shape. +${schema} +Reply with ONE raw JSON object only. Forbidden: markdown, headings, lists, prose outside JSON, code fences. +Your entire message must start with { and end with }.`; +} + const addDocumentToRagHandler = async (req, res) => { try { const { content, metadata, courseId } = req.body; @@ -127,9 +642,24 @@ const searchRagHandler = async (req, res) => { } }; +async function determineSubtopic(llmModule, courseName, learningObjectiveText, granularObjectiveText) { + const prompt = SUBTOPIC_DETERMINATION_PROMPT + .replace('{courseName}', courseName || '') + .replace('{learningObjectiveText}', learningObjectiveText || '') + .replace('{granularLearningObjectiveText}', granularObjectiveText || ''); + try { + const response = await llmModule.sendMessage(prompt, { responseFormat: 'json' }); + const content = response?.content || String(response || ''); + const parsed = safeJsonParse(content); + return typeof parsed?.subtopic === 'string' ? parsed.subtopic.trim() : ''; + } catch { + return ''; + } +} + const generateQuestionsWithRagHandler = async (req, res) => { try { - const { courseId, courseName, learningObjectiveId, learningObjectiveText, granularLearningObjectiveText, bloomLevel } = req.body; + const { courseId, courseName, learningObjectiveId, learningObjectiveText, granularLearningObjectiveText, bloomLevel, questionType } = req.body; console.log("=== RAG + LLM GENERATION REQUEST ==="); console.log("Course ID:", courseId); @@ -138,6 +668,7 @@ const generateQuestionsWithRagHandler = async (req, res) => { console.log("Learning Objective Text:", learningObjectiveText); console.log("Granular Learning Objective Text:", granularLearningObjectiveText); console.log("Bloom Level:", bloomLevel); + console.log("Question Type:", questionType); // Validate required parameters if (!courseName || !learningObjectiveId || !learningObjectiveText || !granularLearningObjectiveText || !bloomLevel) { @@ -147,6 +678,20 @@ const generateQuestionsWithRagHandler = async (req, res) => { }); } + // Validate question types + const ALLOWED_QUESTION_TYPES = [ + QUESTION_TYPES.MULTIPLE_CHOICE, + QUESTION_TYPES.FILL_IN_THE_BLANK, + QUESTION_TYPES.CALCULATION, + QUESTION_TYPES.OPEN_ENDED, + ]; + if (!questionType || !ALLOWED_QUESTION_TYPES.includes(questionType)) { + return res.status(400).json({ + error: "Invalid or missing questionType", + details: `questionType must be one of: ${ALLOWED_QUESTION_TYPES.join(", ")}`, + }); + } + // Check if LLM service is available if (!llmService.isReady()) { console.log("LLM service not available"); @@ -189,12 +734,25 @@ const generateQuestionsWithRagHandler = async (req, res) => { // Get LLM instance from service const llmModule = await llmService.getLLMInstance(); - // Create prompt with RAG context - const createPrompt = () => promptTemplate - .replace('{learningObjectiveText}', learningObjectiveText || '') - .replace('{granularLearningObjectiveText}', granularLearningObjectiveText || '') - .replace('{bloomLevel}', bloomLevel || '') - .replace('{ragContext}', ragContext || ''); + const subtopic = (await determineSubtopic(llmModule, courseName, learningObjectiveText, granularLearningObjectiveText)) + || granularLearningObjectiveText || ''; + console.log('[RAG-LLM] Focused sub-topic:', subtopic); + + // Create prompt with RAG context (optional retry suffix nudges small/local models toward JSON-only) + const buildBasePrompt = () => { + const filled = promptTemplate + .replace('{courseName}', courseName || '') + .replace('{learningObjectiveText}', learningObjectiveText || '') + .replace('{granularLearningObjectiveText}', granularLearningObjectiveText || '') + .replace('{bloomLevel}', bloomLevel || '') + .replace('{questionType}', questionType || '') + .replace('{subtopic}', subtopic) + .replace('{ragContext}', ragContext || ''); + return filterPromptToType(filled, questionType); + }; + + const createPrompt = (attempt, errForRetry) => + attempt > 1 ? buildBasePrompt() + jsonOnlyRetrySuffix(attempt, questionType, errForRetry) : buildBasePrompt(); // Retry logic: regenerate until we get valid JSON const maxRetries = 5; @@ -204,8 +762,9 @@ const generateQuestionsWithRagHandler = async (req, res) => { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { console.log(`Sending prompt to LLM service (attempt ${attempt}/${maxRetries})...`); - const response = await llmModule.sendMessage(createPrompt()); - console.log("Full Prompt: ", createPrompt()); + const promptForAttempt = createPrompt(attempt, lastError); + const response = await llmModule.sendMessage(promptForAttempt, { responseFormat: "json" }); + console.log("Full Prompt: ", promptForAttempt); console.log("✅ LLM service response received"); console.log( @@ -213,6 +772,7 @@ const generateQuestionsWithRagHandler = async (req, res) => { typeof response, response ? Object.keys(response) : "null" ); + console.log("[RAG-LLM] Raw LLM response object (first pass, before JSON extract):", response); // Extract content from response // sendMessage returns { content, model, usage, metadata } @@ -224,7 +784,17 @@ const generateQuestionsWithRagHandler = async (req, res) => { responseContent = response; } - console.log("Response content:", responseContent); + const contentStr = + typeof responseContent === "string" + ? responseContent + : String(responseContent ?? ""); + console.log( + "[RAG-LLM] Extracted text length:", + contentStr.length, + "| starts with:", + JSON.stringify(contentStr.slice(0, 120)) + ); + console.log("[RAG-LLM] Extracted text (full, for JSON debug):\n", contentStr); if (!responseContent) { throw new Error("Empty response from LLM"); @@ -233,12 +803,8 @@ const generateQuestionsWithRagHandler = async (req, res) => { // Try to parse JSON response try { // Use safe JSON parser that handles LaTeX and other edge cases - questionData = safeJsonParse(responseContent); - - // Validate that we have the required fields - if (!questionData.question || !questionData.options || !questionData.correctAnswer) { - throw new Error("Missing required fields in JSON response"); - } + const parsed = safeJsonParse(responseContent); + questionData = validateAndNormalizeQuestionData(parsed, questionType); // If we got here, parsing was successful console.log(`✅ Successfully parsed JSON on attempt ${attempt}`); @@ -247,6 +813,12 @@ const generateQuestionsWithRagHandler = async (req, res) => { } catch (parseError) { lastError = parseError; console.warn(`❌ JSON parsing failed on attempt ${attempt}:`, parseError.message); + console.warn( + "[RAG-LLM] Parse failed — content length:", + contentStr.length, + "| snippet (0-400):", + JSON.stringify(contentStr.slice(0, 400)) + ); if (attempt < maxRetries) { console.log(`Retrying... (${attempt + 1}/${maxRetries})`); continue; @@ -272,12 +844,24 @@ const generateQuestionsWithRagHandler = async (req, res) => { throw new Error(`Failed to generate valid JSON after ${maxRetries} attempts. Last error: ${lastError?.message || 'Unknown error'}`); } - // Verify that the correct answer text exists organically in the selected position - const correctOptionLetter = questionData.correctAnswer; - if (questionData.options && questionData.options[correctOptionLetter]) { - console.log(`✅ Correct answer organically located at position ${correctOptionLetter}: "${questionData.options[correctOptionLetter].substring(0, 50)}..."`); - } else { - console.warn(`⚠️ Warning: No option found at the LLM's selected position ${correctOptionLetter}, but continuing anyway`); + if (questionData.questionType === QUESTION_TYPES.MULTIPLE_CHOICE) { + const correctOptionLetter = questionData.correctAnswer; + const optText = questionData.options?.[correctOptionLetter]; + if (optText) { + console.log( + `✅ Correct answer at position ${correctOptionLetter}: "${String(optText).substring(0, 50)}..."` + ); + } else { + console.warn( + `⚠️ Warning: No option text at position ${correctOptionLetter}, but continuing anyway` + ); + } + } else if (questionData.questionType === QUESTION_TYPES.CALCULATION) { + console.log( + `✅ Calculation question: formula "${String(questionData.calculationFormula || "").substring(0, 60)}..."` + ); + } else if (questionData.questionType === QUESTION_TYPES.OPEN_ENDED) { + console.log("✅ Open-ended question with sample answer and grading criteria"); } res.json({ @@ -431,7 +1015,7 @@ const generateLearningObjectivesHandler = async (req, res) => { } console.log("Sending prompt to LLM service..."); - const response = await llmModule.sendMessage(fullPrompt); + const response = await llmModule.sendMessage(fullPrompt, { responseFormat: "json" }); console.log("Full Prompt: ", fullPrompt); console.log("✅ LLM service response received"); diff --git a/src/controllers/student.js b/src/controllers/student.js index 70adb32..5785ad0 100644 --- a/src/controllers/student.js +++ b/src/controllers/student.js @@ -1,8 +1,10 @@ const { getStudentCourses } = require('../services/user-course'); const quizService = require('../services/quiz'); +const calculationQuestion = require('../services/calculation-question'); const achievementService = require('../services/achievement'); const { getCourseById } = require('../services/course'); const { ObjectId } = require('mongodb'); +const { QUESTION_TYPES } = require('../constants/app-constants'); const databaseService = require('../services/database'); const isQuizAccessible = (quiz) => { @@ -108,6 +110,12 @@ function shuffleArray(array) { return shuffled; } +function resolveQuestionType(q) { + const t = String(q.questionType || q.type || "").trim().toLowerCase(); + const known = [QUESTION_TYPES.FILL_IN_THE_BLANK, QUESTION_TYPES.CALCULATION, QUESTION_TYPES.OPEN_ENDED]; + return known.includes(t) ? t : QUESTION_TYPES.MULTIPLE_CHOICE; +} + // Helper function to shuffle question options and update correct answer function shuffleQuestionOptions(question) { const optionKeys = ['A', 'B', 'C', 'D']; @@ -176,6 +184,97 @@ const getQuizQuestionsHandler = async (req, res) => { } const transformedQuestions = questions.map((q, index) => { + const questionType = resolveQuestionType(q); + const questionText = (q.title || q.stem || "").trim(); + const fibMainText = + questionType === QUESTION_TYPES.FILL_IN_THE_BLANK + ? (q.stem || q.title || "").trim() + : questionText; + + if (questionType === QUESTION_TYPES.FILL_IN_THE_BLANK) { + return { + id: q._id ? (q._id.toString ? q._id.toString() : String(q._id)) : String(q.id || index + 1), + question: fibMainText || questionText || "Question text not available", + questionType: QUESTION_TYPES.FILL_IN_THE_BLANK, + options: {}, + learningObjectiveId: q.learningObjectiveId, + granularObjectiveId: q.granularObjectiveId, + bloom: q.bloom, + }; + } + + if (questionType === QUESTION_TYPES.OPEN_ENDED) { + return { + id: q._id ? (q._id.toString ? q._id.toString() : String(q._id)) : String(q.id || index + 1), + question: fibMainText || questionText || "Question text not available", + questionType: QUESTION_TYPES.OPEN_ENDED, + options: {}, + learningObjectiveId: q.learningObjectiveId, + granularObjectiveId: q.granularObjectiveId, + bloom: q.bloom, + }; + } + + if (questionType === QUESTION_TYPES.CALCULATION) { + const vars = q.calculationVariables; + const template = calculationQuestion.resolveCalculationDisplayTemplate( + q.stem, + q.title, + vars + ); + const formula = (q.calculationFormula || "").trim(); + const answerDec = + q.calculationAnswerDecimals !== undefined && q.calculationAnswerDecimals !== null + ? Math.max(0, Math.min(12, parseInt(q.calculationAnswerDecimals, 10) || 2)) + : 2; + const tolerancePercent = + q.calculationAnswerTolerancePercent != null && + Number.isFinite(Number(q.calculationAnswerTolerancePercent)) + ? Number(q.calculationAnswerTolerancePercent) + : null; + const qid = q._id ? (q._id.toString ? q._id.toString() : String(q._id)) : String(q.id || index + 1); + const built = calculationQuestion.buildStudentCalculationInstance({ + template, + formula, + variableSpecs: vars, + qid, + answerDec, + }); + if (built.ok) { + return { + id: qid, + question: built.rendered, + questionType: QUESTION_TYPES.CALCULATION, + calculationToken: built.token, + answerDecimalPlaces: built.answerDecimalPlaces, + calculationAnswerTolerancePercent: tolerancePercent, + options: {}, + learningObjectiveId: q.learningObjectiveId, + granularObjectiveId: q.granularObjectiveId, + bloom: q.bloom, + }; + } + console.error( + "Calculation question instance failed:", + qid, + built.error && built.error.message + ); + return { + id: qid, + question: + template || + "This calculation question could not be loaded. Please contact your instructor.", + questionType: QUESTION_TYPES.CALCULATION, + calculationToken: null, + answerDecimalPlaces: answerDec, + calculationLoadError: true, + options: {}, + learningObjectiveId: q.learningObjectiveId, + granularObjectiveId: q.granularObjectiveId, + bloom: q.bloom, + }; + } + let optionsObj = {}; if (q.options && typeof q.options === 'object') { if (!Array.isArray(q.options)) { @@ -195,14 +294,12 @@ const getQuizQuestionsHandler = async (req, res) => { } } - const questionText = (q.title || q.stem || "").trim(); - return { id: q._id ? (q._id.toString ? q._id.toString() : String(q._id)) : String(q.id || index + 1), question: questionText || "Question text not available", + questionType: QUESTION_TYPES.MULTIPLE_CHOICE, options: optionsObj, correctAnswer: (q.correctAnswer || "A").toString().toUpperCase(), - // Keep metadata for performance tracking learningObjectiveId: q.learningObjectiveId, granularObjectiveId: q.granularObjectiveId, bloom: q.bloom @@ -221,9 +318,10 @@ const getQuizQuestionsHandler = async (req, res) => { } } - // Questions are already selected and ordered by service logic (LO distribution, etc.) - // We just need to shuffle options for each question - const randomizedQuestions = transformedQuestions.map(q => shuffleQuestionOptions(q)); + // Shuffle MC options only; other types have no options to shuffle + const randomizedQuestions = transformedQuestions.map((q) => + q.questionType === QUESTION_TYPES.MULTIPLE_CHOICE ? shuffleQuestionOptions(q) : q + ); res.json({ success: true, diff --git a/src/services/calculation-question.js b/src/services/calculation-question.js new file mode 100644 index 0000000..a75fa2c --- /dev/null +++ b/src/services/calculation-question.js @@ -0,0 +1,642 @@ +const crypto = require("crypto"); +const { Parser } = require("expr-eval"); + +const _EXPR_PARSER = new Parser(); + +/** Names expr-eval supplies from Parser.consts (not student variables). */ +const EXPR_EVAL_CONST_NAMES = new Set(Object.keys(_EXPR_PARSER.consts)); + +/** sin, fac, max, etc. — must not insert * before their opening parenthesis. */ +const EXPR_EVAL_CALLABLE_NAMES = new Set([ + ...Object.keys(_EXPR_PARSER.functions), + ...Object.keys(_EXPR_PARSER.unaryOps), +]); + +function randomIntegerInclusive(min, max) { + const lo = Math.ceil(Number(min)); + const hi = Math.floor(Number(max)); + if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo > hi) { + throw new Error(`No integer exists in range [${min}, ${max}]`); + } + return Math.floor(Math.random() * (hi - lo + 1)) + lo; +} + +/** Map common Unicode math operators to ASCII for expr-eval. */ +function normalizeAsciiFormula(formula) { + return String(formula || "") + .replace(/\u2212/g, "-") + .replace(/\u2013|\u2014/g, "-") + .replace(/\u00d7|\u22c5|\u00b7/g, "*") + .replace(/\u00f7|\u2044|\u2215/g, "/") + .trim(); +} + +/** Insert * for juxtaposition multiplication (e.g. 0.28(x+1) → 0.28*(x+1)) so expr-eval doesn't read it as a call. */ +function insertImplicitMultiplication(formula) { + let s = String(formula || ""); + for (let i = 0; i < 24; i++) { + let next = s.replace(/\)\s*\(/g, ")*("); + // (expr)0.28(expr2) — ")" is not in the prefix set for the rules below + next = next.replace( + /\)\s*(\d+\.?\d*|\.\d+)\s*\(/g, + ")*$1*(" + ); + next = next.replace( + /\)([A-Za-z_][A-Za-z0-9_]*)\s*\(/g, + (full, id) => + EXPR_EVAL_CALLABLE_NAMES.has(id) ? full : `)*${id}*(` + ); + next = next.replace( + /(^|[+\-*/^,(])(\d+\.?\d*|\.\d+)\s*\(/g, + "$1$2*(" + ); + next = next.replace( + /(^|[+\-*/^,(])([A-Za-z_][A-Za-z0-9_]*)\s*\(/g, + (full, pre, id) => + EXPR_EVAL_CALLABLE_NAMES.has(id) ? full : `${pre}${id}*(` + ); + if (next === s) break; + s = next; + } + return s; +} + +/** Strip invisibles, LaTeX fragments, and unicode math → ASCII expr-eval syntax (∑ ∫ still fail parse). */ +function canonicalizeCalculationSyntax(formula) { + let s = String(formula || "").trim(); + if (!s) return ""; + + s = s.replace(/[\u200B-\u200D\uFEFF]/g, ""); + + s = s.replace(/\$/g, ""); + s = s.replace(/\\\(|\\\)|\\\[|\\\]/g, ""); + s = s.replace(/\\left\s*/gi, ""); + s = s.replace(/\\right\s*/gi, ""); + + s = s.replace(/\\times/gi, "*"); + s = s.replace(/\\div/gi, "/"); + s = s.replace(/\\cdot/gi, "*"); + s = s.replace(/\\ast/gi, "*"); + s = s.replace(/\\pm\b/gi, " "); + s = s.replace(/\\mp\b/gi, " "); + + s = s.replace(/\\pi\b/gi, "PI"); + s = s.replace(/[\u03c0\u03a0]/g, "PI"); + s = s.replace(/\u212f/g, "E"); + s = s.replace(/\\sin\b/gi, "sin"); + s = s.replace(/\\cos\b/gi, "cos"); + s = s.replace(/\\tan\b/gi, "tan"); + s = s.replace(/\\ln\b/gi, "log"); + s = s.replace(/\\log10\b/gi, "log10"); + s = s.replace(/\\log\b/gi, "log"); + s = s.replace(/\\exp\b/gi, "exp"); + s = s.replace(/\\sqrt\{([^}]*)\}/gi, "sqrt($1)"); + + for (let i = 0; i < 8; i++) { + const next = s.replace(/\\frac\{([^}]*)\}\{([^}]*)\}/gi, "(($1)/($2))"); + if (next === s) break; + s = next; + } + + const supMap = { + "\u2070": "^0", + "\u00b9": "^1", + "\u00b2": "^2", + "\u00b3": "^3", + "\u2074": "^4", + "\u2075": "^5", + "\u2076": "^6", + "\u2077": "^7", + "\u2078": "^8", + "\u2079": "^9", + }; + for (const [ch, rep] of Object.entries(supMap)) { + s = s.split(ch).join(rep); + } + + s = normalizeAsciiFormula(s); + s = s.replace(/\s+/g, " ").trim(); + s = insertImplicitMultiplication(s); + return s; +} + +function buildAllowedVariableNames(variableSpecs) { + const allowed = new Set(); + for (const spec of variableSpecs || []) { + const name = sanitizeVariableName(spec); + if (name) allowed.add(name); + } + return allowed; +} + +/** Full normalization for storage and parsing (syntax + pi/e → PI/E when not declared as variables). */ +function prepareCalculationFormula(formula, variableSpecs) { + const allowed = buildAllowedVariableNames(variableSpecs); + let f = canonicalizeCalculationSyntax(formula); + f = normalizePiToBuiltin(f, allowed); + return f.trim(); +} + +/** Rewrite bare "pi"/"e" to expr-eval's case-sensitive PI/E, unless declared as a variable. */ +function normalizePiToBuiltin(formula, allowedVariableNames) { + const allow = allowedVariableNames instanceof Set ? allowedVariableNames : new Set(allowedVariableNames || []); + let s = String(formula || ""); + if (!allow.has("pi") && !allow.has("PI")) { + s = s.replace(/\bpi\b/gi, "PI"); + } + if (!allow.has("e") && !allow.has("E")) { + s = s.replace(/\be\b/g, "E"); + } + return s; +} + +function formulaParseErrorToMessage(err) { + const m = String(err?.message || "parse error"); + if (/Unknown character/i.test(m)) { + return new Error( + "Formula still contains unsupported characters after normalization (e.g. ∫, ∑, or complex LaTeX). Rewrite as a plain expression using + - * / ^ ( ) and variable names; \\times/\\cdot/unicode minus are converted automatically when possible." + ); + } + return new Error(`Invalid formula: ${m}`); +} + +function sanitizeVariableName(spec) { + return String(spec?.name || "") + .trim() + .replace(/[^a-zA-Z0-9_]/g, ""); +} + +/** Names expr-eval already supplies as math constants; declaring them as variables would override them. */ +const RESERVED_VARIABLE_NAMES = new Set(["pi", "PI", "e", "E"]); + +/** Reject variable names that collide with built-in math constants (PI, E). */ +function validateNoReservedVariableNames(variableSpecs) { + if (!Array.isArray(variableSpecs)) return; + const offenders = []; + for (const spec of variableSpecs) { + const name = sanitizeVariableName(spec); + if (!name) continue; + if (RESERVED_VARIABLE_NAMES.has(name)) offenders.push(name); + } + if (offenders.length > 0) { + throw new Error( + `calculationVariables name(s) ${offenders.join(", ")} collide with built-in math constants. Use PI and E directly in calculationFormula (e.g. "PI * r^2") and do not declare them as variables.` + ); + } +} + +/** Reject formulas that don't reference every declared variable (catches literals baked in for sampled values). */ +function validateFormulaReferencesAllVariables(formula, variableSpecs) { + const allowed = buildAllowedVariableNames(variableSpecs); + if (allowed.size === 0) return; + const f = prepareCalculationFormula(formula, variableSpecs); + if (!f) return; + let used; + try { + const parser = new Parser(); + used = new Set(parser.parse(f).variables()); + } catch (e) { + throw formulaParseErrorToMessage(e); + } + const missing = [...allowed].filter((n) => !used.has(n)); + if (missing.length > 0) { + throw new Error( + `calculationFormula must reference every declared variable. Missing from formula: ${missing.join(", ")}. Replace any hard-coded numeric literals in the formula with these variable names so the answer actually depends on the values shown in the stem.` + ); + } +} + +/** Names the stem references via {{name}} (after normalizing {name} and {{var=name}}), filtered to declared vars. */ +function getStemReferencedVariableNames(template, variableSpecs) { + const allowed = buildAllowedVariableNames(variableSpecs); + const t = normalizePlaceholders(template, variableSpecs); + const found = new Set(); + const re = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g; + let m; + while ((m = re.exec(t)) !== null) { + const name = String(m[1]); + if (allowed.has(name)) found.add(name); + } + return found; +} + +/** Enforce that the stem references every declared variable as {{name}} using the exact declared name. */ +function validateStemReferencesAllVariables(template, variableSpecs) { + const allowed = buildAllowedVariableNames(variableSpecs); + if (allowed.size === 0) return; + const referenced = getStemReferencedVariableNames(template, variableSpecs); + const missing = [...allowed].filter((n) => !referenced.has(n)); + if (missing.length > 0) { + throw new Error( + `stem must reference each variable as {{name}} using the exact name from calculationVariables. Missing: ${missing.join(", ")}.` + ); + } +} + +/** Ensure every identifier used in the formula is declared in calculationVariables. */ +function validateFormulaAgainstVariableSpecs(formula, variableSpecs) { + const allowed = buildAllowedVariableNames(variableSpecs); + if (allowed.size === 0) { + throw new Error("calculationVariables must define at least one valid variable name"); + } + + const f = prepareCalculationFormula(formula, variableSpecs); + if (!f) { + throw new Error("calculationFormula is empty"); + } + + let needed; + try { + const parser = new Parser(); + const expr = parser.parse(f); + needed = expr.variables(); + } catch (e) { + throw formulaParseErrorToMessage(e); + } + + const missing = needed.filter( + (n) => !allowed.has(n) && !EXPR_EVAL_CONST_NAMES.has(n) + ); + if (missing.length > 0) { + throw new Error( + `Formula uses variable(s) not defined in calculationVariables: ${missing.join(", ")}. Add each name to the variables list or fix the formula.` + ); + } +} + +/** Build rendered stem + signed token for one student attempt; retries on rare sampling singularities. */ +function buildStudentCalculationInstance({ + template, + formula, + variableSpecs, + qid, + answerDec, +}) { + const f = String(formula || "").trim(); + if (!f) { + return { ok: false, error: new Error("calculationFormula is empty") }; + } + if (!Array.isArray(variableSpecs) || variableSpecs.length === 0) { + return { ok: false, error: new Error("calculationVariables is empty") }; + } + + try { + validateFormulaAgainstVariableSpecs(f, variableSpecs); + } catch (e) { + return { ok: false, error: e }; + } + + const maxDrawAttempts = 40; + let lastError; + for (let attempt = 0; attempt < maxDrawAttempts; attempt++) { + try { + const values = generateVariableValues(variableSpecs); + evaluateCalculationFormula(f, values); + const renderResult = renderCalculationTemplate(template, values, variableSpecs); + const rendered = composeStudentCalculationStem(renderResult, values, variableSpecs); + const token = signCalculationToken(qid, values); + return { + ok: true, + rendered, + token, + answerDecimalPlaces: answerDec, + }; + } catch (e) { + lastError = e; + if (isRetryableCalculationDrawError(e)) continue; + return { ok: false, error: e }; + } + } + return { + ok: false, + error: new Error( + `Could not sample calculation variables so the formula is finite (tried ${maxDrawAttempts} random draws). Tighten min/max so operations stay real and away from singularities — for example, if the answer involves 1/(x+1) or sqrt(x+1), require x > -1 (e.g. set x min to 0 or -0.5).` + ), + }; +} + +function getHmacSecret() { + return ( + process.env.CALCULATION_HMAC_SECRET || + process.env.SESSION_SECRET || + "tlef-grasp-calculation-dev-insecure" + ); +} + +function generateVariableValues(variables) { + if (!Array.isArray(variables) || variables.length === 0) { + throw new Error("Calculation questions require at least one variable definition"); + } + const out = {}; + for (const spec of variables) { + const name = sanitizeVariableName(spec); + if (!name) continue; + const min = Number(spec.min); + const max = Number(spec.max); + if (!Number.isFinite(min) || !Number.isFinite(max) || min > max) { + throw new Error(`Invalid min/max for variable "${name}"`); + } + const integerOnly = spec.integerOnly === true; + const dec = integerOnly ? 0 : Math.max(0, Math.min(8, parseInt(spec.decimals, 10) || 0)); + let val; + if (integerOnly) { + val = randomIntegerInclusive(min, max); + } else if (Math.abs(max - min) < 1e-12) { + val = min; + } else { + val = Math.random() * (max - min) + min; + val = Number(val.toFixed(dec)); + val = Math.min(max, Math.max(min, val)); + } + out[name] = val; + } + if (Object.keys(out).length === 0) { + throw new Error("No valid variable names in calculationVariables"); + } + return out; +} + +function formatVariableForTemplate(value, spec) { + const integerOnly = spec && spec.integerOnly === true; + const dec = integerOnly ? 0 : Math.max(0, Math.min(8, parseInt(spec?.decimals, 10) || 0)); + if (integerOnly) return String(Math.round(Number(value))); + const n = Number(Number(value).toFixed(dec)); + return String(n); +} + +/** Upgrade {var} → {{var}} for declared names, and rewrite {{var=name}} → {{name}}. */ +function normalizePlaceholders(template, variableSpecs) { + let t = String(template || ""); + t = t.replace( + /\{\{\s*var\s*=\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/gi, + "{{$1}}" + ); + for (const spec of variableSpecs || []) { + const name = sanitizeVariableName(spec); + if (!name) continue; + const hasDouble = new RegExp(`\\{\\{\\s*${name}\\s*\\}\\}`).test(t); + if (hasDouble) continue; + const single = new RegExp(`\\{${name}\\}`, "g"); + t = t.replace(single, `{{${name}}}`); + } + return t; +} + +/** Prefer whichever of stem/title actually contains {{placeholders}}. */ +function resolveCalculationDisplayTemplate(stem, title, variableSpecs) { + const stemT = String(stem || "").trim(); + const titleT = String(title || "").trim(); + let template = stemT || titleT; + if (stemT && !stemT.includes("{{") && titleT.includes("{{")) { + template = titleT; + } + return normalizePlaceholders(template, variableSpecs); +} + +/** Final student-facing stem; appends "Given:" listing for any sampled value the template doesn't show. */ +function composeStudentCalculationStem(renderResult, values, variableSpecs) { + const baseText = String(renderResult?.text || ""); + const referenced = + renderResult && renderResult.referencedVariableNames instanceof Set + ? renderResult.referencedVariableNames + : new Set(); + const unknown = + renderResult && renderResult.unknownPlaceholderNames instanceof Set + ? renderResult.unknownPlaceholderNames + : new Set(); + + const specByName = {}; + for (const s of variableSpecs || []) { + const name = sanitizeVariableName(s); + if (name) specByName[name] = s; + } + + const missing = []; + for (const spec of variableSpecs || []) { + const name = sanitizeVariableName(spec); + if (!name) continue; + if (referenced.has(name)) continue; + if (!(name in values)) continue; + missing.push(name); + } + + if (missing.length === 0 && unknown.size === 0) { + return baseText; + } + + const visibleNames = + missing.length > 0 + ? missing + : Object.keys(values).filter((n) => !referenced.has(n)); + if (visibleNames.length === 0) { + return baseText; + } + + const givenParts = visibleNames.map( + (name) => `${name} = ${formatVariableForTemplate(values[name], specByName[name])}` + ); + const separator = baseText.trim().length > 0 ? "\n\n" : ""; + return `${baseText}${separator}Given: ${givenParts.join(", ")}.`; +} + +/** Replace {{name}} with formatted values; unknown placeholders render as "?" and are reported back. */ +function renderCalculationTemplate(template, values, variableSpecs = []) { + const specByName = {}; + for (const s of variableSpecs || []) { + const name = sanitizeVariableName(s); + if (name) specByName[name] = s; + } + const t = normalizePlaceholders(template, variableSpecs); + const referencedVariableNames = new Set(); + const unknownPlaceholderNames = new Set(); + const text = String(t || "").replace( + /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, + (_, name) => { + if (name in values) { + referencedVariableNames.add(name); + return formatVariableForTemplate(values[name], specByName[name]); + } + unknownPlaceholderNames.add(name); + return "?"; + } + ); + return { text, referencedVariableNames, unknownPlaceholderNames }; +} + +function evaluateCalculationFormula(formula, values) { + const coerced = {}; + if (values && typeof values === "object") { + for (const [k, raw] of Object.entries(values)) { + const n = Number(raw); + if (!Number.isFinite(n)) { + throw new Error(`Variable "${k}" must be numeric for formula evaluation`); + } + coerced[k] = n; + } + } + const allowedFromToken = new Set(Object.keys(coerced)); + let f = canonicalizeCalculationSyntax(formula); + if (!f) throw new Error("calculationFormula is required"); + f = normalizePiToBuiltin(f, allowedFromToken); + const parser = new Parser(); + let expr; + try { + expr = parser.parse(f); + } catch (e) { + throw formulaParseErrorToMessage(e); + } + const needed = expr.variables(); + const missing = needed.filter( + (n) => !(n in coerced) && !EXPR_EVAL_CONST_NAMES.has(n) + ); + if (missing.length > 0) { + throw new Error( + `Formula needs variable(s): ${missing.join(", ")}. Reload the quiz to get a fresh version of this question.` + ); + } + let result; + try { + result = expr.evaluate(coerced); + } catch (e) { + const msg = String(e?.message || ""); + if (/undefined variable/i.test(msg)) { + const m = msg.match(/undefined variable:\s*(\w+)/i); + const name = m ? m[1] : "?"; + throw new Error( + `Formula references "${name}" but it is missing from this attempt's values. Reload the quiz or ask your instructor to align the formula with calculationVariables.` + ); + } + if (/is not a function/i.test(msg)) { + throw new Error( + "Formula used implicit multiplication (e.g. 0.28(x+1) without *). The engine expects 0.28*(x+1). Save the question again to normalize the formula, or ask your instructor to add explicit * between a number and '('." + ); + } + throw e; + } + const num = Number(result); + if (!Number.isFinite(num)) { + throw new Error( + "Formula evaluation produced a non-finite value (NaN or Infinity). Common causes: sqrt or log of a negative number, division by zero, or a singularity inside the variable ranges (e.g. 1/(x+1) with x near -1). Narrow min/max for the variables or fix the formula." + ); + } + return num; +} + +/** True when another random draw of variables might succeed (domain / singularity). */ +function isRetryableCalculationDrawError(err) { + return /Formula evaluation produced a non-finite value/.test(String(err?.message || "")); +} + +function roundToDecimals(num, decimals) { + const d = Math.max(0, Math.min(12, parseInt(decimals, 10) || 0)); + const f = 10 ** d; + return Math.round(num * f) / f; +} + +function formatAnswerForDisplay(num, decimals) { + const d = Math.max(0, Math.min(12, parseInt(decimals, 10) || 0)); + const r = roundToDecimals(num, d); + if (d === 0) return String(Math.round(r)); + let s = r.toFixed(d); + if (s.includes(".")) s = s.replace(/\.?0+$/, ""); + return s; +} + +function parseStudentNumericAnswer(text) { + if (text === undefined || text === null) return NaN; + const cleaned = String(text) + .trim() + .replace(/,/g, "") + .replace(/\s+/g, ""); + if (cleaned === "") return NaN; + const n = parseFloat(cleaned); + return n; +} + +/** + * Compare student answer to expected value. + * When tolerancePercent is a finite number 0–100, grades by relative error: + * |student − expected| / |expected| ≤ tolerancePercent / 100 + * When expected ≈ 0 (|expected| < 1e-10), falls back to absolute error with the + * same threshold to avoid division by zero. + * When tolerancePercent is absent/null, uses existing decimal-rounding behaviour. + */ +function numericAnswersMatch(studentValue, expectedValue, answerDecimals, tolerancePercent) { + if (!Number.isFinite(studentValue) || !Number.isFinite(expectedValue)) return false; + + const d = Math.max(0, Math.min(12, parseInt(answerDecimals, 10) || 0)); + const tol = Number(tolerancePercent); + if (Number.isFinite(tol) && tol >= 0) { + const threshold = Math.max(0, Math.min(100, tol)) / 100; + const diff = Math.abs(studentValue - expectedValue); + // Use absolute comparison when expected rounds to zero at the displayed precision. + // Relative error would always be ~100% for sub-ULP values, even when both sides + // display as "0" — e.g. formula 1/n with large n gives 0.0001 which rounds to 0.00. + const displayZeroThreshold = 0.5 * Math.pow(10, -d); + if (Math.abs(expectedValue) < displayZeroThreshold) { + return diff < displayZeroThreshold; + } + return diff / Math.abs(expectedValue) <= threshold; + } + + const a = roundToDecimals(studentValue, d); + const b = roundToDecimals(expectedValue, d); + const eps = Math.max(10 ** -(d + 2), 1e-12); + return Math.abs(a - b) <= eps; +} + +function signCalculationToken(questionId, values) { + const exp = Date.now() + 24 * 60 * 60 * 1000; + const payloadObj = { qid: String(questionId), v: values, exp }; + const payload = Buffer.from(JSON.stringify(payloadObj), "utf8").toString("base64url"); + const sig = crypto.createHmac("sha256", getHmacSecret()).update(payload).digest("hex"); + return `${payload}.${sig}`; +} + +function verifyCalculationToken(token) { + if (!token || typeof token !== "string") return null; + const dot = token.lastIndexOf("."); + if (dot <= 0) return null; + const payload = token.slice(0, dot); + const sig = token.slice(dot + 1); + const expectedSig = crypto.createHmac("sha256", getHmacSecret()).update(payload).digest("hex"); + try { + const a = Buffer.from(sig, "hex"); + const b = Buffer.from(expectedSig, "hex"); + if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null; + } catch { + return null; + } + let data; + try { + data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + } catch { + return null; + } + if (!data || typeof data !== "object") return null; + if (typeof data.exp !== "number" || Date.now() > data.exp) return null; + if (!data.qid || typeof data.v !== "object" || data.v === null) return null; + return { questionId: data.qid, values: data.v, exp: data.exp }; +} + +module.exports = { + generateVariableValues, + renderCalculationTemplate, + resolveCalculationDisplayTemplate, + buildStudentCalculationInstance, + validateFormulaAgainstVariableSpecs, + validateFormulaReferencesAllVariables, + validateNoReservedVariableNames, + validateStemReferencesAllVariables, + getStemReferencedVariableNames, + prepareCalculationFormula, + canonicalizeCalculationSyntax, + normalizeAsciiFormula, + evaluateCalculationFormula, + roundToDecimals, + formatAnswerForDisplay, + parseStudentNumericAnswer, + numericAnswersMatch, + signCalculationToken, + verifyCalculationToken, +}; diff --git a/src/services/question.js b/src/services/question.js index 8bbd43c..4c883ab 100644 --- a/src/services/question.js +++ b/src/services/question.js @@ -1,4 +1,6 @@ const databaseService = require('./database'); +const { QUESTION_TYPES } = require('../constants/app-constants'); +const calculationQuestion = require('./calculation-question'); const { ObjectId } = require('mongodb'); const saveQuestion = async (courseId, questionData) => { @@ -18,12 +20,71 @@ const saveQuestion = async (courseId, questionData) => { : questionData.granularObjectiveId; } + const questionType = + questionData.questionType || + questionData.type || + QUESTION_TYPES.MULTIPLE_CHOICE; + + if (String(questionType).toLowerCase() === QUESTION_TYPES.CALCULATION) { + calculationQuestion.validateFormulaAgainstVariableSpecs( + typeof questionData.calculationFormula === "string" + ? questionData.calculationFormula + : "", + Array.isArray(questionData.calculationVariables) + ? questionData.calculationVariables + : [] + ); + } + // Save the full question data including granularObjectiveId + const answerDecRaw = questionData.calculationAnswerDecimals; + const calculationAnswerDecimals = + answerDecRaw !== undefined && answerDecRaw !== null && answerDecRaw !== "" + ? Math.max(0, Math.min(12, parseInt(answerDecRaw, 10) || 2)) + : 2; + + const tolRaw = questionData.calculationAnswerTolerancePercent; + const calculationAnswerTolerancePercent = + tolRaw !== undefined && tolRaw !== null && tolRaw !== "" + ? Math.max(0, Math.min(100, parseFloat(tolRaw) || 0)) + : null; + + const calcVarsForStore = Array.isArray(questionData.calculationVariables) + ? questionData.calculationVariables + : []; + const calcFormulaRaw = + typeof questionData.calculationFormula === "string" + ? questionData.calculationFormula + : ""; + + const qtLower = String(questionType).toLowerCase(); const question = await collection.insertOne({ title: questionData.title, stem: questionData.stem, options: questionData.options, correctAnswer: questionData.correctAnswer, + questionType, + acceptableAnswers: Array.isArray(questionData.acceptableAnswers) + ? questionData.acceptableAnswers + : [], + openEndedSampleAnswer: + qtLower === QUESTION_TYPES.OPEN_ENDED + ? String(questionData.openEndedSampleAnswer || "").trim() + : "", + openEndedGradingCriteria: + qtLower === QUESTION_TYPES.OPEN_ENDED + ? String(questionData.openEndedGradingCriteria || "").trim() + : "", + calculationFormula: + qtLower === QUESTION_TYPES.CALCULATION + ? calculationQuestion.prepareCalculationFormula( + calcFormulaRaw, + calcVarsForStore + ) + : calcFormulaRaw, + calculationVariables: calcVarsForStore, + calculationAnswerDecimals, + calculationAnswerTolerancePercent, bloom: questionData.bloom, difficulty: questionData.difficulty, courseId: courseIdObj, @@ -149,6 +210,48 @@ const updateQuestion = async (questionId, updateData) => { if (updateData.difficulty !== undefined) update.difficulty = updateData.difficulty; if (updateData.status !== undefined) update.status = updateData.status; if (updateData.flagStatus !== undefined) update.flagStatus = updateData.flagStatus; + if (updateData.questionType !== undefined) update.questionType = updateData.questionType; + if (updateData.type !== undefined && updateData.questionType === undefined) { + update.questionType = updateData.type; + } + if (updateData.acceptableAnswers !== undefined) { + update.acceptableAnswers = Array.isArray(updateData.acceptableAnswers) + ? updateData.acceptableAnswers + : []; + } + if (updateData.calculationFormula !== undefined) { + update.calculationFormula = + typeof updateData.calculationFormula === "string" ? updateData.calculationFormula : ""; + } + if (updateData.calculationVariables !== undefined) { + update.calculationVariables = Array.isArray(updateData.calculationVariables) + ? updateData.calculationVariables + : []; + } + if (updateData.calculationAnswerDecimals !== undefined) { + const d = parseInt(updateData.calculationAnswerDecimals, 10); + update.calculationAnswerDecimals = Math.max(0, Math.min(12, Number.isFinite(d) ? d : 2)); + } + if (updateData.calculationAnswerTolerancePercent !== undefined) { + const t = parseFloat(updateData.calculationAnswerTolerancePercent); + update.calculationAnswerTolerancePercent = (updateData.calculationAnswerTolerancePercent === null || + updateData.calculationAnswerTolerancePercent === "" || + !Number.isFinite(t)) + ? null + : Math.max(0, Math.min(100, t)); + } + if (updateData.openEndedSampleAnswer !== undefined) { + update.openEndedSampleAnswer = + typeof updateData.openEndedSampleAnswer === "string" + ? updateData.openEndedSampleAnswer.trim() + : ""; + } + if (updateData.openEndedGradingCriteria !== undefined) { + update.openEndedGradingCriteria = + typeof updateData.openEndedGradingCriteria === "string" + ? updateData.openEndedGradingCriteria.trim() + : ""; + } if (updateData.granularObjectiveId !== undefined) { // Convert granularObjectiveId to ObjectId if it's a string update.granularObjectiveId = updateData.granularObjectiveId @@ -157,6 +260,38 @@ const updateQuestion = async (questionId, updateData) => { : updateData.granularObjectiveId) : null; } + + const touchesCalculation = + update.calculationFormula !== undefined || + update.calculationVariables !== undefined || + update.questionType !== undefined; + if (touchesCalculation) { + const existing = await collection.findOne({ _id: id }); + if (existing) { + const merged = { ...existing, ...update }; + const qt = String(merged.questionType || merged.type || "") + .trim() + .toLowerCase(); + if (qt === QUESTION_TYPES.CALCULATION) { + const mergedFormula = + typeof merged.calculationFormula === "string" + ? merged.calculationFormula + : ""; + const mergedVars = Array.isArray(merged.calculationVariables) + ? merged.calculationVariables + : []; + calculationQuestion.validateFormulaAgainstVariableSpecs( + mergedFormula, + mergedVars + ); + update.calculationFormula = + calculationQuestion.prepareCalculationFormula( + mergedFormula, + mergedVars + ); + } + } + } const result = await collection.updateOne( { _id: id }, diff --git a/src/services/rag.js b/src/services/rag.js index d8a1661..f749561 100644 --- a/src/services/rag.js +++ b/src/services/rag.js @@ -2,6 +2,12 @@ // Handles all RAG initialization and provides helper functions const { getObjectiveWithMaterials } = require('./objective'); +/** Qdrant collection vector size; must match the embedding model output. */ +function resolveQdrantVectorSize() { + const n = parseInt(process.env.QDRANT_VECTOR_SIZE, 10); + return Number.isFinite(n) && n > 0 ? n : 768; +} + class RAGService { constructor() { if (RAGService.instance) { @@ -36,22 +42,35 @@ class RAGService { throw new Error("Failed to load RAGModule or ConsoleLogger"); } + const llmProvider = process.env.LLM_PROVIDER || 'ollama'; + const embeddingLlmConfig = + llmProvider === 'openai' + ? { + provider: 'openai', + defaultModel: + process.env.LLM_EMBEDDING_MODEL || process.env.OPENAI_MODEL, + apiKey: process.env.OPENAI_API_KEY, + } + : { + provider: 'ollama', + endpoint: + process.env.OLLAMA_ENDPOINT || 'http://localhost:11434', + defaultModel: + process.env.LLM_EMBEDDING_MODEL || process.env.OLLAMA_MODEL, + }; + this.baseConfig = { provider: "qdrant", qdrantConfig: { url: process.env.QDRANT_URL || "http://localhost:6333", - vectorSize: parseInt(process.env.QDRANT_VECTOR_SIZE) || 768, + vectorSize: resolveQdrantVectorSize(), distanceMetric: 'Cosine', apiKey: process.env.QDRANT_API_KEY }, embeddingsConfig: { providerType: process.env.EMBEDDING_PROVIDER, model: process.env.LLM_EMBEDDING_MODEL, - llmConfig: { - provider: process.env.LLM_PROVIDER, - defaultModel: process.env.OPENAI_MODEL, - apiKey: process.env.OPENAI_API_KEY - }, + llmConfig: embeddingLlmConfig, } }; @@ -63,13 +82,16 @@ class RAGService { } /** - * Standardize collection name for a course + * Standardize collection name for a course. + * Includes vector size so changing QDRANT_VECTOR_SIZE uses a new Qdrant collection + * (Qdrant cannot alter vector dimension on an existing collection). */ getCollectionName(courseId) { if (!courseId) return process.env.QDRANT_COLLECTION_NAME || "question-generation-collection"; // Normalize string ID const cid = typeof courseId === 'string' ? courseId : courseId.toString(); - return `grasp_course_${cid}`; + const dim = resolveQdrantVectorSize(); + return `grasp_course_${cid}_v${dim}`; } async getOrCreateInstance(courseId) { diff --git a/src/services/settings.js b/src/services/settings.js index 10b3c08..ef5e048 100644 --- a/src/services/settings.js +++ b/src/services/settings.js @@ -1,11 +1,27 @@ const databaseService = require('./database'); -const { DEFAULT_PROMPTS, DEFAULT_GENERAL } = require('../constants/app-constants'); +const { DEFAULT_PROMPTS, DEFAULT_BLOOM_TYPE_PREFERENCES } = require('../constants/app-constants'); // Mapping between hierarchical object structure and DB flat keys const KEY_MAP = { 'prompts.questionGeneration': 'prompt_question_generation', 'prompts.objectiveGenerationAuto': 'prompt_objective_generation_auto', - 'prompts.objectiveGenerationManual': 'prompt_objective_generation_manual' + 'prompts.objectiveGenerationManual': 'prompt_objective_generation_manual', + 'bloomTypePreferences': 'bloom_type_preferences', +}; + +const REQUIRED_PROMPT_MARKERS = { + questionGeneration: [ + '{questionType}', + '{learningObjectiveText}', + '{ragContext}' + ] +}; + +const isStalePromptSnapshot = (promptKey, value) => { + const required = REQUIRED_PROMPT_MARKERS[promptKey]; + if (!required) return false; + const v = String(value || ''); + return required.some((marker) => !v.includes(marker)); }; /** @@ -26,29 +42,49 @@ const getSettings = async (courseId) => { // Reconstruct the hierarchical settings object const settings = { - prompts: {} + prompts: {}, + bloomTypePreferences: null, }; - // Populate prompts with defaults or found values + // Resolve each prompt: use stored value when present and structurally compatible, + // otherwise fall back to the current default and persist it (insert if missing, refresh if stale). for (const promptKey in DEFAULT_PROMPTS) { const dbKey = KEY_MAP[`prompts.${promptKey}`]; - settings.prompts[promptKey] = (dbKey ? settingsMap[dbKey] : null) ?? DEFAULT_PROMPTS[promptKey]; - } - - // Proactively save defaults if they don't exist (only for mapped keys) for this course - for (const path in KEY_MAP) { - const dbKey = KEY_MAP[path]; - if (!(dbKey in settingsMap)) { - const item = path.split('.')[1]; - const value = DEFAULT_PROMPTS[item]; + if (!dbKey) { + settings.prompts[promptKey] = DEFAULT_PROMPTS[promptKey]; + continue; + } + const storedValue = settingsMap[dbKey]; + const stored = storedValue != null; + const stale = stored && isStalePromptSnapshot(promptKey, storedValue); + if (!stored || stale) { + if (stale) { + console.log(`[settings] Refreshing stale prompt snapshot "${dbKey}" for course ${courseId}.`); + } + settings.prompts[promptKey] = DEFAULT_PROMPTS[promptKey]; await collection.updateOne( - { name: dbKey, courseId: courseId }, - { $set: { name: dbKey, value: value, courseId: courseId, updatedAt: new Date() } }, + { name: dbKey, courseId }, + { $set: { name: dbKey, value: DEFAULT_PROMPTS[promptKey], courseId, updatedAt: new Date() } }, { upsert: true } ); + } else { + settings.prompts[promptKey] = storedValue; } } - + + // Resolve bloomTypePreferences: parse stored JSON or fall back to default. + const bloomDbKey = KEY_MAP['bloomTypePreferences']; + const storedBloom = settingsMap[bloomDbKey]; + if (storedBloom) { + try { + settings.bloomTypePreferences = JSON.parse(storedBloom); + } catch { + settings.bloomTypePreferences = DEFAULT_BLOOM_TYPE_PREFERENCES; + } + } else { + settings.bloomTypePreferences = DEFAULT_BLOOM_TYPE_PREFERENCES; + } + return settings; } catch (error) { console.error(`Error getting settings for course ${courseId}:`, error); @@ -68,23 +104,29 @@ const updateSettings = async (courseId, updateData) => { const operations = []; - // Function to flatten and create bulk ops + // Function to flatten and create bulk ops. + // KEY_MAP is checked first: if the current path maps to a DB key, store it directly + // (serializing objects/arrays to JSON). Only recurse into plain objects that are NOT + // themselves a top-level key — this prevents bloomTypePreferences from being + // flattened into per-level entries. const processUpdates = (obj, prefix = '') => { for (const key in obj) { const path = prefix ? `${prefix}.${key}` : key; - if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) { + const dbKey = KEY_MAP[path]; + if (dbKey) { + const raw = obj[key]; + const value = (raw !== null && typeof raw === 'object') + ? JSON.stringify(raw) + : raw; + operations.push({ + updateOne: { + filter: { name: dbKey, courseId: courseId }, + update: { $set: { name: dbKey, value, courseId: courseId, updatedAt: new Date() } }, + upsert: true + } + }); + } else if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) { processUpdates(obj[key], path); - } else { - const dbKey = KEY_MAP[path]; - if (dbKey) { - operations.push({ - updateOne: { - filter: { name: dbKey, courseId: courseId }, - update: { $set: { name: dbKey, value: obj[key], courseId: courseId, updatedAt: new Date() } }, - upsert: true - } - }); - } } } };