From cfb359dd8ac10c3c56a4e26fef41bb535a2bd740 Mon Sep 17 00:00:00 2001 From: Grace Xue Date: Fri, 3 Apr 2026 19:27:08 -0700 Subject: [PATCH 01/20] create fill-in-the-blank questions with llm service --- public/scripts/direct-ollama-service.js | 58 ++- public/scripts/generation-questions.js | 456 +++++++++++++++--------- public/scripts/llm-service.js | 60 +++- public/scripts/question-generation.js | 134 ++++--- public/settings.html | 1 + src/constants/app-constants.js | 48 ++- src/controllers/quiz.js | 86 ++++- src/controllers/rag-llm.js | 353 ++++++++++++++++-- src/controllers/student.js | 34 +- src/services/question.js | 18 + src/services/rag.js | 38 +- 11 files changed, 999 insertions(+), 287 deletions(-) diff --git a/public/scripts/direct-ollama-service.js b/public/scripts/direct-ollama-service.js index a37829e..e466029 100644 --- a/public/scripts/direct-ollama-service.js +++ b/public/scripts/direct-ollama-service.js @@ -58,12 +58,27 @@ class DirectOpenAIService { } } + async generateQuestionByType(questionType, objective, ragContent, bloomLevel) { + switch (questionType) { + case "fill-in-the-blank": + return await this.generateFillInTheBlankQuestion(objective, ragContent, bloomLevel); + case "multiple-choice": + default: + return await this.generateMultipleChoiceQuestion(objective, ragContent, bloomLevel); + } + } + async generateMultipleChoiceQuestion(objective, ragContent, bloomLevel) { - const prompt = this.createQuestionPrompt(objective, bloomLevel); + const prompt = this.createMultipleChoiceQuestionPrompt(objective, bloomLevel); + return await this.generateQuestionWithRAG(prompt, ragContent); + } + + async generateFillInTheBlankQuestion(objective, ragContent, bloomLevel) { + const prompt = this.createFillInTheBlankQuestionPrompt(objective, bloomLevel); return await this.generateQuestionWithRAG(prompt, ragContent); } - createQuestionPrompt(objective, bloomLevel) { + createMultipleChoiceQuestionPrompt(objective, bloomLevel) { return `You are an expert educational content creator. Generate a high-quality multiple-choice question based on the provided content. OBJECTIVE: ${objective} @@ -78,6 +93,7 @@ INSTRUCTIONS: 6. Focus on the specific concepts, examples, or details mentioned in the content 7. Format your response as JSON with this structure: { + "type": "multiple-choice", "question": "Your specific question here", "options": { "A": "First option text", @@ -108,6 +124,44 @@ IMPORTANT: - 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".`; } + createFillInTheBlankQuestionPrompt(objective, bloomLevel) { + return `You are an expert educational content creator. Generate a high-quality fill-in-the-blank question based on the provided content. + +OBJECTIVE: ${objective} +BLOOM'S TAXONOMY LEVEL: ${bloomLevel} + +INSTRUCTIONS: +1. Create one specific fill-in-the-blank question based on the provided content. +2. The blank should test an important term, number, phrase, formula component, or concept from the materials. +3. Use actual details from the content - do not make the question generic. +4. The sentence should remain clear and meaningful with exactly one blank. +5. Do not make the blank trivial unless the learning goal is simple recall. +6. Provide the correct answer. +7. Provide a short explanation based on the content. +8. Format your response as JSON with this structure: +{ + "type": "fill-in-the-blank", + "question": "Your sentence with one blank, written like this: The capital of France is ____.", + "correctAnswer": "Paris", + "acceptableAnswers": ["Paris"], + "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 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 the question on specific details, examples, formulas, or concepts from the provided content. +- Use exactly one blank written as ____. +- The correctAnswer must be the best canonical answer. +- acceptableAnswers should include reasonable equivalent answers when appropriate. +- If mathematical expressions are used, always wrap them in LaTeX delimiters using \\( ... \\) for inline math and \\[ ... \\] for display math.`; + } + isAvailable() { return this.isInitialized; } diff --git a/public/scripts/generation-questions.js b/public/scripts/generation-questions.js index 6e068fb..7566e42 100644 --- a/public/scripts/generation-questions.js +++ b/public/scripts/generation-questions.js @@ -12,70 +12,59 @@ class QuestionGenerator { async initializeLLMService() { try { console.log("=== QUESTION GENERATOR LLM INITIALIZATION ==="); - - // Use server-side RAG + LLM endpoint + this.llmService = { isAvailable: () => true, - generateMultipleChoiceQuestion: async ( + + generateMultipleChoiceQuestion: async ({ + courseId, courseName, learningObjectiveId, learningObjectiveText, granularLearningObjectiveText, - bloomLevel - ) => { - console.log('Generating multiple choice question...', { - courseName: courseName, - learningObjectiveId: learningObjectiveId, - learningObjectiveText: learningObjectiveText, - granularLearningObjectiveText: granularLearningObjectiveText, - bloomLevel: bloomLevel, + bloomLevel, + }) => { + return await this.callQuestionGenerationApi({ + courseId, + courseName, + learningObjectiveId, + learningObjectiveText, + granularLearningObjectiveText, + bloomLevel, + questionType: "multiple-choice", }); - 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; + }, + + generateFillInTheBlankQuestion: async ({ + courseId, + courseName, + learningObjectiveId, + learningObjectiveText, + granularLearningObjectiveText, + bloomLevel, + }) => { + return await this.callQuestionGenerationApi({ + courseId, + courseName, + learningObjectiveId, + learningObjectiveText, + granularLearningObjectiveText, + bloomLevel, + questionType: "fill-in-the-blank", + }); + }, + + generateQuestionByType: async (questionType, params) => { + switch (questionType) { + case "fill-in-the-blank": + return await this.llmService.generateFillInTheBlankQuestion(params); + case "multiple-choice": + default: + return await this.llmService.generateMultipleChoiceQuestion(params); } }, }; - + console.log("✅ Server-side RAG + LLM service initialized"); } catch (error) { console.error("❌ Failed to initialize LLM service:", error); @@ -83,6 +72,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 +192,22 @@ class QuestionGenerator { } } + getBloomTypePreferences() { + return { + Remember: ["fill-in-the-blank", "multiple-choice"], + Understand: ["multiple-choice", "fill-in-the-blank"], + Apply: ["multiple-choice", "fill-in-the-blank"], + Analyze: ["multiple-choice", "fill-in-the-blank"], + Evaluate: ["multiple-choice", "fill-in-the-blank"], + Create: ["multiple-choice", "fill-in-the-blank"], + }; + } + + determineQuestionType(bloomLevel) { + const preferences = this.getBloomTypePreferences(); + return preferences[bloomLevel]?.[0] || "multiple-choice"; + } + prepareContentForQuestions(summary, objectiveGroups) { let content = `Summary: ${summary}\n\n`; content += `Objectives:\n`; @@ -180,10 +238,11 @@ 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; @@ -201,7 +260,8 @@ class QuestionGenerator { granularLearningObjective.granularId, granularLearningObjective.text, bloomLevel, - i + 1 + i + 1, + questionType ); console.log(`✅ Created question ${i + 1}:`, question.text); @@ -231,6 +291,7 @@ class QuestionGenerator { failedQuestions.push({ questionNumber: i + 1, bloomLevel: bloomLevel, + questionType: questionType, error: error.message }); // Continue with next question instead of stopping @@ -269,70 +330,61 @@ 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 (!response.question) { - throw new Error("Invalid response: question data missing"); - } - - const questionData = response.question; - - return { - id: `${granularLearningObjectiveId}-${questionNumber}`, - granularObjectiveId: `${granularLearningObjectiveId}`, - text: questionData.question, - type: "multiple-choice", - options: questionData.options, - correctAnswer: questionData.correctAnswer, - 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; - } - } else { + 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, + } + ); + + const resolvedType = questionData.type || questionData.questionType || questionType; + const acceptable = + resolvedType === "fill-in-the-blank" + ? Array.isArray(questionData.acceptableAnswers) && questionData.acceptableAnswers.length + ? questionData.acceptableAnswers + : questionData.correctAnswer != null + ? [String(questionData.correctAnswer)] + : [] + : []; + + return { + id: `${granularLearningObjectiveId}-${questionNumber}`, + granularObjectiveId: `${granularLearningObjectiveId}`, + text: questionData.question, + 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; + } } // Extract key concepts from content for question generation @@ -379,41 +431,113 @@ 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 || "multiple-choice"; + if (qt === "fill-in-the-blank") { + const acc = + Array.isArray(q.acceptableAnswers) && q.acceptableAnswers.length + ? q.acceptableAnswers.join("; ") + : q.correctAnswer != null + ? String(q.correctAnswer) + : ""; + csv += `${this.escapeCsvField(qt)},${this.escapeCsvField(q.text)},${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; + } + 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 || "multiple-choice"; + const ident = `q${index + 1}`; + if (qt === "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)} + + + + + + + + + + + + + + ${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 +548,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 +572,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 index bf9d761..8b3a81d 100644 --- a/public/scripts/llm-service.js +++ b/public/scripts/llm-service.js @@ -68,25 +68,41 @@ class LLMService { } } + async generateQuestionByType(questionType, objective, ragContent, bloomLevel) { + switch (questionType) { + case "fill-in-the-blank": + return await this.generateFillInTheBlankQuestion(objective, ragContent, bloomLevel); + case "multiple-choice": + default: + return await this.generateMultipleChoiceQuestion(objective, ragContent, bloomLevel); + } + } + async generateMultipleChoiceQuestion(objective, ragContent, bloomLevel) { - const prompt = this.createQuestionPrompt(objective, bloomLevel); + const prompt = this.createMultipleChoiceQuestionPrompt(objective, bloomLevel); + return await this.generateQuestionWithRAG(prompt, ragContent); + } + + async generateFillInTheBlankQuestion(objective, ragContent, bloomLevel) { + const prompt = this.createFillInTheBlankQuestionPrompt(objective, bloomLevel); return await this.generateQuestionWithRAG(prompt, ragContent); } - createQuestionPrompt(objective, bloomLevel) { + createMultipleChoiceQuestionPrompt(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. +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: { + "type": "multiple-choice", "question": "Your specific question here", "options": { "A": "First option text", // The first option @@ -110,6 +126,44 @@ IMPORTANT: - 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".`; } + createFillInTheBlankQuestionPrompt(objective, bloomLevel) { + return `You are an expert educational content creator. Generate a high-quality fill-in-the-blank question based on the provided content. + +OBJECTIVE: ${objective} +BLOOM'S TAXONOMY LEVEL: ${bloomLevel} + +INSTRUCTIONS: +1. Create one specific fill-in-the-blank question based on the provided content. +2. The blank should test an important term, number, phrase, formula component, or concept from the materials. +3. Use actual details from the content - do not make the question generic. +4. The sentence should remain clear and meaningful with exactly one blank. +5. Do not make the blank trivial unless the learning goal is simple recall. +6. Provide the correct answer. +7. Provide a short explanation based on the content. +8. Format your response as JSON with this structure: +{ + "type": "fill-in-the-blank", + "question": "Your sentence with one blank, written like this: The capital of France is ____.", + "correctAnswer": "Paris", + "acceptableAnswers": ["Paris"], + "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 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 the question on specific details, examples, formulas, or concepts from the provided content. +- Use exactly one blank written as ____. +- The correctAnswer must be the best canonical answer. +- acceptableAnswers should include reasonable equivalent answers when appropriate. +- If mathematical expressions are used, always wrap them in LaTeX delimiters using \\( ... \\) for inline math and \\[ ... \\] for display math.`; + } + isAvailable() { return this.isInitialized && this.llmModule !== null; } diff --git a/public/scripts/question-generation.js b/public/scripts/question-generation.js index 59ad120..213cea7 100644 --- a/public/scripts/question-generation.js +++ b/public/scripts/question-generation.js @@ -3013,53 +3013,90 @@ 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 || "multiple-choice"; + const isFib = qType === "fill-in-the-blank"; + 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: "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.text, + stem: "Fill in the blank:", + questionType: "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, + }; + + return { + id: `lo-${index + 1}-${itemIndex + 1}`, + code: `LO ${index + 1}.${itemIndex + 1}`, + generated: question.count || 1, + min: 1, + badges: [], + questions: [isFib ? fibCard : mcCard], + }; + }), }; groups.push(group); @@ -3761,7 +3798,12 @@ async function handleSaveToQuiz() { title: question.title || question.stem || "", stem: question.stem || question.title || "", options: question.options || [], - correctAnswer: question.correctAnswer || 0, + correctAnswer: question.correctAnswer ?? "", + questionType: + question.questionType || question.type || "multiple-choice", + acceptableAnswers: Array.isArray(question.acceptableAnswers) + ? question.acceptableAnswers + : [], bloom: question.bloom || question.bloomLevel || "Understand", difficulty: question.difficulty || "medium", granularObjectiveId: question.granularObjectiveId || null, diff --git a/public/settings.html b/public/settings.html index f7663fe..e311de5 100644 --- a/public/settings.html +++ b/public/settings.html @@ -60,6 +60,7 @@

LLM Prompts

  • {learningObjectiveText}: The text of the parent learning objective.
  • {granularLearningObjectiveText}: The specific sub-objective text.
  • {bloomLevel}: The targeted Bloom's Taxonomy level(s).
  • +
  • {questionType}: The type of question to generate.
  • {ragContext}: Relevant educational content retrieved from course materials.
  • diff --git a/src/constants/app-constants.js b/src/constants/app-constants.js index b641cb8..813ba4b 100644 --- a/src/constants/app-constants.js +++ b/src/constants/app-constants.js @@ -2,22 +2,30 @@ * 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 QUESTION_GENERATION_PROMPT = `You behave like a strict JSON API, not a chat assistant. + +MANDATORY OUTPUT (read first): +- Output EXACTLY one JSON object and NOTHING else: no preamble, no "##" headings, no bullet lists, no step-by-step reasoning, no "To address this", no summaries of the source, no "The final answer", no markdown code fences. +- The first character of your entire reply MUST be "{" and the last MUST be "}". +- Put all question text, options, and explanations INSIDE the JSON string fields only. Learning Objective: {learningObjectiveText} Granular Learning Objective: {granularLearningObjectiveText} Bloom's Taxonomy Level(s): {bloomLevel} +Question Type: {questionType} -Task: Create a multiple-choice question based on the provided content that effectively test students' understanding of the course learning objective. +Task: Use ONLY the schema that matches Question Type. Base the question on the CONTENT section below (do not summarize or discuss the content in plain text). +--- If Question Type is "multiple-choice" --- PROCEDURE: -1. Create the question content +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 +3. Set correctAnswer to the letter corresponding to the correct option (e.g. "C"). +4. Write a brief explanation. The response format must be a valid JSON with the exact structure as follows: { + "type": "multiple-choice", "question": "Your specific question here", "options": { "A": "First option text", @@ -28,14 +36,32 @@ The response format must be a valid JSON with the exact structure as follows: "correctAnswer": "C", "explanation": "Why this answer is correct based on the content" } +Rules: Four non-empty options; correctAnswer is only "A", "B", "C", or "D"; randomize which letter is correct; option text must NOT start with "A)" or "A." style prefixes. -CRITICAL FORMATTING REQUIREMENTS: -- Return ONLY valid JSON. -- Do NOT wrap the JSON 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. +--- If Question Type is "fill-in-the-blank" --- +PROCEDURE: +1. Create one sentence with exactly one blank, written as ____. +2. correctAnswer must be the best canonical short answer (a word, phrase, or number as appropriate). +3. acceptableAnswers must be an array of strings including the canonical answer and close synonyms or equivalent forms (e.g. spacing, common abbreviations) when reasonable. +4. Do NOT include an "options" object for this type. + +Return valid JSON exactly in this shape (include the "type" field): +{ + "type": "fill-in-the-blank", + "question": "One sentence with exactly one blank as ____.", + "correctAnswer": "canonical answer", + "acceptableAnswers": ["canonical answer", "optional synonym"], + "explanation": "Why this answer is correct based on the content" +} +Rules: No "options" key. Use ____ for the blank. acceptableAnswers must include correctAnswer. +CRITICAL FORMATTING REQUIREMENTS (both types): +- 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 JSON strings (each backslash in the content becomes \\\\\\\\ in JSON where needed). +- For multiple-choice: Do NOT include letter prefixes (A), B), etc.) inside the option text values. +FORMATTING INSIDE JSON STRINGS: +- Escape backslashes for LaTeX: use \\\\\\\\ where a single backslash is needed in the rendered math, so JSON.parse succeeds. CONTENT: {ragContext}`; 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. diff --git a/src/controllers/quiz.js b/src/controllers/quiz.js index b0b95e1..4379bdb 100644 --- a/src/controllers/quiz.js +++ b/src/controllers/quiz.js @@ -204,6 +204,14 @@ function shuffleArray(array) { return shuffled; } +function resolveQuestionType(q) { + const t = q.questionType || q.type; + if (t === "fill-in-the-blank") { + return "fill-in-the-blank"; + } + return "multiple-choice"; +} + // Helper function to generate a shuffled order of option indices function shuffleQuestionOptions(question) { const optionIndices = [0, 1, 2, 3]; @@ -238,6 +246,25 @@ const getQuizQuestionsHandler = async (req, res) => { } const transformedQuestions = questions.map((q, index) => { + const questionType = resolveQuestionType(q); + const questionText = (q.title || q.stem || "").trim(); + + if (questionType === "fill-in-the-blank") { + 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: "fill-in-the-blank", + options: {}, + }; + let finalQuestion = formattedQuestion; + if (approvedOnlyBool) { + delete finalQuestion.correctAnswer; + delete finalQuestion.acceptableAnswers; + } + return finalQuestion; + } + let optionsObj = {}; if (q.options && typeof q.options === 'object') { if (!Array.isArray(q.options)) { @@ -256,34 +283,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: "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 +354,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 +362,41 @@ 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); + + if (questionType === "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", + }); + } + const normalize = (s) => String(s).trim().toLowerCase(); + const given = normalize(answerText); + const acceptableRaw = + Array.isArray(question.acceptableAnswers) && question.acceptableAnswers.length > 0 + ? question.acceptableAnswers + : [question.correctAnswer]; + const normalizedAcceptable = acceptableRaw + .map((a) => normalize(a)) + .filter(Boolean); + const isCorrect = normalizedAcceptable.some((a) => a === given); + const canonical = String(question.correctAnswer || "").trim(); + res.json({ + success: true, + isCorrect, + feedback: isCorrect ? "Correct." : "Incorrect.", + correctAnswer: isCorrect ? canonical : null, + correctOptionText: isCorrect ? 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 +404,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 +413,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..6df01da 100644 --- a/src/controllers/rag-llm.js +++ b/src/controllers/rag-llm.js @@ -30,6 +30,258 @@ 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 === "fill-in-the-blank" || t === "multiple-choice") { + 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 || "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"); + } + if (!data.question || typeof data.question !== "string" || !data.question.trim()) { + throw new Error("Missing required field: question"); + } + + const resolvedType = resolveQuestionTypeFromPayload(data, requestedType); + if (resolvedType !== requestedType) { + throw new Error( + `Response type "${resolvedType}" does not match requested questionType "${requestedType}"` + ); + } + + if (resolvedType === "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]; + } + } + return { + type: "fill-in-the-blank", + questionType: "fill-in-the-blank", + question: merged.question.trim(), + 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: "multiple-choice", + questionType: "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" string. + * 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" && + typeof obj.question === "string" && + obj.question.trim() + ) { + return obj; + } + } catch (_) { + /* try next { */ + } + } + pos = start + 1; + } + return null; +} + function safeJsonParse(jsonInput) { // If it's already an object, return it if (typeof jsonInput === 'object' && jsonInput !== null && !Array.isArray(jsonInput)) { @@ -49,11 +301,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 +316,19 @@ function safeJsonParse(jsonInput) { } } +function jsonOnlyRetrySuffix(attempt, questionType) { + 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, required keys: "type":"fill-in-the-blank", "question", "correctAnswer" (short text), "acceptableAnswers" (array of strings), "explanation". Do not use "options".`; + const schema = questionType === "multiple-choice" ? mcSchema : fibSchema; + 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; @@ -129,7 +393,7 @@ const searchRagHandler = async (req, res) => { 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 +402,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 +412,15 @@ const generateQuestionsWithRagHandler = async (req, res) => { }); } + // Validate question types + const ALLOWED_QUESTION_TYPES = ["multiple-choice", "fill-in-the-blank"]; + 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 +463,17 @@ 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 || ''); + // Create prompt with RAG context (optional retry suffix nudges small/local models toward JSON-only) + const buildBasePrompt = () => + promptTemplate + .replace('{learningObjectiveText}', learningObjectiveText || '') + .replace('{granularLearningObjectiveText}', granularLearningObjectiveText || '') + .replace('{bloomLevel}', bloomLevel || '') + .replace('{questionType}', questionType || '') + .replace('{ragContext}', ragContext || ''); + + const createPrompt = (attempt) => + attempt > 1 ? buildBasePrompt() + jsonOnlyRetrySuffix(attempt, questionType) : buildBasePrompt(); // Retry logic: regenerate until we get valid JSON const maxRetries = 5; @@ -204,8 +483,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); + const response = await llmModule.sendMessage(promptForAttempt); + console.log("Full Prompt: ", promptForAttempt); console.log("✅ LLM service response received"); console.log( @@ -213,6 +493,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 +505,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 +524,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 +534,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 +565,18 @@ 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 === "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` + ); + } } res.json({ diff --git a/src/controllers/student.js b/src/controllers/student.js index 70adb32..20d40fc 100644 --- a/src/controllers/student.js +++ b/src/controllers/student.js @@ -108,6 +108,14 @@ function shuffleArray(array) { return shuffled; } +function resolveQuestionType(q) { + const t = q.questionType || q.type; + if (t === "fill-in-the-blank") { + return "fill-in-the-blank"; + } + return "multiple-choice"; +} + // Helper function to shuffle question options and update correct answer function shuffleQuestionOptions(question) { const optionKeys = ['A', 'B', 'C', 'D']; @@ -176,6 +184,21 @@ const getQuizQuestionsHandler = async (req, res) => { } const transformedQuestions = questions.map((q, index) => { + const questionType = resolveQuestionType(q); + const questionText = (q.title || q.stem || "").trim(); + + if (questionType === "fill-in-the-blank") { + 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: "fill-in-the-blank", + 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 +218,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: "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 +242,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; fill-in-the-blank has no options to shuffle + const randomizedQuestions = transformedQuestions.map((q) => + q.questionType === "fill-in-the-blank" ? q : shuffleQuestionOptions(q) + ); res.json({ success: true, diff --git a/src/services/question.js b/src/services/question.js index 8bbd43c..9f91223 100644 --- a/src/services/question.js +++ b/src/services/question.js @@ -18,12 +18,21 @@ const saveQuestion = async (courseId, questionData) => { : questionData.granularObjectiveId; } + const questionType = + questionData.questionType || + questionData.type || + "multiple-choice"; + // Save the full question data including granularObjectiveId 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 + : [], bloom: questionData.bloom, difficulty: questionData.difficulty, courseId: courseIdObj, @@ -149,6 +158,15 @@ 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.granularObjectiveId !== undefined) { // Convert granularObjectiveId to ObjectId if it's a string update.granularObjectiveId = updateData.granularObjectiveId 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) { From 1d19f7a0f7c10aa2fe61d79a6a1ddf02d3eac442 Mon Sep 17 00:00:00 2001 From: Grace Xue Date: Fri, 3 Apr 2026 19:33:17 -0700 Subject: [PATCH 02/20] add question type column on question bank page --- public/question-bank.html | 4 +++ public/scripts/question-bank.js | 50 ++++++++++++++++++++++++++------- public/styles/question-bank.css | 24 ++++++++++++++++ 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/public/question-bank.html b/public/question-bank.html index 9d38587..711f206 100644 --- a/public/question-bank.html +++ b/public/question-bank.html @@ -153,6 +153,10 @@ Bloom's Level + + Question Type + + Status diff --git a/public/scripts/question-bank.js b/public/scripts/question-bank.js index 38bb8f8..2ab05d8 100644 --- a/public/scripts/question-bank.js +++ b/public/scripts/question-bank.js @@ -83,6 +83,19 @@ function getObjectId(obj) { return toStringId(obj._id || obj.id); } +/** Normalize API questionType / type for display (legacy rows default to multiple-choice). */ +function normalizeQuestionTypeKey(raw) { + const t = (raw || "multiple-choice").toString().trim().toLowerCase().replace(/_/g, "-"); + if (t === "fill-in-the-blank") return "fill-in-the-blank"; + return "multiple-choice"; +} + +function formatQuestionTypeLabel(raw) { + const key = normalizeQuestionTypeKey(raw); + if (key === "fill-in-the-blank") return "Fill-in-the-blank"; + return "Multiple choice"; +} + class QuestionBankPage { constructor() { // Get course from sessionStorage @@ -378,6 +391,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 +1804,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 +1872,9 @@ class QuestionBankPage { ${question.bloom || "N/A"} + + ${formatQuestionTypeLabel(question.questionType)} + ${question.status || "Draft"} @@ -1912,7 +1932,9 @@ 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 typeLabel = formatQuestionTypeLabel(q.questionType).toLowerCase(); + const typeMatch = typeLabel.includes(searchTerm); + return titleMatch || stemMatch || gloMatch || typeMatch; } ); } @@ -1928,20 +1950,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 || "multiple-choice").toLowerCase(); + bValue = (b.questionType || "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") { diff --git a/public/styles/question-bank.css b/public/styles/question-bank.css index babaca6..60f405f 100644 --- a/public/styles/question-bank.css +++ b/public/styles/question-bank.css @@ -1265,6 +1265,30 @@ 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; +} + /* Edit Mode */ .question-title-input { From 500540396ab539562157010cc5a6a49836535581 Mon Sep 17 00:00:00 2001 From: Grace Xue Date: Fri, 3 Apr 2026 19:40:20 -0700 Subject: [PATCH 03/20] Display question and correct answer when click on 'View/Edit Question' button --- public/scripts/question-bank.js | 262 ++++++++++++++++++++++++++------ 1 file changed, 215 insertions(+), 47 deletions(-) diff --git a/public/scripts/question-bank.js b/public/scripts/question-bank.js index 2ab05d8..7ed486c 100644 --- a/public/scripts/question-bank.js +++ b/public/scripts/question-bank.js @@ -96,6 +96,14 @@ function formatQuestionTypeLabel(raw) { return "Multiple choice"; } +/** Escape text placed inside body */ +function escapeForTextareaContent(str) { + return String(str ?? "") + .replace(/&/g, "&") + .replace(//g, ">"); +} + class QuestionBankPage { constructor() { // Get course from sessionStorage @@ -2595,49 +2603,73 @@ 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 === "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: "fill-in-the-blank", + correctAnswer: canonical, + acceptableAnswers: acceptable, + 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: "multiple-choice", + options: normalizedOptions, + correctAnswer: correctAnswerLetter, + canEdit, + learningObjectiveId: question.learningObjectiveId, + granularObjectiveId: question.granularObjectiveId, + }; + } // Render question in modal (uses this.currentEditingQuestion) this.renderQuestionInModal(); @@ -2658,7 +2690,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; @@ -2667,17 +2698,91 @@ 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 isFib = question.questionType === "fill-in-the-blank"; + + 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) { @@ -2844,10 +2949,72 @@ 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 === "fill-in-the-blank") { + if (!title && !stem) { + this.showNotification("Question title or question text is required", "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: title || stem, + stem: stem || title, + questionType: "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 = "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 { @@ -2899,6 +3066,7 @@ class QuestionBankPage { const updateData = { title: title || stem, stem: stem || title, + questionType: "multiple-choice", options: optionsObject, correctAnswer: correctAnswerLetter, }; From d9420a9c913c8c578502b516545de45b66547ffc Mon Sep 17 00:00:00 2001 From: Grace Xue Date: Fri, 3 Apr 2026 19:50:54 -0700 Subject: [PATCH 04/20] display question and correct answer in generation question stage of question generation page --- public/scripts/question-generation.js | 168 +++++++++++++++++++++----- public/styles/question-generation.css | 79 ++++++++++++ 2 files changed, 216 insertions(+), 31 deletions(-) diff --git a/public/scripts/question-generation.js b/public/scripts/question-generation.js index 213cea7..3f60ae5 100644 --- a/public/scripts/question-generation.js +++ b/public/scripts/question-generation.js @@ -3208,6 +3208,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 `
    @@ -3236,44 +3250,73 @@ function renderGranularLoSection(lo, group) { function renderQuestionCard(question, group) { const isEditing = question.isEditing || false; + const isFib = + (question.questionType || question.type) === "fill-in-the-blank"; - return ` -
    -
    -
    - ${isEditing - ? `` - : `
    ${question.title}
    ` - } + const titleEditingHtml = + isFib && isEditing + ? `
    Fill-in-the-blank question
    ` + : isEditing + ? `` + : `
    ${escapeQuestionHtml(question.title)}
    `; + + const chipsHtml = `
    - ${question.metaCode - } - ${question.loCode - } - Bloom: ${question.bloom - } -
    -
    - -
    + ${altAccepted.length + ? `
    + Also accepted +

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

    +
    ` + : "" + } +
    `; + } + } 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 `
    @@ -3281,7 +3324,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)">` : `` }
    @@ -3290,8 +3333,27 @@ function renderQuestionCard(question, group) { } ) .join("")} +
    `; + } + + return ` +
    +
    +
    + ${titleEditingHtml} + ${chipsHtml} +
    +
    +
    + ${bodyHtml} +
    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 9ca5ee6..ad4837a 100644 --- a/src/constants/app-constants.js +++ b/src/constants/app-constants.js @@ -199,10 +199,22 @@ const DEFAULT_PROMPTS = { 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: ["fill-in-the-blank", "multiple-choice"], + Understand: ["multiple-choice", "fill-in-the-blank"], + Apply: ["multiple-choice", "fill-in-the-blank"], + Analyze: ["multiple-choice", "fill-in-the-blank"], + Evaluate: ["calculation", "multiple-choice"], + Create: ["open-ended", "multiple-choice"], +}; + module.exports = { QUESTION_GENERATION_PROMPT, OBJECTIVE_GENERATION_AUTO_PROMPT, OBJECTIVE_GENERATION_MANUAL_PROMPT, BLOOM_LEVELS, - DEFAULT_PROMPTS + DEFAULT_PROMPTS, + DEFAULT_BLOOM_TYPE_PREFERENCES, }; diff --git a/src/services/settings.js b/src/services/settings.js index 147665d..ef5e048 100644 --- a/src/services/settings.js +++ b/src/services/settings.js @@ -1,11 +1,12 @@ 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 = { @@ -41,7 +42,8 @@ const getSettings = async (courseId) => { // Reconstruct the hierarchical settings object const settings = { - prompts: {} + prompts: {}, + bloomTypePreferences: null, }; // Resolve each prompt: use stored value when present and structurally compatible, @@ -69,7 +71,20 @@ const getSettings = async (courseId) => { 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); @@ -89,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 - } - }); - } } } }; From d66ac47130c22b65651cb92b5a1e98681e44d2f7 Mon Sep 17 00:00:00 2001 From: Grace Xue Date: Mon, 25 May 2026 20:46:16 -0700 Subject: [PATCH 13/20] add tolerance --- public/scripts/direct-ollama-service.js | 7 +++-- public/scripts/generation-questions.js | 4 +++ public/scripts/llm-service.js | 31 ++++++++++++------- public/scripts/question-bank.js | 20 ++++++++++++- public/scripts/quiz.js | 11 +++++-- src/constants/app-constants.js | 31 ++++++++++++------- src/controllers/quiz.js | 13 +++++++- src/controllers/rag-llm.js | 40 ++++++++++++++++--------- src/controllers/student.js | 6 ++++ src/services/calculation-question.js | 24 +++++++++++++-- src/services/question.js | 15 ++++++++++ 11 files changed, 157 insertions(+), 45 deletions(-) diff --git a/public/scripts/direct-ollama-service.js b/public/scripts/direct-ollama-service.js index c438d76..0f81095 100644 --- a/public/scripts/direct-ollama-service.js +++ b/public/scripts/direct-ollama-service.js @@ -217,7 +217,10 @@ FORMAT FOR "calculationVariables": - A JSON array of objects, one per variable: "name" (string), "min" and "max" (numbers; equal min and max fixes a constant), "decimals" (0–8 for sampled value rounding), optional "integerOnly": true. FORMAT FOR "calculationAnswerDecimals": -- Integer 0-12: how many decimal places the student's submitted answer is rounded to for grading. +- Integer 0-12: how many decimal places are displayed to the student. Controls display precision only. + +FORMAT FOR "calculationAnswerTolerancePercent" (OPTIONAL): +- Number 0-100. Use when the domain grades within a percentage band: e.g. 2 for chemistry, 5 for geology/engineering. Omit for math/physics where exact decimal rounding is expected. Example (base yours on the provided materials): { @@ -235,7 +238,7 @@ Example (base yours on the provided materials): INSTRUCTIONS: 1. Create one specific calculation item tied to the provided content—not a generic drill unrelated to the materials. -2. Set "type" to "calculation" and include "topicTitle", "stem", "calculationFormula", "calculationVariables", "calculationAnswerDecimals", and "explanation". +2. Set "type" to "calculation" and include "topicTitle", "stem", "calculationFormula", "calculationVariables", "calculationAnswerDecimals", and "explanation". Add "calculationAnswerTolerancePercent" only when the subject warrants percentage-based grading. 3. Use about 2-4 variables unless the objective clearly needs only one. 4. Ensure the formula always evaluates to a finite real number for every value in the given ranges (no division by zero; no invalid operations). 5. Do NOT include "options", a lettered multiple-choice "correctAnswer", or a static numeric "correctAnswer"—the platform computes the correct value from the formula and sampled variables. diff --git a/public/scripts/generation-questions.js b/public/scripts/generation-questions.js index 16e55be..82e164b 100644 --- a/public/scripts/generation-questions.js +++ b/public/scripts/generation-questions.js @@ -459,6 +459,9 @@ class QuestionGenerator { 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}`, @@ -472,6 +475,7 @@ class QuestionGenerator { calculationFormula: questionData.calculationFormula, calculationVariables: questionData.calculationVariables, calculationAnswerDecimals: answerDec, + calculationAnswerTolerancePercent: tolerancePct, bloomLevel: bloomLevel, difficulty: this.determineDifficulty(bloomLevel), metaCode: learningObjectiveText, diff --git a/public/scripts/llm-service.js b/public/scripts/llm-service.js index d3c4404..77043df 100644 --- a/public/scripts/llm-service.js +++ b/public/scripts/llm-service.js @@ -212,31 +212,42 @@ FORMAT FOR THE "stem" FIELD (mandatory): - Every variable in "calculationFormula" must appear in "stem" as {{name}} with the same "name" as in "calculationVariables". FORMAT FOR "calculationFormula": -- One expression using variable names and + - * / ^ and parentheses. Prefer plain ASCII (e.g. "a * b", "(a + b) / 2"). Do NOT use ∫, ∑, or other symbols the calculator cannot parse. Avoid LaTeX in this field; use LaTeX only in "stem" if needed. (Simple \\frac or \\times may be normalized server-side, but ASCII is strongly preferred.) +- One expression using variable names and + - * / ^ and parentheses. Plain ASCII only (e.g. "a * b", "(a + b) / 2", "y0 * E^(k*t)"). Do NOT use ∫, ∑, d/dx, or symbolic notation — the evaluator cannot parse them. Use LaTeX only in "stem" for display; keep "calculationFormula" as pure arithmetic. + +CALCULUS-SAFE PATTERN: For calculus objectives, pre-solve the symbolic math yourself, then encode the closed-form result in "calculationFormula". The system evaluates it numerically at random variable values. +- Derivative: f(x) = {{a}}x²+{{b}}x → f'({{x}}) → formula: "2*a*x + b" +- Definite integral with simple closed form: ∫₀^{{b}} {{a}}x² dx → formula: "a * b^3 / 3" +- ODE: dy/dt = {{k}}y, y(0)={{y0}} at t={{t}} → formula: "y0 * E^(k*t)" (add calculationAnswerTolerancePercent: 1) +- IMPORTANT — if the integral has NO simple closed form (e.g. involves cos, sin, ln, or complex compositions), do NOT write the integral in the formula. Instead REFORMULATE to a simpler sub-skill: evaluate the integrand at a point, apply the power rule to a term, or test an initial condition — anything expressible as plain arithmetic. FORMAT FOR "calculationVariables": - Array of objects: "name", "min", "max", "decimals" (0-8), optional "integerOnly": true. Use min === max for a fixed constant. FORMAT FOR "calculationAnswerDecimals": -- Integer 0–12 for grading precision (student answer rounded to this many decimal places). +- Integer 0–12. Controls how many decimal places are displayed to the student — not the grading window when tolerancePercent is set. -Example: +FORMAT FOR "calculationAnswerTolerancePercent" (OPTIONAL): +- Number 0–100. Use when the domain grades within a percentage band: e.g. 2 for chemistry, 5 for geology/engineering, 1 for ODE/integral results. Omit for exact arithmetic. + +Example JSON structure (STRUCTURAL REFERENCE ONLY — do NOT copy this topic, formula, or variables; create your own based on the course content above): { "type": "calculation", - "topicTitle": "Product of two quantities", - "stem": "If a = {{a}} and b = {{b}}, what is a multiplied by b?", + "topicTitle": "topic title here", + "stem": "A question involving {{a}} and {{b}} goes here.", "calculationFormula": "a * b", "calculationVariables": [ - { "name": "a", "min": 1, "max": 5, "decimals": 1 }, - { "name": "b", "min": 1, "max": 20, "decimals": 0, "integerOnly": true } + { "name": "a", "min": 1, "max": 10, "integerOnly": true }, + { "name": "b", "min": 1, "max": 5, "decimals": 1 } ], - "calculationAnswerDecimals": 1, - "explanation": "Apply multiplication; values are drawn from the configured ranges." + "calculationAnswerDecimals": 2, + "explanation": "Brief justification from the content." } +CRITICAL: The formula above ("a * b") is a placeholder. You MUST derive the formula from the actual course content. If the content is about differential equations, write a differential-equation formula. If about integration, write an integration result. Do NOT output generic formulas unrelated to the materials. + INSTRUCTIONS: 1. Create one calculation item grounded in the provided materials. -2. Include "type": "calculation", "topicTitle", "stem", "calculationFormula", "calculationVariables", "calculationAnswerDecimals", and "explanation". +2. Include "type": "calculation", "topicTitle", "stem", "calculationFormula", "calculationVariables", "calculationAnswerDecimals", and "explanation". Add "calculationAnswerTolerancePercent" only when the subject warrants percentage-based grading. 3. Prefer 2-4 variables unless a single variable clearly suffices for the objective. 4. Ensure the formula stays finite for all sampled values in range (e.g. no division by zero). 5. Do NOT include "options", multiple-choice letters, or a static numeric correct answer field—the server grades using the formula and sampled variables. diff --git a/public/scripts/question-bank.js b/public/scripts/question-bank.js index df524eb..0a0a0d3 100644 --- a/public/scripts/question-bank.js +++ b/public/scripts/question-bank.js @@ -2638,6 +2638,8 @@ class QuestionBankPage { 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 || "", @@ -2646,6 +2648,7 @@ class QuestionBankPage { calculationFormula: (question.calculationFormula || "").trim(), calculationVariables: vars, calculationAnswerDecimals: Number.isFinite(dec) ? dec : 2, + calculationAnswerTolerancePercent: tol, canEdit, learningObjectiveId: question.learningObjectiveId, granularObjectiveId: question.granularObjectiveId, @@ -2836,6 +2839,8 @@ class QuestionBankPage { 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 = `
    @@ -2882,13 +2887,22 @@ class QuestionBankPage { style="${readonlyStyle}">${varsJson}
    - +
    +
    + + +
    `; @@ -3155,6 +3169,7 @@ class QuestionBankPage { 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"); @@ -3191,6 +3206,8 @@ class QuestionBankPage { 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, @@ -3199,6 +3216,7 @@ class QuestionBankPage { calculationFormula: formula, calculationVariables: variables, calculationAnswerDecimals: dec, + calculationAnswerTolerancePercent: tolPct, options: {}, acceptableAnswers: [], }; diff --git a/public/scripts/quiz.js b/public/scripts/quiz.js index 6a3376f..e184865 100644 --- a/public/scripts/quiz.js +++ b/public/scripts/quiz.js @@ -555,9 +555,14 @@ function showQuestion(questionIndex) { } if (question.questionType === "calculation" && !question.calculationLoadError) { - const p = Number(question.answerDecimalPlaces); - const dec = Number.isFinite(p) ? Math.max(0, Math.min(12, Math.round(p))) : 2; - completeHTML += `
    Round your answer to ${dec} decimal place${dec === 1 ? "" : "s"}.
    `; + const tol = Number(question.calculationAnswerTolerancePercent); + if (Number.isFinite(tol) && tol > 0) { + completeHTML += `
    Your answer will be accepted within ${tol}% of the correct value.
    `; + } else { + const p = Number(question.answerDecimalPlaces); + const dec = Number.isFinite(p) ? Math.max(0, Math.min(12, Math.round(p))) : 2; + completeHTML += `
    Round your answer to ${dec} decimal place${dec === 1 ? "" : "s"}.
    `; + } } if (question.questionType === "open-ended") { diff --git a/src/constants/app-constants.js b/src/constants/app-constants.js index ad4837a..898bd54 100644 --- a/src/constants/app-constants.js +++ b/src/constants/app-constants.js @@ -62,35 +62,44 @@ Return valid JSON in this shape. Rules: No "options" key; include "topicTitle"; --- If Question Type is "calculation" --- CRITICAL RULES — violating any of these causes immediate rejection: 1. STEM PLACEHOLDERS: Every variable MUST appear in "stem" using DOUBLE curly braces: {{name}}. The name must exactly match the "name" field in "calculationVariables". Example: if the variable name is "V", write {{V}} in the stem. Writing {V}, [V], (V), or bare "V" is WRONG and will be rejected. -2. FORMULA SYNTAX: "calculationFormula" must use ONLY plain ASCII: + - * / ^ ( ) digits and declared variable names. NO LaTeX (no \frac, no \sqrt{}, no $...$), NO Unicode math symbols (∫ ∑ ∂ π ℯ), NO "from a to b" text. Use PI and E (uppercase ASCII) for the math constants — do NOT declare "pi", "PI", "e", or "E" as variables. +2. FORMULA SYNTAX: "calculationFormula" must use ONLY: + - * / ^ ( ) digits, declared variable names, and the built-in functions sin cos tan sqrt log exp and constants PI E. NO LaTeX (no \frac, no \sqrt{}, no $...$), NO Unicode math symbols (∫ ∑ ∂ π ℯ), NO "from a to b" text, NO = sign. Do NOT declare "pi", "PI", "e", or "E" as variable names. 3. ALL VARIABLES USED: Every name declared in "calculationVariables" must appear in BOTH "stem" (as {{name}}) AND "calculationFormula". A variable that only appears in one of them will be rejected. You are authoring a parameterised question: the server samples random values, substitutes {{name}} in the stem, then evaluates "calculationFormula" with those values to compute the correct answer. -SCOPE: The correct answer must be a SINGLE numeric value from ONE closed-form arithmetic expression. Do NOT use integrals, summations, derivatives, or symbolic solving. If the natural answer requires those, pick a simpler sub-problem (e.g. evaluate a formula at a point) or use a different question type. +SCOPE: The correct answer must be a SINGLE numeric value from ONE closed-form arithmetic expression. The formula field must be pure ASCII arithmetic — no ∫, ∑, d/dx, or symbolic notation. + +CALCULUS-SAFE PATTERN: For calculus objectives (derivatives, integrals, ODEs), pre-solve the symbolic math yourself, then encode the closed-form result in "calculationFormula". Show the original calculus problem in "stem" using LaTeX; compute the answer via arithmetic. +- Derivative: stem shows \( f'(x) \) at \( x = {{x}} \); formula: "2*a*x + b" (from f(x) = ax²+bx → f'(x) = 2ax+b) +- Definite integral with simple closed form: stem shows \( \int_0^{{{b}}} {{a}}x^2\,dx \); formula: "a * b^3 / 3" +- ODE solution: stem shows dy/dt = {{k}}y, y(0)={{y0}} at t={{t}}; formula: "y0 * E^(k*t)" + add calculationAnswerTolerancePercent: 1 +- IMPORTANT — if the integral or equation has NO simple closed form (e.g. involves cos, sin, ln, or complex compositions that cannot be written as a single arithmetic expression), do NOT write the integral in the formula. Instead, REFORMULATE to a simpler testable sub-skill: evaluate the integrand at a point, apply the power rule to a polynomial term, or test an initial condition — anything expressible as plain arithmetic. PROCEDURE: 1. "topicTitle": short neutral label (3–10 words), no "?", must not reveal the answer. 2. "stem": question text. Each variable must appear as {{name}} (double curly braces). Do NOT write the variable's numeric value — use the {{name}} placeholder instead. 3. "calculationFormula": ONE ASCII expression solving the stem. Reference EVERY declared variable at least once. No LaTeX, no Unicode math. 4. "calculationVariables": non-empty array of {"name": "...", "min": number, "max": number, "decimals": 0–8} or {"name": "...", "min": number, "max": number, "integerOnly": true}. "min" and "max" must be numbers, never null. Forbidden names: "pi", "PI", "e", "E". -5. "calculationAnswerDecimals": integer 0–12. -6. "explanation": brief explanation of the formula. +5. "calculationAnswerDecimals": integer 0–12. Controls how many decimal places are shown to the student — not the grading window when tolerancePercent is set. +6. (Optional) "calculationAnswerTolerancePercent": number 0–100. Use when the domain grades within a percentage band rather than by rounding — e.g. 2 for chemistry (≈2 sig figs), 5 for geology or engineering estimates. Omit for physics/math where exact decimal rounding is expected. +7. "explanation": brief explanation of the formula. -Example: +Example JSON structure (STRUCTURAL REFERENCE ONLY — do NOT copy this topic, formula, or variables; create your own based on the course content above): { "type": "calculation", - "topicTitle": "Ohm's law application", - "stem": "Given voltage {{V}} V and resistance {{R}} Ω, the current is _____ A.", - "calculationFormula": "V / R", + "topicTitle": "topic title here", + "stem": "A question involving {{a}} and {{b}} goes here.", + "calculationFormula": "a * b", "calculationVariables": [ - { "name": "V", "min": 10, "max": 120, "integerOnly": true }, - { "name": "R", "min": 5, "max": 50, "decimals": 1 } + { "name": "a", "min": 1, "max": 10, "integerOnly": true }, + { "name": "b", "min": 1, "max": 5, "decimals": 1 } ], "calculationAnswerDecimals": 2, - "explanation": "Apply Ohm's law I = V/R." + "explanation": "Brief justification from the content." } +CRITICAL: The formula above ("a * b") is a placeholder. You MUST derive the formula from the actual course content. If the content is about differential equations, write a differential-equation formula. If about integration, write an integration result. Do NOT output "V / R" or "a * b" unless the content is specifically about those relationships. + Do NOT include "options" or a multiple-choice "correctAnswer" in the calculation output. --- If Question Type is "open-ended" --- diff --git a/src/controllers/quiz.js b/src/controllers/quiz.js index 667cbf3..795fdd4 100644 --- a/src/controllers/quiz.js +++ b/src/controllers/quiz.js @@ -310,6 +310,11 @@ const getQuizQuestionsHandler = async (req, res) => { 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) { @@ -327,6 +332,7 @@ const getQuizQuestionsHandler = async (req, res) => { questionType: "calculation", calculationToken: built.token, answerDecimalPlaces: built.answerDecimalPlaces, + calculationAnswerTolerancePercent: tolerancePercent, options: {}, learningObjectiveId: q.learningObjectiveId, granularObjectiveId: q.granularObjectiveId, @@ -505,6 +511,11 @@ const checkQuestionAnswerHandler = async (req, res) => { 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); @@ -525,7 +536,7 @@ const checkQuestionAnswerHandler = async (req, res) => { }); } const studentNum = calculationQuestion.parseStudentNumericAnswer(answerText); - const isCorrect = calculationQuestion.numericAnswersMatch(studentNum, expected, answerDec); + const isCorrect = calculationQuestion.numericAnswersMatch(studentNum, expected, answerDec, tolerancePercent); const displayCorrect = calculationQuestion.formatAnswerForDisplay(expected, answerDec); res.json({ success: true, diff --git a/src/controllers/rag-llm.js b/src/controllers/rag-llm.js index c13f3e2..8643330 100644 --- a/src/controllers/rag-llm.js +++ b/src/controllers/rag-llm.js @@ -204,6 +204,11 @@ function validateAndNormalizeQuestionData(data, requestedType) { 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(/\?+$/, ""); @@ -239,6 +244,7 @@ function validateAndNormalizeQuestionData(data, requestedType) { calculationFormula: formulaCanonical, calculationVariables: normalizedVars, calculationAnswerDecimals: answerDec, + calculationAnswerTolerancePercent: answerTolerance, explanation: merged.explanation != null ? String(merged.explanation) : "", options: null, }; @@ -523,35 +529,41 @@ function jsonOnlyRetrySuffix(attempt, questionType, lastError) { let calcExtra = ""; if (questionType === "calculation" && lastError) { const msg = String(lastError.message || ""); - if (/prose response|text.*instead of|refused/i.test(msg)) { - calcExtra = "\nPrevious response was commentary, not a question. You MUST output a JSON calculation question. "; + 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 (/d\/dt|d\/dx|differential|integral|lim |∫|∑|calculus|symbolic/i.test(msg) || + 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 calculus notation (d/dt, d/dx, integrals, limits) which the engine CANNOT evaluate. " + - "Do NOT attempt differential equations or symbolic calculus. " + - "Instead, pick a SIMPLE ARITHMETIC sub-problem related to the topic — for example: " + - "evaluate a polynomial at a point, compute a rate using a given formula, apply a physics/finance formula, or calculate a geometric quantity. " + - "The formula must be a single closed-form expression using only + - * / ^ ( ) and numbers."; + "\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", - "stem": "Question text with {{V}} volts and {{R}} ohms.", - "calculationFormula": "V / R", + "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": "V", "min": 10, "max": 120, "integerOnly": true}, - {"name": "R", "min": 5, "max": 50, "decimals": 1} + {"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 — NOT [V], NOT {V}, NOT bare "V". (2) calculationFormula uses ONLY ASCII + - * / ^ ( ) and variable names — NO LaTeX, NO \\frac, NO ∫ ∑, NO d/dt, NO = sign. (3) Every name in calculationVariables must appear in BOTH stem (as {{name}}) AND calculationFormula. (4) min/max must be numbers. No "options" field.`; +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 === "multiple-choice") schema = mcSchema; diff --git a/src/controllers/student.js b/src/controllers/student.js index 18abfe3..c8a9d01 100644 --- a/src/controllers/student.js +++ b/src/controllers/student.js @@ -236,6 +236,11 @@ const getQuizQuestionsHandler = async (req, res) => { 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, @@ -251,6 +256,7 @@ const getQuizQuestionsHandler = async (req, res) => { questionType: "calculation", calculationToken: built.token, answerDecimalPlaces: built.answerDecimalPlaces, + calculationAnswerTolerancePercent: tolerancePercent, options: {}, learningObjectiveId: q.learningObjectiveId, granularObjectiveId: q.granularObjectiveId, diff --git a/src/services/calculation-question.js b/src/services/calculation-question.js index 7792529..b39d0d7 100644 --- a/src/services/calculation-question.js +++ b/src/services/calculation-question.js @@ -553,10 +553,28 @@ function parseStudentNumericAnswer(text) { return n; } -/** Compare student answer to expected within decimal rounding rules. */ -function numericAnswersMatch(studentValue, expectedValue, answerDecimals) { - const d = Math.max(0, Math.min(12, parseInt(answerDecimals, 10) || 0)); +/** + * 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 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); + if (Math.abs(expectedValue) < 1e-10) { + return diff <= threshold; + } + return diff / Math.abs(expectedValue) <= threshold; + } + + const d = Math.max(0, Math.min(12, parseInt(answerDecimals, 10) || 0)); const a = roundToDecimals(studentValue, d); const b = roundToDecimals(expectedValue, d); const eps = Math.max(10 ** -(d + 2), 1e-12); diff --git a/src/services/question.js b/src/services/question.js index 8987bc9..365f730 100644 --- a/src/services/question.js +++ b/src/services/question.js @@ -42,6 +42,12 @@ const saveQuestion = async (courseId, questionData) => { ? 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 : []; @@ -77,6 +83,7 @@ const saveQuestion = async (courseId, questionData) => { : calcFormulaRaw, calculationVariables: calcVarsForStore, calculationAnswerDecimals, + calculationAnswerTolerancePercent, bloom: questionData.bloom, difficulty: questionData.difficulty, courseId: courseIdObj, @@ -224,6 +231,14 @@ const updateQuestion = async (questionId, updateData) => { 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" From 6e9c8cf56b02e100727517886bc92323465265fc Mon Sep 17 00:00:00 2001 From: Huiyue Xue Date: Tue, 26 May 2026 14:55:54 -0700 Subject: [PATCH 14/20] revise question prompt --- src/constants/app-constants.js | 160 +++++++++++++++++---------------- 1 file changed, 83 insertions(+), 77 deletions(-) diff --git a/src/constants/app-constants.js b/src/constants/app-constants.js index 898bd54..3eefa5e 100644 --- a/src/constants/app-constants.js +++ b/src/constants/app-constants.js @@ -2,93 +2,103 @@ * Application-wide default prompt constants */ -const QUESTION_GENERATION_PROMPT = `You behave like a strict JSON API, not a chat assistant. - -MANDATORY OUTPUT (read first): -- Output EXACTLY one JSON object and NOTHING else: no preamble, no "##" headings, no bullet lists, no step-by-step reasoning, no "To address this", no summaries of the source, no "The final answer", no markdown code fences. -- The first character of your entire reply MUST be "{" and the last MUST be "}". -- Put all question text, options, and explanations INSIDE the JSON string fields only. +const QUESTION_GENERATION_PROMPT = `You are a university instructor. Generate a high-quality question that tests students' understanding of the provided learning objective. Learning Objective: {learningObjectiveText} Granular Learning Objective: {granularLearningObjectiveText} Bloom's Taxonomy Level(s): {bloomLevel} Question Type: {questionType} -Task: Use ONLY the schema that matches Question Type. Base the question on the CONTENT section below (do not summarize or discuss the content in plain text). +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 ---- If Question Type is "multiple-choice" --- -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 a brief explanation. +BACKGROUND COURSE MATERIAL: +{ragContext} +--- END OF MATERIAL --- + +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): { - "type": "multiple-choice", - "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" } -Rules: Four non-empty options; correctAnswer is only "A", "B", "C", or "D"; randomize which letter is correct; option text must NOT start with "A)" or "A." style prefixes. --- If Question Type is "fill-in-the-blank" --- -PROCEDURE: -1. "topicTitle" is REQUIRED: a very short label (about 3-10 words) that names the topic or skill being tested. It must be a neutral phrase or title (not a question, no "?"). It must NOT reveal the answer, must NOT repeat the wording of correctAnswer or acceptableAnswers, and must NOT be instructions like "Fill in the blank" or "Complete the sentence". -2. The "question" string is ONLY the item stem: one unfinished DECLARATIVE sentence (a statement with a gap), NOT a WH-question. FORBIDDEN in "question": "What is...", "Which...", "How...", "Define...", ending with "?". -3. The sentence MUST contain exactly ONE blank, written ONLY as nine underscores: _________ (not ____, not [blank]). -4. correctAnswer is what fills the blank (canonical form; use LaTeX \\( ... \\) inside JSON strings for math, with backslashes escaped for JSON). -5. acceptableAnswers must include correctAnswer and reasonable equivalents (alternate LaTeX, plain-text math, synonyms). -6. Do NOT include an "options" object. +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: { - "type": "fill-in-the-blank", "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": "Why this answer is correct based on the content" + "explanation": "Brief justification from the content." } -Return valid JSON in this shape. Rules: No "options" key; include "topicTitle"; exactly one _________ in "question". - --- If Question Type is "calculation" --- -CRITICAL RULES — violating any of these causes immediate rejection: -1. STEM PLACEHOLDERS: Every variable MUST appear in "stem" using DOUBLE curly braces: {{name}}. The name must exactly match the "name" field in "calculationVariables". Example: if the variable name is "V", write {{V}} in the stem. Writing {V}, [V], (V), or bare "V" is WRONG and will be rejected. -2. FORMULA SYNTAX: "calculationFormula" must use ONLY: + - * / ^ ( ) digits, declared variable names, and the built-in functions sin cos tan sqrt log exp and constants PI E. NO LaTeX (no \frac, no \sqrt{}, no $...$), NO Unicode math symbols (∫ ∑ ∂ π ℯ), NO "from a to b" text, NO = sign. Do NOT declare "pi", "PI", "e", or "E" as variable names. -3. ALL VARIABLES USED: Every name declared in "calculationVariables" must appear in BOTH "stem" (as {{name}}) AND "calculationFormula". A variable that only appears in one of them will be rejected. - -You are authoring a parameterised question: the server samples random values, substitutes {{name}} in the stem, then evaluates "calculationFormula" with those values to compute the correct answer. - -SCOPE: The correct answer must be a SINGLE numeric value from ONE closed-form arithmetic expression. The formula field must be pure ASCII arithmetic — no ∫, ∑, d/dx, or symbolic notation. - -CALCULUS-SAFE PATTERN: For calculus objectives (derivatives, integrals, ODEs), pre-solve the symbolic math yourself, then encode the closed-form result in "calculationFormula". Show the original calculus problem in "stem" using LaTeX; compute the answer via arithmetic. -- Derivative: stem shows \( f'(x) \) at \( x = {{x}} \); formula: "2*a*x + b" (from f(x) = ax²+bx → f'(x) = 2ax+b) -- Definite integral with simple closed form: stem shows \( \int_0^{{{b}}} {{a}}x^2\,dx \); formula: "a * b^3 / 3" -- ODE solution: stem shows dy/dt = {{k}}y, y(0)={{y0}} at t={{t}}; formula: "y0 * E^(k*t)" + add calculationAnswerTolerancePercent: 1 -- IMPORTANT — if the integral or equation has NO simple closed form (e.g. involves cos, sin, ln, or complex compositions that cannot be written as a single arithmetic expression), do NOT write the integral in the formula. Instead, REFORMULATE to a simpler testable sub-skill: evaluate the integrand at a point, apply the power rule to a polynomial term, or test an initial condition — anything expressible as plain arithmetic. +You are authoring a parameterised question. The server will sample random values +for each variable, substitute {{name}} in the stem, and evaluate calculationFormula +to compute the correct answer. + +RULES (violations cause immediate rejection): +1. Every variable must appear in "stem" as {{name}} (double curly braces, exact match to its "name" in calculationVariables). +2. 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, no "from a to b" text. +3. Every declared variable must appear in BOTH "stem" AND calculationFormula. +4. For calculus problems, pre-solve the symbolic math yourself and encode only + the closed-form arithmetic result in calculationFormula. If no simple closed + form exists, reformulate to a directly computable sub-skill (e.g. evaluate + the integrand at a point, apply the power rule to one term). PROCEDURE: -1. "topicTitle": short neutral label (3–10 words), no "?", must not reveal the answer. -2. "stem": question text. Each variable must appear as {{name}} (double curly braces). Do NOT write the variable's numeric value — use the {{name}} placeholder instead. -3. "calculationFormula": ONE ASCII expression solving the stem. Reference EVERY declared variable at least once. No LaTeX, no Unicode math. -4. "calculationVariables": non-empty array of {"name": "...", "min": number, "max": number, "decimals": 0–8} or {"name": "...", "min": number, "max": number, "integerOnly": true}. "min" and "max" must be numbers, never null. Forbidden names: "pi", "PI", "e", "E". -5. "calculationAnswerDecimals": integer 0–12. Controls how many decimal places are shown to the student — not the grading window when tolerancePercent is set. -6. (Optional) "calculationAnswerTolerancePercent": number 0–100. Use when the domain grades within a percentage band rather than by rounding — e.g. 2 for chemistry (≈2 sig figs), 5 for geology or engineering estimates. Omit for physics/math where exact decimal rounding is expected. +1. "topicTitle": short neutral label (3-10 words), no "?", must not reveal the answer. +2. "stem": question text with each variable as {{name}}. Do NOT write numeric values — use placeholders. +3. "calculationFormula": ONE ASCII expression. Every declared variable used at least once. +4. "calculationVariables": array of {"name": "...", "min": number, "max": number, "decimals": 0-8} + or {"name": "...", "min": number, "max": number, "integerOnly": true}. + Forbidden names: "pi", "PI", "e", "E". +5. "calculationAnswerDecimals": integer 0-12 (decimal places shown to the student). +6. "calculationAnswerTolerancePercent" (optional): 0-100. Use for percentage-band grading + (e.g. 2 for chemistry, 5 for engineering estimates). Omit for exact rounding. 7. "explanation": brief explanation of the formula. -Example JSON structure (STRUCTURAL REFERENCE ONLY — do NOT copy this topic, formula, or variables; create your own based on the course content above): +Example (structure only — derive your own formula and variables from the course content): { - "type": "calculation", "topicTitle": "topic title here", - "stem": "A question involving {{a}} and {{b}} goes here.", + "stem": "Given {{a}} and {{b}}, calculate the result.", "calculationFormula": "a * b", "calculationVariables": [ { "name": "a", "min": 1, "max": 10, "integerOnly": true }, @@ -98,38 +108,34 @@ Example JSON structure (STRUCTURAL REFERENCE ONLY — do NOT copy this topic, fo "explanation": "Brief justification from the content." } -CRITICAL: The formula above ("a * b") is a placeholder. You MUST derive the formula from the actual course content. If the content is about differential equations, write a differential-equation formula. If about integration, write an integration result. Do NOT output "V / R" or "a * b" unless the content is specifically about those relationships. - -Do NOT include "options" or a multiple-choice "correctAnswer" in the calculation output. +Do NOT include "options" or a multiple-choice "correctAnswer". --- If Question Type is "open-ended" --- -PROCEDURE: -1. "topicTitle" is REQUIRED: a short neutral label (3–10 words), not a question. -2. Use "question" OR "stem" for the prompt students respond to (paragraph-length is fine). Do NOT use nine underscores; this is not fill-in-the-blank. -3. "openEndedSampleAnswer" is REQUIRED: a strong example response. Students see it only after they submit; it is not used for auto-grading. -4. "openEndedGradingCriteria" is REQUIRED: clear criteria or a short rubric (bullet-style in a string is fine) so students can self-check. +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: { - "type": "open-ended", "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: names two distinct trade-offs with reasoning. Partial: one trade-off or vague reasoning. No credit: off-topic.", - "explanation": "Why this aligns with the materials" + "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 (all matching types): +CRITICAL FORMATTING REQUIREMENTS: - 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 JSON strings (each backslash in the content becomes \\\\\\\\ in JSON where needed). -- For multiple-choice: Do NOT include letter prefixes (A), B), etc.) inside the option text values. -- For calculation: Do NOT include an "options" object or MC "correctAnswer"; use "stem" (not only "question") for the template, and place each variable inline as {{name}} where "name" is one of "calculationVariables[].name" (never a literal {{var}} placeholder). The formula field must stay machine-evaluable (no integral sign ∫ or similar). -- For open-ended: Include "openEndedSampleAnswer" and "openEndedGradingCriteria"; the platform does not auto-grade text responses. -FORMATTING INSIDE JSON STRINGS: -- Escape backslashes for LaTeX: use \\\\\\\\ where a single backslash is needed in the rendered math, so JSON.parse succeeds. -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. From bdb1ffc60c1639bb2cbba74611761a0c8afeb2f8 Mon Sep 17 00:00:00 2001 From: Huiyue Xue Date: Tue, 26 May 2026 15:23:55 -0700 Subject: [PATCH 15/20] refine calculation prompt --- src/constants/app-constants.js | 80 +++++++++++++++++++++++----------- 1 file changed, 54 insertions(+), 26 deletions(-) diff --git a/src/constants/app-constants.js b/src/constants/app-constants.js index 3eefa5e..443d5a6 100644 --- a/src/constants/app-constants.js +++ b/src/constants/app-constants.js @@ -2,7 +2,7 @@ * Application-wide default prompt constants */ -const QUESTION_GENERATION_PROMPT = `You are a university instructor. Generate a high-quality question that tests students' understanding of the provided learning objective. +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. Learning Objective: {learningObjectiveText} Granular Learning Objective: {granularLearningObjectiveText} @@ -68,44 +68,72 @@ Example: } --- If Question Type is "calculation" --- -You are authoring a parameterised question. The server will sample random values -for each variable, substitute {{name}} in the stem, and evaluate calculationFormula +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. Every variable must appear in "stem" as {{name}} (double curly braces, exact match to its "name" in calculationVariables). -2. 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, no "from a to b" text. -3. Every declared variable must appear in BOTH "stem" AND calculationFormula. -4. For calculus problems, pre-solve the symbolic math yourself and encode only - the closed-form arithmetic result in calculationFormula. If no simple closed - form exists, reformulate to a directly computable sub-skill (e.g. evaluate - the integrand at a point, apply the power rule to one term). +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. +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). PROCEDURE: 1. "topicTitle": short neutral label (3-10 words), no "?", must not reveal the answer. -2. "stem": question text with each variable as {{name}}. Do NOT write numeric values — use placeholders. -3. "calculationFormula": ONE ASCII expression. Every declared variable used at least once. -4. "calculationVariables": array of {"name": "...", "min": number, "max": number, "decimals": 0-8} - or {"name": "...", "min": number, "max": number, "integerOnly": true}. - Forbidden names: "pi", "PI", "e", "E". +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. Use for percentage-band grading - (e.g. 2 for chemistry, 5 for engineering estimates). Omit for exact rounding. +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 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. + Example (structure only — derive your own formula and variables from the course content): { - "topicTitle": "topic title here", - "stem": "Given {{a}} and {{b}}, calculate the result.", - "calculationFormula": "a * b", + "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": "a", "min": 1, "max": 10, "integerOnly": true }, - { "name": "b", "min": 1, "max": 5, "decimals": 1 } + { "name": "m", "min": 1, "max": 20, "integerOnly": true }, + { "name": "v", "min": 1, "max": 15, "integerOnly": true } ], - "calculationAnswerDecimals": 2, - "explanation": "Brief justification from the content." + "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". From f17889254fe6dae15bccb1a1020ec1b3bc30587f Mon Sep 17 00:00:00 2001 From: Huiyue Xue Date: Tue, 26 May 2026 15:26:39 -0700 Subject: [PATCH 16/20] add tmp lines to summarize raw llm responses and rejection reasons for analysis --- src/controllers/rag-llm.js | 48 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/controllers/rag-llm.js b/src/controllers/rag-llm.js index 8643330..a15ed42 100644 --- a/src/controllers/rag-llm.js +++ b/src/controllers/rag-llm.js @@ -14,6 +14,28 @@ const settingsService = require('../services/settings'); const calculationQuestionService = require('../services/calculation-question'); const { DEFAULT_PROMPTS, BLOOM_LEVELS } = require('../constants/app-constants'); +// --------------------------------------------------------------------------- +// Temporary debug logger — appends one JSON entry per attempt to llm-debug.json +// in the project root. Remove once rejection patterns are understood. +// --------------------------------------------------------------------------- +const fs = require('fs'); +const path = require('path'); +const DEBUG_LOG_PATH = path.join(__dirname, '../../llm-debug.json'); + +function llmDebugLog(entry) { + try { + let existing = []; + if (fs.existsSync(DEBUG_LOG_PATH)) { + try { existing = JSON.parse(fs.readFileSync(DEBUG_LOG_PATH, 'utf8')); } catch (_) {} + } + if (!Array.isArray(existing)) existing = []; + existing.push({ ts: new Date().toISOString(), ...entry }); + fs.writeFileSync(DEBUG_LOG_PATH, JSON.stringify(existing, null, 2), 'utf8'); + } catch (writeErr) { + console.warn('[llmDebugLog] Could not write debug file:', writeErr.message); + } +} + // Simple error response function function returnErrorResponse(res, error, details = null) { console.error("Question generation failed:", error); @@ -787,6 +809,14 @@ const generateQuestionsWithRagHandler = async (req, res) => { // If we got here, parsing was successful console.log(`✅ Successfully parsed JSON on attempt ${attempt}`); + llmDebugLog({ + event: 'success', + questionType, + bloomLevel, + granularObjective: granularLearningObjectiveText, + attempt, + rawResponse: contentStr, + }); break; } catch (parseError) { @@ -798,6 +828,15 @@ const generateQuestionsWithRagHandler = async (req, res) => { "| snippet (0-400):", JSON.stringify(contentStr.slice(0, 400)) ); + llmDebugLog({ + event: 'rejection', + questionType, + bloomLevel, + granularObjective: granularLearningObjectiveText, + attempt, + rejectionReason: parseError.message, + rawResponse: contentStr, + }); if (attempt < maxRetries) { console.log(`Retrying... (${attempt + 1}/${maxRetries})`); continue; @@ -809,6 +848,15 @@ const generateQuestionsWithRagHandler = async (req, res) => { } catch (error) { lastError = error; console.warn(`❌ LLM call failed on attempt ${attempt}:`, error.message); + llmDebugLog({ + event: 'llm_error', + questionType, + bloomLevel, + granularObjective: granularLearningObjectiveText, + attempt, + rejectionReason: error.message, + rawResponse: null, + }); if (attempt < maxRetries) { console.log(`Retrying... (${attempt + 1}/${maxRetries})`); continue; From d1982b6ae772592c89102067544efe9a3fe63319 Mon Sep 17 00:00:00 2001 From: Huiyue Xue Date: Tue, 26 May 2026 15:39:22 -0700 Subject: [PATCH 17/20] remove out-dated prompts and structures --- public/question-generation.html | 1 - public/scripts/direct-ollama-service.js | 288 ----------------------- public/scripts/llm-service.js | 298 ------------------------ src/controllers/student.js | 16 +- 4 files changed, 3 insertions(+), 600 deletions(-) delete mode 100644 public/scripts/direct-ollama-service.js delete mode 100644 public/scripts/llm-service.js diff --git a/public/question-generation.html b/public/question-generation.html index bf555df..e136207 100644 --- a/public/question-generation.html +++ b/public/question-generation.html @@ -490,7 +490,6 @@

    Questions Saved Successfully!

    - diff --git a/public/scripts/direct-ollama-service.js b/public/scripts/direct-ollama-service.js deleted file mode 100644 index 0f81095..0000000 --- a/public/scripts/direct-ollama-service.js +++ /dev/null @@ -1,288 +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 generateQuestionByType(questionType, objective, ragContent, bloomLevel) { - switch (questionType) { - case "fill-in-the-blank": - return await this.generateFillInTheBlankQuestion(objective, ragContent, bloomLevel); - case "calculation": - return await this.generateCalculationQuestion(objective, ragContent, bloomLevel); - case "open-ended": - return await this.generateOpenEndedQuestion(objective, ragContent, bloomLevel); - case "multiple-choice": - default: - return await this.generateMultipleChoiceQuestion(objective, ragContent, bloomLevel); - } - } - - async generateMultipleChoiceQuestion(objective, ragContent, bloomLevel) { - const prompt = this.createMultipleChoiceQuestionPrompt(objective, bloomLevel); - return await this.generateQuestionWithRAG(prompt, ragContent); - } - - async generateFillInTheBlankQuestion(objective, ragContent, bloomLevel) { - const prompt = this.createFillInTheBlankQuestionPrompt(objective, bloomLevel); - return await this.generateQuestionWithRAG(prompt, ragContent); - } - - async generateCalculationQuestion(objective, ragContent, bloomLevel) { - const prompt = this.createCalculationQuestionPrompt(objective, bloomLevel); - return await this.generateQuestionWithRAG(prompt, ragContent); - } - - async generateOpenEndedQuestion(objective, ragContent, bloomLevel) { - const prompt = this.createOpenEndedQuestionPrompt(objective, bloomLevel); - return await this.generateQuestionWithRAG(prompt, ragContent); - } - - createMultipleChoiceQuestionPrompt(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: -{ - "type": "multiple-choice", - "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".`; - } - - createFillInTheBlankQuestionPrompt(objective, bloomLevel) { - return `You are an expert educational content creator. Generate a high-quality fill-in-the-blank item based on the provided content. - -OBJECTIVE: ${objective} -BLOOM'S TAXONOMY LEVEL: ${bloomLevel} - -REQUIRED "topicTitle" FIELD: -- A very short label (about 3–10 words) naming the topic or skill. Use a neutral phrase or title, NOT a question (no "?", no "What is…"). -- Must NOT reveal the correct answer or paraphrase acceptableAnswers. -- Must NOT be filler such as "Fill in the blank", "Complete the sentence", or "Question". - -FORMAT FOR THE "question" FIELD (mandatory): -- ONLY the item stem: one unfinished DECLARATIVE sentence (a statement with a gap), NOT an interrogative. -- FORBIDDEN: do not start with or use "What is...", "What are...", "Which...", "Who...", "How...", "Why...", "Define...", or any question mark at the end. -- The sentence MUST contain exactly ONE blank, written ONLY as nine underscores: _________ -- Do not use "____", "___", "[blank]", or other placeholders—only _________ -- The part that belongs in the blank is what the student should recall (term, formula, number, etc.); write the sentence so it reads naturally if the blank were filled in. - -INSTRUCTIONS: -1. Create one specific fill-in-the-blank item based on the provided content. -2. Set "topicTitle" as described above, separate from the stem in "question". -3. The blank should test an important term, number, phrase, formula component, or concept from the materials. -4. Use actual details from the content - do not make the question generic. -5. Provide the correct answer and acceptableAnswers. -6. Provide a short explanation based on the content. -7. Format your response as JSON with these examples: -Example (geometry): -{ - "type": "fill-in-the-blank", - "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 materials." -} - -Example (non-math): -{ - "type": "fill-in-the-blank", - "topicTitle": "European capitals", - "question": "The capital of France is _________.", - "correctAnswer": "Paris", - "acceptableAnswers": ["Paris"], - "explanation": "Brief justification from the materials." -} - -CRITICAL FORMATTING REQUIREMENTS: -- Return ONLY valid JSON. No markdown fences. First character "{", last "}". -- Return pure JSON that can be parsed with JSON.parse(). - -IMPORTANT: -- Include "topicTitle" in every response (separate from "question"). -- Exactly one _________ in "question". -- correctAnswer must be what fills the blank, not a full sentence. -- If mathematical expressions are used, also use \\( ... \\) for inline math inside the "question" string where needed (properly escaped in JSON).`; - } - - createCalculationQuestionPrompt(objective, bloomLevel) { - return `You are an expert educational content creator. Generate a high-quality numeric calculation question based on the provided content. The quiz system will randomize variable values per attempt and grade answers using decimal rounding. - -OBJECTIVE: ${objective} -BLOOM'S TAXONOMY LEVEL: ${bloomLevel} - -REQUIRED "topicTitle" FIELD: -- A very short label (about 3-10 words) naming the topic or skill. Use a neutral phrase or title; do not embed the numeric answer or final computed result. -- Must NOT be filler such as "Calculation question" or "Math problem" alone—make it specific to the content. - -FORMAT FOR THE "stem" FIELD (mandatory): -- A question template string shown to students. Embed each random variable using double-brace placeholders only, e.g. {{a}}, {{b}}. -- Example pattern: "If a = {{a}} and b = {{b}}, what is a multiplied by b?" -- Every variable used in "calculationFormula" must appear as {{name}} in "stem" (names must match "calculationVariables[].name" exactly). - -FORMAT FOR "calculationFormula": -- A single expression using ONLY those variable names and safe arithmetic: + - * / ^ (power), parentheses. Prefer plain ASCII (examples: "a * b", "(a + b) / 2", "a^2 + 3 * b"). Do NOT use ∫, ∑, or LaTeX environments here—the engine cannot evaluate them. Put display math in "stem" only. (Basic \\frac{a}{b} or \\times may be auto-converted, but prefer "a/b" and "*".) - -FORMAT FOR "calculationVariables": -- A JSON array of objects, one per variable: "name" (string), "min" and "max" (numbers; equal min and max fixes a constant), "decimals" (0–8 for sampled value rounding), optional "integerOnly": true. - -FORMAT FOR "calculationAnswerDecimals": -- Integer 0-12: how many decimal places are displayed to the student. Controls display precision only. - -FORMAT FOR "calculationAnswerTolerancePercent" (OPTIONAL): -- Number 0-100. Use when the domain grades within a percentage band: e.g. 2 for chemistry, 5 for geology/engineering. Omit for math/physics where exact decimal rounding is expected. - -Example (base yours on the provided materials): -{ - "type": "calculation", - "topicTitle": "Product of two quantities", - "stem": "If a = {{a}} and b = {{b}}, what is a multiplied by b?", - "calculationFormula": "a * b", - "calculationVariables": [ - { "name": "a", "min": 1, "max": 5, "decimals": 1 }, - { "name": "b", "min": 1, "max": 20, "decimals": 0, "integerOnly": true } - ], - "calculationAnswerDecimals": 1, - "explanation": "Students apply multiplication using values sampled from the stated ranges." -} - -INSTRUCTIONS: -1. Create one specific calculation item tied to the provided content—not a generic drill unrelated to the materials. -2. Set "type" to "calculation" and include "topicTitle", "stem", "calculationFormula", "calculationVariables", "calculationAnswerDecimals", and "explanation". Add "calculationAnswerTolerancePercent" only when the subject warrants percentage-based grading. -3. Use about 2-4 variables unless the objective clearly needs only one. -4. Ensure the formula always evaluates to a finite real number for every value in the given ranges (no division by zero; no invalid operations). -5. Do NOT include "options", a lettered multiple-choice "correctAnswer", or a static numeric "correctAnswer"—the platform computes the correct value from the formula and sampled variables. - -CRITICAL FORMATTING REQUIREMENTS: -- Return ONLY valid JSON. Do NOT wrap the JSON in markdown code blocks. -- Do NOT include any text before or after the JSON object. -- The first character of your entire reply MUST be "{" and the last MUST be "}". -- Return pure JSON that can be parsed with JSON.parse(). -- Escape backslashes inside JSON strings as required for JSON.parse(). - -IMPORTANT: -- Include "topicTitle" in every response (separate from "stem"). -- Keep "calculationFormula" in plain expr-eval-style math only; if "stem" needs math notation, use LaTeX \\( ... \\) inside the JSON string (properly escaped for JSON). -- Placeholders in "stem" must use exactly the form {{variableName}} matching "calculationVariables".`; - - } - - createOpenEndedQuestionPrompt(objective, bloomLevel) { - return `You are an expert educational content creator. Generate one open-ended question from the content. The quiz does not auto-grade text; students see a sample answer and grading criteria after submit. - -OBJECTIVE: ${objective} -BLOOM'S TAXONOMY LEVEL: ${bloomLevel} - -Return JSON only: -{ - "type": "open-ended", - "topicTitle": "short neutral label", - "question": "the prompt (or use stem instead)", - "openEndedSampleAnswer": "model response", - "openEndedGradingCriteria": "rubric or criteria in one string", - "explanation": "brief instructor note" -} - -Rules: No markdown fences. First "{" last "}". No options or MC fields.`; - } - - 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/llm-service.js b/public/scripts/llm-service.js deleted file mode 100644 index 77043df..0000000 --- a/public/scripts/llm-service.js +++ /dev/null @@ -1,298 +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 generateQuestionByType(questionType, objective, ragContent, bloomLevel) { - switch (questionType) { - case "fill-in-the-blank": - return await this.generateFillInTheBlankQuestion(objective, ragContent, bloomLevel); - case "calculation": - return await this.generateCalculationQuestion(objective, ragContent, bloomLevel); - case "open-ended": - return await this.generateOpenEndedQuestion(objective, ragContent, bloomLevel); - case "multiple-choice": - default: - return await this.generateMultipleChoiceQuestion(objective, ragContent, bloomLevel); - } - } - - async generateMultipleChoiceQuestion(objective, ragContent, bloomLevel) { - const prompt = this.createMultipleChoiceQuestionPrompt(objective, bloomLevel); - return await this.generateQuestionWithRAG(prompt, ragContent); - } - - async generateFillInTheBlankQuestion(objective, ragContent, bloomLevel) { - const prompt = this.createFillInTheBlankQuestionPrompt(objective, bloomLevel); - return await this.generateQuestionWithRAG(prompt, ragContent); - } - - async generateCalculationQuestion(objective, ragContent, bloomLevel) { - const prompt = this.createCalculationQuestionPrompt(objective, bloomLevel); - return await this.generateQuestionWithRAG(prompt, ragContent); - } - - async generateOpenEndedQuestion(objective, ragContent, bloomLevel) { - const prompt = this.createOpenEndedQuestionPrompt(objective, bloomLevel); - return await this.generateQuestionWithRAG(prompt, ragContent); - } - - createMultipleChoiceQuestionPrompt(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: -{ - "type": "multiple-choice", - "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".`; - } - - createFillInTheBlankQuestionPrompt(objective, bloomLevel) { - return `You are an expert educational content creator. Generate a high-quality fill-in-the-blank item based on the provided content. - -OBJECTIVE: ${objective} -BLOOM'S TAXONOMY LEVEL: ${bloomLevel} - -REQUIRED "topicTitle" FIELD: -- A very short label (about 3–10 words) naming the topic or skill. Neutral phrase or title only—NOT a question (no "?"), NOT "What is…". -- Must NOT reveal the answer or duplicate correctAnswer / acceptableAnswers wording. -- Must NOT be "Fill in the blank", "Complete the sentence", or similar instructions. - -FORMAT FOR THE "question" FIELD (mandatory): -- ONLY the stem: one unfinished DECLARATIVE sentence (a statement with a gap), NOT an interrogative. -- FORBIDDEN: do not start with or use "What is...", "What are...", "Which...", "Who...", "How...", "Why...", "Define...", or any question mark at the end. -- The sentence MUST contain exactly ONE blank, written ONLY as nine underscores: _________ -- Do not use "____", "___", "[blank]", or other placeholders—only _________ -- The part that belongs in the blank is what the student should recall (term, formula, number, etc.). - -Example (geometry): -{ - "type": "fill-in-the-blank", - "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 materials." -} - -Example (non-math): -{ - "type": "fill-in-the-blank", - "topicTitle": "European capitals", - "question": "The capital of France is _________.", - "correctAnswer": "Paris", - "acceptableAnswers": ["Paris"], - "explanation": "Brief justification from the materials." -} - -INSTRUCTIONS: -1. Include "topicTitle" separate from the stem in "question". -2. Follow the unfinished-sentence + _________ format above. -3. Target an important term, phrase, formula, or concept from the materials. -4. correctAnswer: canonical form; use LaTeX \\( ... \\) inside the JSON string for math answers (escape backslashes for JSON). -5. acceptableAnswers: include canonical answer plus equivalents (alternate LaTeX, plain-text math, synonyms). - -CRITICAL FORMATTING REQUIREMENTS: -- Return ONLY valid JSON. No markdown fences. First character "{", last "}". -- Return pure JSON that can be parsed with JSON.parse(). - -IMPORTANT: -- Include "topicTitle" in every response. -- Exactly one _________ in "question". -- correctAnswer must be what fills the blank, not a full sentence. -- Use \\( ... \\) for inline math inside "question" when needed (properly escaped in JSON).`; - } - - createCalculationQuestionPrompt(objective, bloomLevel) { - return `You are an expert educational content creator. Generate a high-quality numeric calculation question based on the provided content. The quiz system randomizes variable values each attempt and grades answers by rounding to a configured number of decimal places. - -OBJECTIVE: ${objective} -BLOOM'S TAXONOMY LEVEL: ${bloomLevel} - -REQUIRED "topicTitle" FIELD: -- A very short label (about 3-10 words) naming the topic or skill. Neutral phrase or title—do not embed the numeric answer or final computed result. -- Must NOT be generic filler alone (e.g. only "Calculation question"); tie the label to the content. - -FORMAT FOR THE "stem" FIELD (mandatory): -- Question template for students. Use double-brace placeholders only: {{a}}, {{b}}, etc. -- Example: "If a = {{a}} and b = {{b}}, what is a multiplied by b?" -- Every variable in "calculationFormula" must appear in "stem" as {{name}} with the same "name" as in "calculationVariables". - -FORMAT FOR "calculationFormula": -- One expression using variable names and + - * / ^ and parentheses. Plain ASCII only (e.g. "a * b", "(a + b) / 2", "y0 * E^(k*t)"). Do NOT use ∫, ∑, d/dx, or symbolic notation — the evaluator cannot parse them. Use LaTeX only in "stem" for display; keep "calculationFormula" as pure arithmetic. - -CALCULUS-SAFE PATTERN: For calculus objectives, pre-solve the symbolic math yourself, then encode the closed-form result in "calculationFormula". The system evaluates it numerically at random variable values. -- Derivative: f(x) = {{a}}x²+{{b}}x → f'({{x}}) → formula: "2*a*x + b" -- Definite integral with simple closed form: ∫₀^{{b}} {{a}}x² dx → formula: "a * b^3 / 3" -- ODE: dy/dt = {{k}}y, y(0)={{y0}} at t={{t}} → formula: "y0 * E^(k*t)" (add calculationAnswerTolerancePercent: 1) -- IMPORTANT — if the integral has NO simple closed form (e.g. involves cos, sin, ln, or complex compositions), do NOT write the integral in the formula. Instead REFORMULATE to a simpler sub-skill: evaluate the integrand at a point, apply the power rule to a term, or test an initial condition — anything expressible as plain arithmetic. - -FORMAT FOR "calculationVariables": -- Array of objects: "name", "min", "max", "decimals" (0-8), optional "integerOnly": true. Use min === max for a fixed constant. - -FORMAT FOR "calculationAnswerDecimals": -- Integer 0–12. Controls how many decimal places are displayed to the student — not the grading window when tolerancePercent is set. - -FORMAT FOR "calculationAnswerTolerancePercent" (OPTIONAL): -- Number 0–100. Use when the domain grades within a percentage band: e.g. 2 for chemistry, 5 for geology/engineering, 1 for ODE/integral results. Omit for exact arithmetic. - -Example JSON structure (STRUCTURAL REFERENCE ONLY — do NOT copy this topic, formula, or variables; create your own based on the course content above): -{ - "type": "calculation", - "topicTitle": "topic title here", - "stem": "A question involving {{a}} and {{b}} goes here.", - "calculationFormula": "a * b", - "calculationVariables": [ - { "name": "a", "min": 1, "max": 10, "integerOnly": true }, - { "name": "b", "min": 1, "max": 5, "decimals": 1 } - ], - "calculationAnswerDecimals": 2, - "explanation": "Brief justification from the content." -} - -CRITICAL: The formula above ("a * b") is a placeholder. You MUST derive the formula from the actual course content. If the content is about differential equations, write a differential-equation formula. If about integration, write an integration result. Do NOT output generic formulas unrelated to the materials. - -INSTRUCTIONS: -1. Create one calculation item grounded in the provided materials. -2. Include "type": "calculation", "topicTitle", "stem", "calculationFormula", "calculationVariables", "calculationAnswerDecimals", and "explanation". Add "calculationAnswerTolerancePercent" only when the subject warrants percentage-based grading. -3. Prefer 2-4 variables unless a single variable clearly suffices for the objective. -4. Ensure the formula stays finite for all sampled values in range (e.g. no division by zero). -5. Do NOT include "options", multiple-choice letters, or a static numeric correct answer field—the server grades using the formula and sampled variables. - -CRITICAL FORMATTING REQUIREMENTS: -- Return ONLY valid JSON. No markdown code fences. First character "{", last "}". -- Return pure JSON that can be parsed with JSON.parse(). -- Do NOT include any text before or after the JSON object. - -IMPORTANT: -- Include "topicTitle" in every response (separate from "stem"). -- Keep "calculationFormula" as plain math; use LaTeX \\( ... \\) only inside "stem" when needed, with backslashes escaped for JSON. -- Placeholders in "stem" must match variable names in "calculationVariables" and "calculationFormula" exactly.`; - } - - createOpenEndedQuestionPrompt(objective, bloomLevel) { - return `You are an expert educational content creator. Generate one open-ended question based on the provided content. The platform does NOT auto-grade text; students see a sample answer and grading criteria only after they submit. - -OBJECTIVE: ${objective} -BLOOM'S TAXONOMY LEVEL: ${bloomLevel} - -REQUIRED FIELDS: -- "topicTitle": short neutral label (3–10 words), not a question. -- "question" OR "stem": the prompt (paragraph OK). -- "openEndedSampleAnswer": a strong model response. -- "openEndedGradingCriteria": clear rubric or bullet-style criteria in one string. -- "explanation": brief note for instructors. - -Example: -{ - "type": "open-ended", - "topicTitle": "Conceptual comparison", - "question": "Compare two approaches described in the materials and explain when each is preferable.", - "openEndedSampleAnswer": "Approach A emphasizes ... whereas B focuses on ... A is preferable when ...", - "openEndedGradingCriteria": "Full credit: contrasts both approaches with a justified use case. Partial: one approach or vague comparison.", - "explanation": "Aligned with the reading." -} - -CRITICAL: Return ONLY valid JSON. First character "{", last "}". No markdown fences. No "options" or "correctAnswer".`; - } - - isAvailable() { - return this.isInitialized && this.llmModule !== null; - } -} - -// Export for use in other files -window.LLMService = LLMService; diff --git a/src/controllers/student.js b/src/controllers/student.js index c8a9d01..372b425 100644 --- a/src/controllers/student.js +++ b/src/controllers/student.js @@ -110,19 +110,9 @@ function shuffleArray(array) { } function resolveQuestionType(q) { - const t = String(q.questionType || q.type || "") - .trim() - .toLowerCase(); - if (t === "fill-in-the-blank") { - return "fill-in-the-blank"; - } - if (t === "calculation") { - return "calculation"; - } - if (t === "open-ended") { - return "open-ended"; - } - return "multiple-choice"; + const t = String(q.questionType || q.type || "").trim().toLowerCase(); + const known = ["fill-in-the-blank", "calculation", "open-ended"]; + return known.includes(t) ? t : "multiple-choice"; } // Helper function to shuffle question options and update correct answer From d05d9fd83bdb21fc96c8dad22cb5187be86b258e Mon Sep 17 00:00:00 2001 From: Huiyue Xue Date: Tue, 26 May 2026 16:10:15 -0700 Subject: [PATCH 18/20] add a /api/app-constants route that exposes default bloom type preferences --- public/question-generation.html | 1 + public/scripts/app-constants.js | 11 +++ public/scripts/generation-questions.js | 107 +------------------------ public/scripts/question-bank.js | 20 ++--- public/scripts/settings.js | 11 +-- public/settings.html | 1 + 6 files changed, 29 insertions(+), 122 deletions(-) create mode 100644 public/scripts/app-constants.js diff --git a/public/question-generation.html b/public/question-generation.html index e136207..c2a7ee1 100644 --- a/public/question-generation.html +++ b/public/question-generation.html @@ -488,6 +488,7 @@

    Questions Saved Successfully!

    + diff --git a/public/scripts/app-constants.js b/public/scripts/app-constants.js new file mode 100644 index 0000000..ad75907 --- /dev/null +++ b/public/scripts/app-constants.js @@ -0,0 +1,11 @@ +// Browser-side mirror of src/constants/app-constants.js +// Keep in sync with DEFAULT_BLOOM_TYPE_PREFERENCES in that file. + +window.DEFAULT_BLOOM_TYPE_PREFERENCES = { + Remember: ["fill-in-the-blank", "multiple-choice"], + Understand: ["multiple-choice", "fill-in-the-blank"], + Apply: ["multiple-choice", "fill-in-the-blank"], + Analyze: ["multiple-choice", "fill-in-the-blank"], + Evaluate: ["calculation", "multiple-choice"], + Create: ["open-ended", "multiple-choice"], +}; diff --git a/public/scripts/generation-questions.js b/public/scripts/generation-questions.js index 82e164b..0bfc11b 100644 --- a/public/scripts/generation-questions.js +++ b/public/scripts/generation-questions.js @@ -1,21 +1,10 @@ // Generation Questions Module // Handles question generation based on content and objectives -// Question Generation Class -// Default Bloom → question-type preferences (mirrors DEFAULT_BLOOM_TYPE_PREFERENCES in app-constants.js). -const DEFAULT_BLOOM_TYPE_PREFERENCES = { - Remember: ["fill-in-the-blank", "multiple-choice"], - Understand: ["multiple-choice", "fill-in-the-blank"], - Apply: ["multiple-choice", "fill-in-the-blank"], - Analyze: ["multiple-choice", "fill-in-the-blank"], - Evaluate: ["calculation", "multiple-choice"], - Create: ["open-ended", "multiple-choice"], -}; - class QuestionGenerator { constructor(contentGenerator, options = {}) { this.contentGenerator = contentGenerator; - this.bloomTypePreferences = options.bloomTypePreferences || DEFAULT_BLOOM_TYPE_PREFERENCES; + this.bloomTypePreferences = options.bloomTypePreferences || window.DEFAULT_BLOOM_TYPE_PREFERENCES; this.llmService = null; this.initializeLLMService(); } @@ -23,101 +12,11 @@ class QuestionGenerator { async initializeLLMService() { try { console.log("=== QUESTION GENERATOR LLM INITIALIZATION ==="); - this.llmService = { isAvailable: () => true, - - generateMultipleChoiceQuestion: async ({ - courseId, - courseName, - learningObjectiveId, - learningObjectiveText, - granularLearningObjectiveText, - bloomLevel, - }) => { - return await this.callQuestionGenerationApi({ - courseId, - courseName, - learningObjectiveId, - learningObjectiveText, - granularLearningObjectiveText, - bloomLevel, - questionType: "multiple-choice", - }); - }, - - generateFillInTheBlankQuestion: async ({ - courseId, - courseName, - learningObjectiveId, - learningObjectiveText, - granularLearningObjectiveText, - bloomLevel, - }) => { - return await this.callQuestionGenerationApi({ - courseId, - courseName, - learningObjectiveId, - learningObjectiveText, - granularLearningObjectiveText, - bloomLevel, - questionType: "fill-in-the-blank", - }); - }, - - generateCalculationQuestion: async ({ - courseId, - courseName, - learningObjectiveId, - learningObjectiveText, - granularLearningObjectiveText, - bloomLevel, - }) => { - return await this.callQuestionGenerationApi({ - courseId, - courseName, - learningObjectiveId, - learningObjectiveText, - granularLearningObjectiveText, - bloomLevel, - questionType: "calculation", - }); - }, - - generateOpenEndedQuestion: async ({ - courseId, - courseName, - learningObjectiveId, - learningObjectiveText, - granularLearningObjectiveText, - bloomLevel, - }) => { - return await this.callQuestionGenerationApi({ - courseId, - courseName, - learningObjectiveId, - learningObjectiveText, - granularLearningObjectiveText, - bloomLevel, - questionType: "open-ended", - }); - }, - - generateQuestionByType: async (questionType, params) => { - switch (questionType) { - case "fill-in-the-blank": - return await this.llmService.generateFillInTheBlankQuestion(params); - case "calculation": - return await this.llmService.generateCalculationQuestion(params); - case "open-ended": - return await this.llmService.generateOpenEndedQuestion(params); - case "multiple-choice": - default: - return await this.llmService.generateMultipleChoiceQuestion(params); - } - }, + 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); diff --git a/public/scripts/question-bank.js b/public/scripts/question-bank.js index 0a0a0d3..fbe63b2 100644 --- a/public/scripts/question-bank.js +++ b/public/scripts/question-bank.js @@ -84,20 +84,20 @@ function getObjectId(obj) { } /** 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 || "multiple-choice").toString().trim().toLowerCase().replace(/_/g, "-"); - if (t === "fill-in-the-blank") return "fill-in-the-blank"; - if (t === "calculation") return "calculation"; - if (t === "open-ended") return "open-ended"; - return "multiple-choice"; + const t = (raw || "").toString().trim().toLowerCase().replace(/_/g, "-"); + return QUESTION_TYPE_LABELS[t] ? t : "multiple-choice"; } function formatQuestionTypeLabel(raw) { - const key = normalizeQuestionTypeKey(raw); - if (key === "fill-in-the-blank") return "Fill-in-the-blank"; - if (key === "calculation") return "Calculation"; - if (key === "open-ended") return "Open-ended"; - return "Multiple choice"; + return QUESTION_TYPE_LABELS[normalizeQuestionTypeKey(raw)]; } /** Escape text placed inside body */ diff --git a/public/scripts/settings.js b/public/scripts/settings.js index ad6c5eb..36184be 100644 --- a/public/scripts/settings.js +++ b/public/scripts/settings.js @@ -45,14 +45,9 @@ async function initializeSettings() { const resetBloomBtn = document.getElementById('reset-bloom-defaults'); if (resetBloomBtn) { resetBloomBtn.addEventListener('click', () => { - const defaults = { - Remember: "fill-in-the-blank", - Understand: "multiple-choice", - Apply: "multiple-choice", - Analyze: "multiple-choice", - Evaluate: "calculation", - Create: "open-ended", - }; + const defaults = Object.fromEntries( + Object.entries(window.DEFAULT_BLOOM_TYPE_PREFERENCES).map(([level, types]) => [level, types[0]]) + ); document.querySelectorAll('.bloom-select').forEach(select => { const level = select.getAttribute('data-bloom'); if (defaults[level]) select.value = defaults[level]; diff --git a/public/settings.html b/public/settings.html index 5636698..10b4f5d 100644 --- a/public/settings.html +++ b/public/settings.html @@ -191,6 +191,7 @@

    LLM Prompts

    + \ No newline at end of file From 7fbea03d65d9fb646a8af97383d31f6f9aa9bdc7 Mon Sep 17 00:00:00 2001 From: Huiyue Xue Date: Tue, 26 May 2026 16:21:12 -0700 Subject: [PATCH 19/20] replace question type name with constants --- public/question-bank.html | 1 + public/quiz.html | 1 + public/scripts/app-constants.js | 23 +++++++--- public/scripts/generation-questions.js | 44 +++++++++---------- public/scripts/question-bank.js | 48 ++++++++++----------- public/scripts/question-generation.js | 34 +++++++-------- public/scripts/quiz.js | 30 ++++++------- src/constants/app-constants.js | 20 ++++++--- src/controllers/quiz.js | 39 +++++++++-------- src/controllers/rag-llm.js | 60 +++++++++++++------------- src/controllers/student.js | 25 +++++------ src/services/question.js | 13 +++--- 12 files changed, 180 insertions(+), 158 deletions(-) diff --git a/public/question-bank.html b/public/question-bank.html index 711f206..e79fe7f 100644 --- a/public/question-bank.html +++ b/public/question-bank.html @@ -246,6 +246,7 @@

    Export Summary

    + 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 index ad75907..bbe1685 100644 --- a/public/scripts/app-constants.js +++ b/public/scripts/app-constants.js @@ -1,11 +1,20 @@ // Browser-side mirror of src/constants/app-constants.js -// Keep in sync with DEFAULT_BLOOM_TYPE_PREFERENCES in that file. +// 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: ["fill-in-the-blank", "multiple-choice"], - Understand: ["multiple-choice", "fill-in-the-blank"], - Apply: ["multiple-choice", "fill-in-the-blank"], - Analyze: ["multiple-choice", "fill-in-the-blank"], - Evaluate: ["calculation", "multiple-choice"], - Create: ["open-ended", "multiple-choice"], + 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/generation-questions.js b/public/scripts/generation-questions.js index 0bfc11b..4c16687 100644 --- a/public/scripts/generation-questions.js +++ b/public/scripts/generation-questions.js @@ -150,7 +150,7 @@ class QuestionGenerator { determineQuestionType(bloomLevel) { const preferences = this.getBloomTypePreferences(); - return preferences[bloomLevel]?.[0] || "multiple-choice"; + return preferences[bloomLevel]?.[0] || QUESTION_TYPES.MULTIPLE_CHOICE; } prepareContentForQuestions(summary, objectiveGroups) { @@ -235,7 +235,7 @@ class QuestionGenerator { } } - if (!question && activeQuestionType === "calculation") { + if (!question && activeQuestionType === QUESTION_TYPES.CALCULATION) { console.warn( `⚠️ Calculation retries exhausted for question ${i + 1}; falling back to multiple-choice for objective "${granularLearningObjective.text}".` ); @@ -249,9 +249,9 @@ class QuestionGenerator { granularLearningObjective.text, bloomLevel, i + 1, - "multiple-choice" + QUESTION_TYPES.MULTIPLE_CHOICE ); - activeQuestionType = "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) { @@ -269,7 +269,7 @@ class QuestionGenerator { if (!question) { console.error( `❌ Failed to generate question ${i + 1} after ${maxRetries} attempts${ - questionType === "calculation" ? " (including multiple-choice fallback)" : "" + questionType === QUESTION_TYPES.CALCULATION ? " (including multiple-choice fallback)" : "" }:`, lastError ? lastError.message : "unknown error" ); @@ -338,7 +338,7 @@ class QuestionGenerator { const resolvedType = questionData.type || questionData.questionType || questionType; - if (resolvedType === "calculation") { + if (resolvedType === QUESTION_TYPES.CALCULATION) { const stemText = String( questionData.stem || questionData.question || "" ).trim(); @@ -366,8 +366,8 @@ class QuestionGenerator { granularObjectiveId: `${granularLearningObjectiveId}`, text: stemText, topicTitle: topicTitleCalc, - type: "calculation", - questionType: "calculation", + type: QUESTION_TYPES.CALCULATION, + questionType: QUESTION_TYPES.CALCULATION, options: null, correctAnswer: "", acceptableAnswers: [], @@ -385,7 +385,7 @@ class QuestionGenerator { }; } - if (resolvedType === "open-ended") { + if (resolvedType === QUESTION_TYPES.OPEN_ENDED) { const stemText = String( questionData.stem || questionData.question || "" ).trim(); @@ -407,8 +407,8 @@ class QuestionGenerator { text: stemText, stem: stemText, topicTitle: topicTitleOpen, - type: "open-ended", - questionType: "open-ended", + type: QUESTION_TYPES.OPEN_ENDED, + questionType: QUESTION_TYPES.OPEN_ENDED, options: null, correctAnswer: "", acceptableAnswers: [], @@ -429,7 +429,7 @@ class QuestionGenerator { } const acceptable = - resolvedType === "fill-in-the-blank" + resolvedType === QUESTION_TYPES.FILL_IN_THE_BLANK ? Array.isArray(questionData.acceptableAnswers) && questionData.acceptableAnswers.length ? questionData.acceptableAnswers : questionData.correctAnswer != null @@ -439,7 +439,7 @@ class QuestionGenerator { const fibStem = String(questionData.question || "").trim(); const rawTopic = - resolvedType === "fill-in-the-blank" + resolvedType === QUESTION_TYPES.FILL_IN_THE_BLANK ? String( questionData.topicTitle || questionData.topic || @@ -448,7 +448,7 @@ class QuestionGenerator { ).trim() : ""; const topicTitleFib = - resolvedType === "fill-in-the-blank" + resolvedType === QUESTION_TYPES.FILL_IN_THE_BLANK ? rawTopic || (() => { const before = fibStem.split("_________")[0].trim(); @@ -461,7 +461,7 @@ class QuestionGenerator { id: `${granularLearningObjectiveId}-${questionNumber}`, granularObjectiveId: `${granularLearningObjectiveId}`, text: questionData.question, - topicTitle: resolvedType === "fill-in-the-blank" ? topicTitleFib : undefined, + topicTitle: resolvedType === QUESTION_TYPES.FILL_IN_THE_BLANK ? topicTitleFib : undefined, type: resolvedType, questionType: resolvedType, options: questionData.options || null, @@ -542,15 +542,15 @@ class QuestionGenerator { let csv = "Question Type,Question,Option A,Option B,Option C,Option D,Correct Answer,Acceptable Answers,Bloom Level,Difficulty\n"; questions.forEach((q) => { - const qt = q.type || q.questionType || "multiple-choice"; - if (qt === "calculation") { + 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 === "fill-in-the-blank") { + if (qt === QUESTION_TYPES.FILL_IN_THE_BLANK) { const acc = Array.isArray(q.acceptableAnswers) && q.acceptableAnswers.length ? q.acceptableAnswers.join("; ") @@ -561,7 +561,7 @@ class QuestionGenerator { 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 === "open-ended") { + if (qt === QUESTION_TYPES.OPEN_ENDED) { const stem = q.text || q.stem || ""; const sample = q.openEndedSampleAnswer || ""; const crit = q.openEndedGradingCriteria || ""; @@ -588,9 +588,9 @@ class QuestionGenerator { formatAsQTI(questions) { const itemsXml = questions .map((q, index) => { - const qt = q.type || q.questionType || "multiple-choice"; + const qt = q.type || q.questionType || QUESTION_TYPES.MULTIPLE_CHOICE; const ident = `q${index + 1}`; - if (qt === "calculation") { + 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 ` @@ -623,7 +623,7 @@ class QuestionGenerator { `; } - if (qt === "fill-in-the-blank") { + if (qt === QUESTION_TYPES.FILL_IN_THE_BLANK) { const acceptable = Array.isArray(q.acceptableAnswers) && q.acceptableAnswers.length ? q.acceptableAnswers diff --git a/public/scripts/question-bank.js b/public/scripts/question-bank.js index fbe63b2..ef06db7 100644 --- a/public/scripts/question-bank.js +++ b/public/scripts/question-bank.js @@ -93,7 +93,7 @@ const QUESTION_TYPE_LABELS = { function normalizeQuestionTypeKey(raw) { const t = (raw || "").toString().trim().toLowerCase().replace(/_/g, "-"); - return QUESTION_TYPE_LABELS[t] ? t : "multiple-choice"; + return QUESTION_TYPE_LABELS[t] ? t : QUESTION_TYPES.MULTIPLE_CHOICE; } function formatQuestionTypeLabel(raw) { @@ -1885,7 +1885,7 @@ class QuestionBankPage { ${question.bloom || "N/A"} - ${formatQuestionTypeLabel(question.questionType)} + ${formatQuestionTypeLabel(question.questionType)} ${question.status || "Draft"} @@ -1977,8 +1977,8 @@ class QuestionBankPage { bValue = (b.bloom || "").toLowerCase(); break; case "questionType": - aValue = (a.questionType || "multiple-choice").toLowerCase(); - bValue = (b.questionType || "multiple-choice").toLowerCase(); + aValue = (a.questionType || QUESTION_TYPES.MULTIPLE_CHOICE).toLowerCase(); + bValue = (b.questionType || QUESTION_TYPES.MULTIPLE_CHOICE).toLowerCase(); break; case "status": aValue = (a.status || "Draft").toLowerCase(); @@ -2612,7 +2612,7 @@ class QuestionBankPage { const qType = normalizeQuestionTypeKey(question.questionType || question.type); - if (qType === "fill-in-the-blank") { + if (qType === QUESTION_TYPES.FILL_IN_THE_BLANK) { const canonical = question.correctAnswer != null ? String(question.correctAnswer).trim() : ""; let acceptable = Array.isArray(question.acceptableAnswers) @@ -2625,14 +2625,14 @@ class QuestionBankPage { id: questionId, title: question.title || question.stem || "", stem: question.stem || question.title || "", - questionType: "fill-in-the-blank", + questionType: QUESTION_TYPES.FILL_IN_THE_BLANK, correctAnswer: canonical, acceptableAnswers: acceptable, canEdit, learningObjectiveId: question.learningObjectiveId, granularObjectiveId: question.granularObjectiveId, }; - } else if (qType === "calculation") { + } else if (qType === QUESTION_TYPES.CALCULATION) { const vars = Array.isArray(question.calculationVariables) ? question.calculationVariables : []; const dec = question.calculationAnswerDecimals !== undefined && question.calculationAnswerDecimals !== null @@ -2644,7 +2644,7 @@ class QuestionBankPage { id: questionId, title: question.title || "", stem: question.stem || "", - questionType: "calculation", + questionType: QUESTION_TYPES.CALCULATION, calculationFormula: (question.calculationFormula || "").trim(), calculationVariables: vars, calculationAnswerDecimals: Number.isFinite(dec) ? dec : 2, @@ -2653,12 +2653,12 @@ class QuestionBankPage { learningObjectiveId: question.learningObjectiveId, granularObjectiveId: question.granularObjectiveId, }; - } else if (qType === "open-ended") { + } else if (qType === QUESTION_TYPES.OPEN_ENDED) { this.currentEditingQuestion = { id: questionId, title: question.title || "", stem: question.stem || question.title || "", - questionType: "open-ended", + questionType: QUESTION_TYPES.OPEN_ENDED, openEndedSampleAnswer: String(question.openEndedSampleAnswer || "").trim(), openEndedGradingCriteria: String(question.openEndedGradingCriteria || "").trim(), canEdit, @@ -2702,7 +2702,7 @@ class QuestionBankPage { id: questionId, title: question.title || question.stem || "", stem: question.stem || question.title || "", - questionType: "multiple-choice", + questionType: QUESTION_TYPES.MULTIPLE_CHOICE, options: normalizedOptions, correctAnswer: correctAnswerLetter, canEdit, @@ -2748,9 +2748,9 @@ class QuestionBankPage { modalTitleEl.textContent = canEdit ? "Edit question" : "View question"; } - const isCalc = question.questionType === "calculation"; - const isFib = question.questionType === "fill-in-the-blank"; - const isOpen = question.questionType === "open-ended"; + 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; @@ -3155,7 +3155,7 @@ class QuestionBankPage { const title = titleInput ? titleInput.value.trim() : ""; const stem = stemInput ? stemInput.value.trim() : ""; - if (this.currentEditingQuestion.questionType === "calculation") { + if (this.currentEditingQuestion.questionType === QUESTION_TYPES.CALCULATION) { if (!title) { this.showNotification("Topic title is required", "error"); if (saveBtn) saveBtn.disabled = false; @@ -3212,7 +3212,7 @@ class QuestionBankPage { const updateData = { title, stem, - questionType: "calculation", + questionType: QUESTION_TYPES.CALCULATION, calculationFormula: formula, calculationVariables: variables, calculationAnswerDecimals: dec, @@ -3236,7 +3236,7 @@ class QuestionBankPage { if (q) { q.title = updateData.title; q.stem = updateData.stem; - q.questionType = "calculation"; + q.questionType = QUESTION_TYPES.CALCULATION; } this.closeQuestionModal(); @@ -3246,7 +3246,7 @@ class QuestionBankPage { return; } - if (this.currentEditingQuestion.questionType === "open-ended") { + if (this.currentEditingQuestion.questionType === QUESTION_TYPES.OPEN_ENDED) { if (!title) { this.showNotification("Topic title is required", "error"); if (saveBtn) saveBtn.disabled = false; @@ -3275,7 +3275,7 @@ class QuestionBankPage { const updateData = { title, stem, - questionType: "open-ended", + questionType: QUESTION_TYPES.OPEN_ENDED, openEndedSampleAnswer, openEndedGradingCriteria, options: {}, @@ -3298,7 +3298,7 @@ class QuestionBankPage { if (q) { q.title = updateData.title; q.stem = updateData.stem; - q.questionType = "open-ended"; + q.questionType = QUESTION_TYPES.OPEN_ENDED; } this.closeQuestionModal(); @@ -3308,7 +3308,7 @@ class QuestionBankPage { return; } - if (this.currentEditingQuestion.questionType === "fill-in-the-blank") { + if (this.currentEditingQuestion.questionType === QUESTION_TYPES.FILL_IN_THE_BLANK) { if (!title) { this.showNotification("Topic title is required", "error"); if (saveBtn) saveBtn.disabled = false; @@ -3348,7 +3348,7 @@ class QuestionBankPage { const updateData = { title, stem, - questionType: "fill-in-the-blank", + questionType: QUESTION_TYPES.FILL_IN_THE_BLANK, correctAnswer: correct, acceptableAnswers: acceptable, options: {}, @@ -3369,7 +3369,7 @@ class QuestionBankPage { if (q) { q.title = updateData.title; q.stem = updateData.stem; - q.questionType = "fill-in-the-blank"; + q.questionType = QUESTION_TYPES.FILL_IN_THE_BLANK; } this.closeQuestionModal(); @@ -3431,7 +3431,7 @@ class QuestionBankPage { const updateData = { title: title || stem, stem: stem || title, - questionType: "multiple-choice", + questionType: QUESTION_TYPES.MULTIPLE_CHOICE, options: optionsObject, correctAnswer: correctAnswerLetter, }; diff --git a/public/scripts/question-generation.js b/public/scripts/question-generation.js index 81073cc..31cea8c 100644 --- a/public/scripts/question-generation.js +++ b/public/scripts/question-generation.js @@ -3032,10 +3032,10 @@ function convertQuestionsToGroups(questions) { isOpen: true, // Open all panels by default when generating for multiple learning objectives los: groupQuestions.map((question, itemIndex) => { const qType = - question.type || question.questionType || "multiple-choice"; - const isFib = qType === "fill-in-the-blank"; - const isCalc = qType === "calculation"; - const isOpen = qType === "open-ended"; + 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) && @@ -3049,7 +3049,7 @@ function convertQuestionsToGroups(questions) { id: question.id, title: question.text, stem: "Select the best answer:", - questionType: "multiple-choice", + questionType: QUESTION_TYPES.MULTIPLE_CHOICE, options: { A: { id: "A", @@ -3098,7 +3098,7 @@ function convertQuestionsToGroups(questions) { return words.slice(0, 10).join(" ") || "Fill-in-the-blank"; })(), stem: question.text, - questionType: "fill-in-the-blank", + questionType: QUESTION_TYPES.FILL_IN_THE_BLANK, options: {}, correctAnswer: question.correctAnswer, acceptableAnswers: acceptable, @@ -3126,7 +3126,7 @@ function convertQuestionsToGroups(questions) { return words.slice(0, 10).join(" ") || "Calculation"; })(), stem: stemCalc, - questionType: "calculation", + questionType: QUESTION_TYPES.CALCULATION, options: {}, correctAnswer: "", acceptableAnswers: [], @@ -3161,7 +3161,7 @@ function convertQuestionsToGroups(questions) { return words.slice(0, 10).join(" ") || "Open-ended"; })(), stem: stemOpen, - questionType: "open-ended", + questionType: QUESTION_TYPES.OPEN_ENDED, options: {}, correctAnswer: "", acceptableAnswers: [], @@ -3352,11 +3352,11 @@ function renderGranularLoSection(lo, group) { function renderQuestionCard(question, group) { const isEditing = question.isEditing || false; const isFib = - (question.questionType || question.type) === "fill-in-the-blank"; + (question.questionType || question.type) === QUESTION_TYPES.FILL_IN_THE_BLANK; const isCalc = - (question.questionType || question.type) === "calculation"; + (question.questionType || question.type) === QUESTION_TYPES.CALCULATION; const isOpen = - (question.questionType || question.type) === "open-ended"; + (question.questionType || question.type) === QUESTION_TYPES.OPEN_ENDED; const titleEditingHtml = (isFib || isCalc || isOpen) && isEditing @@ -3742,7 +3742,7 @@ function saveQuestionEdit(questionId) { } const isFib = - (question.questionType || question.type) === "fill-in-the-blank"; + (question.questionType || question.type) === QUESTION_TYPES.FILL_IN_THE_BLANK; if (isFib) { if (!String(question.title || "").trim()) { showToast("Topic title is required", "error"); @@ -3770,7 +3770,7 @@ function saveQuestionEdit(questionId) { } const isCalc = - (question.questionType || question.type) === "calculation"; + (question.questionType || question.type) === QUESTION_TYPES.CALCULATION; if (isCalc) { if (!String(question.title || "").trim()) { showToast("Topic title is required", "error"); @@ -3808,7 +3808,7 @@ function saveQuestionEdit(questionId) { } const isOpen = - (question.questionType || question.type) === "open-ended"; + (question.questionType || question.type) === QUESTION_TYPES.OPEN_ENDED; if (isOpen) { if (!String(question.title || "").trim()) { showToast("Topic title is required", "error"); @@ -4203,7 +4203,7 @@ async function handleSaveToQuiz() { for (const lo of group.los) { for (const question of lo.questions) { const qt = - question.questionType || question.type || "multiple-choice"; + question.questionType || question.type || QUESTION_TYPES.MULTIPLE_CHOICE; const payload = { title: question.title || question.stem || "", stem: question.stem || question.title || "", @@ -4220,7 +4220,7 @@ async function handleSaveToQuiz() { status: question.status || "Draft", flagStatus: question.flagStatus || false, }; - if (qt === "calculation") { + if (qt === QUESTION_TYPES.CALCULATION) { payload.options = {}; payload.calculationFormula = question.calculationFormula || ""; payload.calculationVariables = Array.isArray( @@ -4232,7 +4232,7 @@ async function handleSaveToQuiz() { if (!Number.isFinite(d)) d = 2; payload.calculationAnswerDecimals = Math.max(0, Math.min(12, d)); } - if (qt === "open-ended") { + if (qt === QUESTION_TYPES.OPEN_ENDED) { payload.options = {}; payload.openEndedSampleAnswer = String( question.openEndedSampleAnswer || "" diff --git a/public/scripts/quiz.js b/public/scripts/quiz.js index e184865..b3ad90f 100644 --- a/public/scripts/quiz.js +++ b/public/scripts/quiz.js @@ -544,7 +544,7 @@ function showQuestion(questionIndex) { const rawStem = (question.stem || "").trim(); const isGenericFibStem = - question.questionType === "fill-in-the-blank" && + question.questionType === QUESTION_TYPES.FILL_IN_THE_BLANK && /^fill\s+in\s+the\s+blank:?\s*$/i.test(rawStem); if (question.stem && !isGenericFibStem) { completeHTML += ` @@ -554,7 +554,7 @@ function showQuestion(questionIndex) { `; } - if (question.questionType === "calculation" && !question.calculationLoadError) { + if (question.questionType === QUESTION_TYPES.CALCULATION && !question.calculationLoadError) { const tol = Number(question.calculationAnswerTolerancePercent); if (Number.isFinite(tol) && tol > 0) { completeHTML += `
    Your answer will be accepted within ${tol}% of the correct value.
    `; @@ -565,7 +565,7 @@ function showQuestion(questionIndex) { } } - if (question.questionType === "open-ended") { + if (question.questionType === QUESTION_TYPES.OPEN_ENDED) { completeHTML += `
    This question is not auto-graded. After you submit, you will see a sample answer and the grading criteria for self-checking.
    `; } @@ -608,7 +608,7 @@ function showQuestion(questionIndex) { // Disable next button if current question hasn't been answered const hasAnswer = quizState.answers[questionId] !== undefined; const calcBroken = - question.questionType === "calculation" && question.calculationLoadError; + question.questionType === QUESTION_TYPES.CALCULATION && question.calculationLoadError; document.getElementById("nextButton").disabled = !hasAnswer && !calcBroken; // Update question indicators @@ -616,15 +616,15 @@ function showQuestion(questionIndex) { } function isFillInTheBlankQuestion(question) { - return question.questionType === "fill-in-the-blank"; + return question.questionType === QUESTION_TYPES.FILL_IN_THE_BLANK; } function isCalculationQuestion(question) { - return question.questionType === "calculation"; + return question.questionType === QUESTION_TYPES.CALCULATION; } function isOpenEndedQuestion(question) { - return question.questionType === "open-ended"; + return question.questionType === QUESTION_TYPES.OPEN_ENDED; } function renderFillInTheBlankAnswerUI(question, questionIndex) { @@ -861,7 +861,7 @@ async function submitOpenEndedAnswer(questionId, questionIndex) { sampleAnswer: result.sampleAnswer, gradingCriteria: result.gradingCriteria, feedbackText: result.feedback, - questionType: "open-ended", + questionType: QUESTION_TYPES.OPEN_ENDED, }; btn.textContent = prevLabel; @@ -940,7 +940,7 @@ async function submitCalculationAnswer(questionId, questionIndex) { correctAnswer: result.correctAnswer, feedbackText: result.feedback, correctOptionText: result.correctOptionText, - questionType: "calculation", + questionType: QUESTION_TYPES.CALCULATION, }; btn.textContent = prevLabel; @@ -1007,7 +1007,7 @@ async function submitFillInBlankAnswer(questionId, questionIndex) { correctAnswer: result.correctAnswer, feedbackText: result.feedback, correctOptionText: result.correctOptionText, - questionType: "fill-in-the-blank" + questionType: QUESTION_TYPES.FILL_IN_THE_BLANK }; btn.textContent = prevLabel; @@ -1209,7 +1209,7 @@ function showFeedback(questionIndex, questionId, selectedIndex, correctAnswer) { : ""; // Open-ended: no right/wrong; show sample answer and criteria after submit - if (feedbackData.openEnded || (feedbackData.questionType === "open-ended" && feedbackData.isCorrect === null)) { + if (feedbackData.openEnded || (feedbackData.questionType === QUESTION_TYPES.OPEN_ENDED && feedbackData.isCorrect === null)) { feedbackMessage.className = "feedback-message feedback-open-ended"; const sample = feedbackData.sampleAnswer != null ? String(feedbackData.sampleAnswer).trim() : ""; const crit = @@ -1243,8 +1243,8 @@ function showFeedback(questionIndex, questionId, selectedIndex, correctAnswer) { feedbackSection.style.display = "block"; } else { feedbackMessage.className = "feedback-message feedback-incorrect"; - const isFib = feedbackData.questionType === "fill-in-the-blank"; - const isCalc = feedbackData.questionType === "calculation"; + const isFib = feedbackData.questionType === QUESTION_TYPES.FILL_IN_THE_BLANK; + const isCalc = feedbackData.questionType === QUESTION_TYPES.CALCULATION; const fibReveal = isFib && feedbackData.correctOptionText != null && String(feedbackData.correctOptionText).trim() !== ""; const calcReveal = @@ -1289,7 +1289,7 @@ function updateQuestionIndicators() { indicator.classList.add("answered"); if (quizState.feedback[questionId]) { const fd = quizState.feedback[questionId]; - if (fd.openEnded || (fd.questionType === "open-ended" && fd.isCorrect === null)) { + if (fd.openEnded || (fd.questionType === QUESTION_TYPES.OPEN_ENDED && fd.isCorrect === null)) { indicator.classList.add("submitted"); } else if (fd.isCorrect) { indicator.classList.add("correct"); @@ -1307,7 +1307,7 @@ async function showCompletion() { document.querySelector(".quiz-navigation").style.display = "none"; // Score only auto-graded items; open-ended questions are excluded from the percentage - const gradedQuestions = quizState.quizData.questions.filter((q) => q.questionType !== "open-ended"); + const gradedQuestions = quizState.quizData.questions.filter((q) => q.questionType !== QUESTION_TYPES.OPEN_ENDED); const totalGraded = gradedQuestions.length; let correctCount = 0; gradedQuestions.forEach((q) => { diff --git a/src/constants/app-constants.js b/src/constants/app-constants.js index 443d5a6..fbb8d2c 100644 --- a/src/constants/app-constants.js +++ b/src/constants/app-constants.js @@ -236,6 +236,13 @@ 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, @@ -245,12 +252,12 @@ const DEFAULT_PROMPTS = { // 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: ["fill-in-the-blank", "multiple-choice"], - Understand: ["multiple-choice", "fill-in-the-blank"], - Apply: ["multiple-choice", "fill-in-the-blank"], - Analyze: ["multiple-choice", "fill-in-the-blank"], - Evaluate: ["calculation", "multiple-choice"], - Create: ["open-ended", "multiple-choice"], + 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 = { @@ -258,6 +265,7 @@ module.exports = { OBJECTIVE_GENERATION_AUTO_PROMPT, OBJECTIVE_GENERATION_MANUAL_PROMPT, BLOOM_LEVELS, + QUESTION_TYPES, DEFAULT_PROMPTS, DEFAULT_BLOOM_TYPE_PREFERENCES, }; diff --git a/src/controllers/quiz.js b/src/controllers/quiz.js index 795fdd4..cef469b 100644 --- a/src/controllers/quiz.js +++ b/src/controllers/quiz.js @@ -2,6 +2,7 @@ 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 @@ -209,16 +210,16 @@ function resolveQuestionType(q) { const t = String(q.questionType || q.type || "") .trim() .toLowerCase(); - if (t === "fill-in-the-blank") { - return "fill-in-the-blank"; + if (t === QUESTION_TYPES.FILL_IN_THE_BLANK) { + return QUESTION_TYPES.FILL_IN_THE_BLANK; } - if (t === "calculation") { - return "calculation"; + if (t === QUESTION_TYPES.CALCULATION) { + return QUESTION_TYPES.CALCULATION; } - if (t === "open-ended") { - return "open-ended"; + if (t === QUESTION_TYPES.OPEN_ENDED) { + return QUESTION_TYPES.OPEN_ENDED; } - return "multiple-choice"; + return QUESTION_TYPES.MULTIPLE_CHOICE; } // Helper function to generate a shuffled order of option indices @@ -258,13 +259,13 @@ const getQuizQuestionsHandler = async (req, res) => { const questionType = resolveQuestionType(q); const questionText = (q.title || q.stem || "").trim(); - if (questionType === "fill-in-the-blank") { + 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: "fill-in-the-blank", + questionType: QUESTION_TYPES.FILL_IN_THE_BLANK, options: {}, }; let finalQuestion = formattedQuestion; @@ -277,14 +278,14 @@ const getQuizQuestionsHandler = async (req, res) => { return finalQuestion; } - if (questionType === "open-ended") { + 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: "open-ended", + questionType: QUESTION_TYPES.OPEN_ENDED, options: {}, learningObjectiveId: q.learningObjectiveId, granularObjectiveId: q.granularObjectiveId, @@ -298,7 +299,7 @@ const getQuizQuestionsHandler = async (req, res) => { return formattedQuestion; } - if (questionType === "calculation") { + if (questionType === QUESTION_TYPES.CALCULATION) { const vars = q.calculationVariables; const template = calculationQuestion.resolveCalculationDisplayTemplate( q.stem, @@ -329,7 +330,7 @@ const getQuizQuestionsHandler = async (req, res) => { return { id: qid, question: built.rendered, - questionType: "calculation", + questionType: QUESTION_TYPES.CALCULATION, calculationToken: built.token, answerDecimalPlaces: built.answerDecimalPlaces, calculationAnswerTolerancePercent: tolerancePercent, @@ -349,7 +350,7 @@ const getQuizQuestionsHandler = async (req, res) => { question: template || "This calculation question could not be loaded. Please contact your instructor.", - questionType: "calculation", + questionType: QUESTION_TYPES.CALCULATION, calculationToken: null, answerDecimalPlaces: answerDec, calculationLoadError: true, @@ -364,7 +365,7 @@ const getQuizQuestionsHandler = async (req, res) => { ...q, id: qid, question: template || "Question text not available", - questionType: "calculation", + questionType: QUESTION_TYPES.CALCULATION, options: {}, calculationFormula: formula, calculationVariables: vars, @@ -402,7 +403,7 @@ const getQuizQuestionsHandler = async (req, res) => { ...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: "multiple-choice", + questionType: QUESTION_TYPES.MULTIPLE_CHOICE, options: optionsObj, correctAnswer: (q.correctAnswer || "A").toString().toUpperCase() }; @@ -480,7 +481,7 @@ const checkQuestionAnswerHandler = async (req, res) => { question.calculationFormula.trim().length > 0; const treatAsCalculation = - questionType === "calculation" || + questionType === QUESTION_TYPES.CALCULATION || (calculationToken && hasCalculationFormula); if (treatAsCalculation) { @@ -548,7 +549,7 @@ const checkQuestionAnswerHandler = async (req, res) => { return; } - if (questionType === "open-ended") { + if (questionType === QUESTION_TYPES.OPEN_ENDED) { if (answerText === undefined || answerText === null || String(answerText).trim() === "") { return res.status(400).json({ success: false, @@ -577,7 +578,7 @@ const checkQuestionAnswerHandler = async (req, res) => { return; } - if (questionType === "fill-in-the-blank") { + if (questionType === QUESTION_TYPES.FILL_IN_THE_BLANK) { if (answerText === undefined || answerText === null) { return res.status(400).json({ success: false, diff --git a/src/controllers/rag-llm.js b/src/controllers/rag-llm.js index a15ed42..882b5f7 100644 --- a/src/controllers/rag-llm.js +++ b/src/controllers/rag-llm.js @@ -12,7 +12,7 @@ const { getMaterialCourseId } = require('../services/material'); const { isUserInCourse } = require('../services/user-course'); const settingsService = require('../services/settings'); const calculationQuestionService = require('../services/calculation-question'); -const { DEFAULT_PROMPTS, BLOOM_LEVELS } = require('../constants/app-constants'); +const { DEFAULT_PROMPTS, BLOOM_LEVELS, QUESTION_TYPES } = require('../constants/app-constants'); // --------------------------------------------------------------------------- // Temporary debug logger — appends one JSON entry per attempt to llm-debug.json @@ -56,10 +56,10 @@ function returnErrorResponse(res, error, details = null) { function resolveQuestionTypeFromPayload(data, requestedType) { const t = data?.type || data?.questionType; if ( - t === "fill-in-the-blank" || - t === "multiple-choice" || - t === "calculation" || - t === "open-ended" + t === QUESTION_TYPES.FILL_IN_THE_BLANK || + t === QUESTION_TYPES.MULTIPLE_CHOICE || + t === QUESTION_TYPES.CALCULATION || + t === QUESTION_TYPES.OPEN_ENDED ) { return t; } @@ -138,7 +138,7 @@ function repairLooseMultipleChoiceShape(data) { } return { ...d, - type: d.type || "multiple-choice", + type: d.type || QUESTION_TYPES.MULTIPLE_CHOICE, options, correctAnswer: letters[correctIdx], }; @@ -173,7 +173,7 @@ function validateAndNormalizeQuestionData(data, requestedType) { ); } - if (resolvedType === "calculation") { + if (resolvedType === QUESTION_TYPES.CALCULATION) { const merged = { ...data }; const stemText = String(merged.stem || merged.question || "").trim(); if (!stemText) { @@ -258,8 +258,8 @@ function validateAndNormalizeQuestionData(data, requestedType) { normalizedVars ); return { - type: "calculation", - questionType: "calculation", + type: QUESTION_TYPES.CALCULATION, + questionType: QUESTION_TYPES.CALCULATION, topicTitle, question: stemText, stem: stemText, @@ -272,7 +272,7 @@ function validateAndNormalizeQuestionData(data, requestedType) { }; } - if (resolvedType === "open-ended") { + if (resolvedType === QUESTION_TYPES.OPEN_ENDED) { const merged = { ...data }; const stemText = String(merged.stem || merged.question || "").trim(); if (!stemText) { @@ -300,8 +300,8 @@ function validateAndNormalizeQuestionData(data, requestedType) { topicTitle = words.slice(0, 10).join(" ") || "Open-ended"; } return { - type: "open-ended", - questionType: "open-ended", + type: QUESTION_TYPES.OPEN_ENDED, + questionType: QUESTION_TYPES.OPEN_ENDED, topicTitle, question: stemText, stem: stemText, @@ -316,7 +316,7 @@ function validateAndNormalizeQuestionData(data, requestedType) { throw new Error("Missing required field: question"); } - if (resolvedType === "fill-in-the-blank") { + if (resolvedType === QUESTION_TYPES.FILL_IN_THE_BLANK) { const merged = { ...data }; if ( (merged.correctAnswer == null || String(merged.correctAnswer).trim() === "") && @@ -353,8 +353,8 @@ function validateAndNormalizeQuestionData(data, requestedType) { topicTitle = words.slice(0, 10).join(" ") || "Fill-in-the-blank"; } return { - type: "fill-in-the-blank", - questionType: "fill-in-the-blank", + type: QUESTION_TYPES.FILL_IN_THE_BLANK, + questionType: QUESTION_TYPES.FILL_IN_THE_BLANK, topicTitle, question: qText, correctAnswer: canonical, @@ -386,8 +386,8 @@ function validateAndNormalizeQuestionData(data, requestedType) { } letter = letter.trim().toUpperCase(); return { - type: "multiple-choice", - questionType: "multiple-choice", + type: QUESTION_TYPES.MULTIPLE_CHOICE, + questionType: QUESTION_TYPES.MULTIPLE_CHOICE, question: mcData.question.trim(), options: { A: String(mcData.options.A).trim(), @@ -480,7 +480,7 @@ function tryParseQuestionJsonFromLaxText(jsonString) { * for small models like llama3.1:8b. */ function filterPromptToType(template, questionType) { - const allTypes = ["multiple-choice", "fill-in-the-blank", "calculation", "open-ended"]; + 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; @@ -549,7 +549,7 @@ function jsonOnlyRetrySuffix(attempt, questionType, lastError) { // Build targeted extra guidance from the last error message for calculation type. let calcExtra = ""; - if (questionType === "calculation" && lastError) { + 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. "; @@ -588,10 +588,10 @@ function jsonOnlyRetrySuffix(attempt, questionType, lastError) { 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 === "multiple-choice") schema = mcSchema; - else if (questionType === "fill-in-the-blank") schema = fibSchema; - else if (questionType === "calculation") schema = calcSchema; - else if (questionType === "open-ended") schema = openSchema; + 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 ` @@ -687,10 +687,10 @@ const generateQuestionsWithRagHandler = async (req, res) => { // Validate question types const ALLOWED_QUESTION_TYPES = [ - "multiple-choice", - "fill-in-the-blank", - "calculation", - "open-ended", + 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({ @@ -871,7 +871,7 @@ const generateQuestionsWithRagHandler = async (req, res) => { throw new Error(`Failed to generate valid JSON after ${maxRetries} attempts. Last error: ${lastError?.message || 'Unknown error'}`); } - if (questionData.questionType === "multiple-choice") { + if (questionData.questionType === QUESTION_TYPES.MULTIPLE_CHOICE) { const correctOptionLetter = questionData.correctAnswer; const optText = questionData.options?.[correctOptionLetter]; if (optText) { @@ -883,11 +883,11 @@ const generateQuestionsWithRagHandler = async (req, res) => { `⚠️ Warning: No option text at position ${correctOptionLetter}, but continuing anyway` ); } - } else if (questionData.questionType === "calculation") { + } else if (questionData.questionType === QUESTION_TYPES.CALCULATION) { console.log( `✅ Calculation question: formula "${String(questionData.calculationFormula || "").substring(0, 60)}..."` ); - } else if (questionData.questionType === "open-ended") { + } else if (questionData.questionType === QUESTION_TYPES.OPEN_ENDED) { console.log("✅ Open-ended question with sample answer and grading criteria"); } diff --git a/src/controllers/student.js b/src/controllers/student.js index 372b425..5785ad0 100644 --- a/src/controllers/student.js +++ b/src/controllers/student.js @@ -4,6 +4,7 @@ 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) => { @@ -111,8 +112,8 @@ function shuffleArray(array) { function resolveQuestionType(q) { const t = String(q.questionType || q.type || "").trim().toLowerCase(); - const known = ["fill-in-the-blank", "calculation", "open-ended"]; - return known.includes(t) ? t : "multiple-choice"; + 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 @@ -186,15 +187,15 @@ const getQuizQuestionsHandler = async (req, res) => { const questionType = resolveQuestionType(q); const questionText = (q.title || q.stem || "").trim(); const fibMainText = - questionType === "fill-in-the-blank" + questionType === QUESTION_TYPES.FILL_IN_THE_BLANK ? (q.stem || q.title || "").trim() : questionText; - if (questionType === "fill-in-the-blank") { + 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: "fill-in-the-blank", + questionType: QUESTION_TYPES.FILL_IN_THE_BLANK, options: {}, learningObjectiveId: q.learningObjectiveId, granularObjectiveId: q.granularObjectiveId, @@ -202,11 +203,11 @@ const getQuizQuestionsHandler = async (req, res) => { }; } - if (questionType === "open-ended") { + 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: "open-ended", + questionType: QUESTION_TYPES.OPEN_ENDED, options: {}, learningObjectiveId: q.learningObjectiveId, granularObjectiveId: q.granularObjectiveId, @@ -214,7 +215,7 @@ const getQuizQuestionsHandler = async (req, res) => { }; } - if (questionType === "calculation") { + if (questionType === QUESTION_TYPES.CALCULATION) { const vars = q.calculationVariables; const template = calculationQuestion.resolveCalculationDisplayTemplate( q.stem, @@ -243,7 +244,7 @@ const getQuizQuestionsHandler = async (req, res) => { return { id: qid, question: built.rendered, - questionType: "calculation", + questionType: QUESTION_TYPES.CALCULATION, calculationToken: built.token, answerDecimalPlaces: built.answerDecimalPlaces, calculationAnswerTolerancePercent: tolerancePercent, @@ -263,7 +264,7 @@ const getQuizQuestionsHandler = async (req, res) => { question: template || "This calculation question could not be loaded. Please contact your instructor.", - questionType: "calculation", + questionType: QUESTION_TYPES.CALCULATION, calculationToken: null, answerDecimalPlaces: answerDec, calculationLoadError: true, @@ -296,7 +297,7 @@ const getQuizQuestionsHandler = async (req, res) => { 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: "multiple-choice", + questionType: QUESTION_TYPES.MULTIPLE_CHOICE, options: optionsObj, correctAnswer: (q.correctAnswer || "A").toString().toUpperCase(), learningObjectiveId: q.learningObjectiveId, @@ -319,7 +320,7 @@ const getQuizQuestionsHandler = async (req, res) => { // Shuffle MC options only; other types have no options to shuffle const randomizedQuestions = transformedQuestions.map((q) => - q.questionType === "multiple-choice" ? shuffleQuestionOptions(q) : q + q.questionType === QUESTION_TYPES.MULTIPLE_CHOICE ? shuffleQuestionOptions(q) : q ); res.json({ diff --git a/src/services/question.js b/src/services/question.js index 365f730..4c883ab 100644 --- a/src/services/question.js +++ b/src/services/question.js @@ -1,4 +1,5 @@ const databaseService = require('./database'); +const { QUESTION_TYPES } = require('../constants/app-constants'); const calculationQuestion = require('./calculation-question'); const { ObjectId } = require('mongodb'); @@ -22,9 +23,9 @@ const saveQuestion = async (courseId, questionData) => { const questionType = questionData.questionType || questionData.type || - "multiple-choice"; + QUESTION_TYPES.MULTIPLE_CHOICE; - if (String(questionType).toLowerCase() === "calculation") { + if (String(questionType).toLowerCase() === QUESTION_TYPES.CALCULATION) { calculationQuestion.validateFormulaAgainstVariableSpecs( typeof questionData.calculationFormula === "string" ? questionData.calculationFormula @@ -67,15 +68,15 @@ const saveQuestion = async (courseId, questionData) => { ? questionData.acceptableAnswers : [], openEndedSampleAnswer: - qtLower === "open-ended" + qtLower === QUESTION_TYPES.OPEN_ENDED ? String(questionData.openEndedSampleAnswer || "").trim() : "", openEndedGradingCriteria: - qtLower === "open-ended" + qtLower === QUESTION_TYPES.OPEN_ENDED ? String(questionData.openEndedGradingCriteria || "").trim() : "", calculationFormula: - qtLower === "calculation" + qtLower === QUESTION_TYPES.CALCULATION ? calculationQuestion.prepareCalculationFormula( calcFormulaRaw, calcVarsForStore @@ -271,7 +272,7 @@ const updateQuestion = async (questionId, updateData) => { const qt = String(merged.questionType || merged.type || "") .trim() .toLowerCase(); - if (qt === "calculation") { + if (qt === QUESTION_TYPES.CALCULATION) { const mergedFormula = typeof merged.calculationFormula === "string" ? merged.calculationFormula From d4a4fc7aaaaac8baa0f4acaeb24f4c5080648093 Mon Sep 17 00:00:00 2001 From: Grace Xue Date: Tue, 26 May 2026 22:40:35 -0700 Subject: [PATCH 20/20] add subtopic prompt --- src/constants/app-constants.js | 32 ++++++++++++- src/controllers/rag-llm.js | 71 +++++++++------------------- src/services/calculation-question.js | 10 ++-- 3 files changed, 60 insertions(+), 53 deletions(-) diff --git a/src/constants/app-constants.js b/src/constants/app-constants.js index fbb8d2c..8a5277e 100644 --- a/src/constants/app-constants.js +++ b/src/constants/app-constants.js @@ -2,12 +2,23 @@ * Application-wide default prompt constants */ +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} Bloom's level guidance: - Remember: recall a definition or fact @@ -21,6 +32,8 @@ 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. --- If Question Type is "multiple-choice" --- @@ -87,7 +100,9 @@ RULES (violations cause immediate rejection): + - * / ^ ( ) 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. + 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. @@ -103,6 +118,17 @@ RULES (violations cause immediate rejection): - 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. @@ -119,9 +145,12 @@ PROCEDURE: 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): { @@ -261,6 +290,7 @@ const DEFAULT_BLOOM_TYPE_PREFERENCES = { }; module.exports = { + SUBTOPIC_DETERMINATION_PROMPT, QUESTION_GENERATION_PROMPT, OBJECTIVE_GENERATION_AUTO_PROMPT, OBJECTIVE_GENERATION_MANUAL_PROMPT, diff --git a/src/controllers/rag-llm.js b/src/controllers/rag-llm.js index 882b5f7..5383516 100644 --- a/src/controllers/rag-llm.js +++ b/src/controllers/rag-llm.js @@ -12,29 +12,7 @@ const { getMaterialCourseId } = require('../services/material'); const { isUserInCourse } = require('../services/user-course'); const settingsService = require('../services/settings'); const calculationQuestionService = require('../services/calculation-question'); -const { DEFAULT_PROMPTS, BLOOM_LEVELS, QUESTION_TYPES } = require('../constants/app-constants'); - -// --------------------------------------------------------------------------- -// Temporary debug logger — appends one JSON entry per attempt to llm-debug.json -// in the project root. Remove once rejection patterns are understood. -// --------------------------------------------------------------------------- -const fs = require('fs'); -const path = require('path'); -const DEBUG_LOG_PATH = path.join(__dirname, '../../llm-debug.json'); - -function llmDebugLog(entry) { - try { - let existing = []; - if (fs.existsSync(DEBUG_LOG_PATH)) { - try { existing = JSON.parse(fs.readFileSync(DEBUG_LOG_PATH, 'utf8')); } catch (_) {} - } - if (!Array.isArray(existing)) existing = []; - existing.push({ ts: new Date().toISOString(), ...entry }); - fs.writeFileSync(DEBUG_LOG_PATH, JSON.stringify(existing, null, 2), 'utf8'); - } catch (writeErr) { - console.warn('[llmDebugLog] Could not write debug file:', writeErr.message); - } -} +const { DEFAULT_PROMPTS, BLOOM_LEVELS, QUESTION_TYPES, SUBTOPIC_DETERMINATION_PROMPT } = require('../constants/app-constants'); // Simple error response function function returnErrorResponse(res, error, details = null) { @@ -664,6 +642,21 @@ 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, questionType } = req.body; @@ -741,13 +734,19 @@ const generateQuestionsWithRagHandler = async (req, res) => { // Get LLM instance from service const llmModule = await llmService.getLLMInstance(); + 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); }; @@ -809,14 +808,6 @@ const generateQuestionsWithRagHandler = async (req, res) => { // If we got here, parsing was successful console.log(`✅ Successfully parsed JSON on attempt ${attempt}`); - llmDebugLog({ - event: 'success', - questionType, - bloomLevel, - granularObjective: granularLearningObjectiveText, - attempt, - rawResponse: contentStr, - }); break; } catch (parseError) { @@ -828,15 +819,6 @@ const generateQuestionsWithRagHandler = async (req, res) => { "| snippet (0-400):", JSON.stringify(contentStr.slice(0, 400)) ); - llmDebugLog({ - event: 'rejection', - questionType, - bloomLevel, - granularObjective: granularLearningObjectiveText, - attempt, - rejectionReason: parseError.message, - rawResponse: contentStr, - }); if (attempt < maxRetries) { console.log(`Retrying... (${attempt + 1}/${maxRetries})`); continue; @@ -848,15 +830,6 @@ const generateQuestionsWithRagHandler = async (req, res) => { } catch (error) { lastError = error; console.warn(`❌ LLM call failed on attempt ${attempt}:`, error.message); - llmDebugLog({ - event: 'llm_error', - questionType, - bloomLevel, - granularObjective: granularLearningObjectiveText, - attempt, - rejectionReason: error.message, - rawResponse: null, - }); if (attempt < maxRetries) { console.log(`Retrying... (${attempt + 1}/${maxRetries})`); continue; diff --git a/src/services/calculation-question.js b/src/services/calculation-question.js index b39d0d7..a75fa2c 100644 --- a/src/services/calculation-question.js +++ b/src/services/calculation-question.js @@ -564,17 +564,21 @@ function parseStudentNumericAnswer(text) { 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); - if (Math.abs(expectedValue) < 1e-10) { - return diff <= threshold; + // 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 d = Math.max(0, Math.min(12, parseInt(answerDecimals, 10) || 0)); const a = roundToDecimals(studentValue, d); const b = roundToDecimals(expectedValue, d); const eps = Math.max(10 ** -(d + 2), 1e-12);