github-actions[bot] commited on
Commit
4a9be6e
ยท
1 Parent(s): f524bd9

๐Ÿš€ Auto-deploy backend from GitHub (052328b)

Browse files
Files changed (2) hide show
  1. main.py +3 -3
  2. routes/quiz_generation_routes.py +344 -0
main.py CHANGED
@@ -326,9 +326,9 @@ ROLE_POLICIES: Dict[str, Set[str]] = {
326
  "/api/upload/course-materials": TEACHER_OR_ADMIN,
327
  "/api/upload/course-materials/recent": TEACHER_OR_ADMIN,
328
  "/api/course-materials/topics": TEACHER_OR_ADMIN,
329
- "/api/quiz/generate": TEACHER_OR_ADMIN,
330
- "/api/quiz/generate-async": TEACHER_OR_ADMIN,
331
- "/api/quiz/preview": TEACHER_OR_ADMIN,
332
  "/api/lesson/generate": TEACHER_OR_ADMIN,
333
  "/api/lesson/generate-async": TEACHER_OR_ADMIN,
334
  "/api/rag/lesson": ALL_APP_ROLES,
 
326
  "/api/upload/course-materials": TEACHER_OR_ADMIN,
327
  "/api/upload/course-materials/recent": TEACHER_OR_ADMIN,
328
  "/api/course-materials/topics": TEACHER_OR_ADMIN,
329
+ "/api/quiz/generate": ALL_APP_ROLES,
330
+ "/api/quiz/generate-async": ALL_APP_ROLES,
331
+ "/api/quiz/preview": ALL_APP_ROLES,
332
  "/api/lesson/generate": TEACHER_OR_ADMIN,
333
  "/api/lesson/generate-async": TEACHER_OR_ADMIN,
334
  "/api/rag/lesson": ALL_APP_ROLES,
routes/quiz_generation_routes.py ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unified Quiz Generation Routes.
3
+
4
+ Generates dynamic quiz questions using DeepSeek AI + RAG curriculum context.
5
+ Used by: lesson practice quizzes, module quizzes, and quiz battle.
6
+
7
+ When new PDFs are ingested into the vectorstore, this endpoint automatically
8
+ picks up the new content via RAG retrieval.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import random
16
+ import re
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ from fastapi import APIRouter, HTTPException, Request
20
+ from pydantic import BaseModel, Field
21
+
22
+ from rag.curriculum_rag import (
23
+ retrieve_curriculum_context,
24
+ summarize_retrieval_confidence,
25
+ )
26
+ from services.inference_client import (
27
+ InferenceRequest,
28
+ create_default_client,
29
+ get_model_for_task,
30
+ )
31
+
32
+ logger = logging.getLogger("mathpulse.quiz_generation")
33
+ router = APIRouter(prefix="/api/quiz", tags=["quiz-generation"])
34
+
35
+ _inference_client = None
36
+
37
+
38
+ def _get_inference_client():
39
+ global _inference_client
40
+ if _inference_client is None:
41
+ _inference_client = create_default_client()
42
+ return _inference_client
43
+
44
+
45
+ # โ”€โ”€ Request/Response Models โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
46
+
47
+ class QuizGenerationRequest(BaseModel):
48
+ topic: str = Field(..., min_length=1, description="Lesson topic or competency")
49
+ subject: str = Field(..., min_length=1, description="Subject name (e.g., 'General Mathematics')")
50
+ lessonTitle: Optional[str] = Field(default=None, description="Full lesson title")
51
+ questionCount: int = Field(default=6, ge=1, le=20, description="Number of questions to generate")
52
+ questionTypes: List[str] = Field(
53
+ default=["multiple-choice", "true-false", "fill-in-blank"],
54
+ description="Question types to include",
55
+ )
56
+ difficulty: str = Field(default="medium", pattern="^(easy|medium|hard)$")
57
+ quarter: Optional[int] = Field(default=1, ge=1, le=4)
58
+ moduleId: Optional[str] = Field(default=None)
59
+ lessonId: Optional[str] = Field(default=None)
60
+ competencyCode: Optional[str] = Field(default=None)
61
+ storagePath: Optional[str] = Field(default=None)
62
+ userId: Optional[str] = Field(default=None)
63
+
64
+
65
+ class QuizQuestion(BaseModel):
66
+ id: int
67
+ type: str
68
+ question: str
69
+ options: Optional[List[str]] = None
70
+ correctAnswer: str
71
+ explanation: str
72
+
73
+
74
+ class QuizGenerationResponse(BaseModel):
75
+ questions: List[QuizQuestion]
76
+ retrievalConfidence: Dict[str, Any]
77
+ sourceChunks: int
78
+ generatedAt: str
79
+
80
+
81
+ # โ”€โ”€ Prompt Builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
82
+
83
+ def _build_quiz_generation_prompt(
84
+ topic: str,
85
+ subject: str,
86
+ lesson_title: Optional[str],
87
+ question_count: int,
88
+ question_types: List[str],
89
+ difficulty: str,
90
+ retrieved_context: str,
91
+ ) -> str:
92
+ """Build the DeepSeek prompt for quiz generation."""
93
+
94
+ type_instructions = []
95
+ if "multiple-choice" in question_types:
96
+ type_instructions.append(
97
+ '- multiple-choice: 4 options (A/B/C/D format), exactly one correct answer'
98
+ )
99
+ if "true-false" in question_types:
100
+ type_instructions.append(
101
+ '- true-false: statement that is either True or False'
102
+ )
103
+ if "fill-in-blank" in question_types:
104
+ type_instructions.append(
105
+ '- fill-in-blank: question with a single numeric or short text answer'
106
+ )
107
+
108
+ return f"""You are a DepEd-aligned mathematics quiz generator for Filipino Senior High School students (Grades 11-12).
109
+
110
+ Given the following curriculum context about "{topic}" from {subject}, generate {question_count} {difficulty}-difficulty quiz questions.
111
+
112
+ ## Retrieved Curriculum Context
113
+ {retrieved_context}
114
+
115
+ ## Instructions
116
+ 1. Generate exactly {question_count} questions covering the topic above.
117
+ 2. Question types to use: {', '.join(question_types)}
118
+ 3. Distribution: roughly 50% multiple-choice, 30% true-false, 20% fill-in-blank (adjust for count).
119
+ 4. Difficulty: {difficulty} โ€” appropriate for Grade 11-12 Filipino STEM students.
120
+ 5. Use Filipino-localized context where possible (pesos, jeepney, barangay, sari-sari store, etc.).
121
+ 6. Each question must be mathematically accurate and curriculum-aligned.
122
+ 7. Provide clear explanations for the correct answer.
123
+
124
+ ## Question Type Rules
125
+ {chr(10).join(type_instructions)}
126
+
127
+ ## Output Format
128
+ Return ONLY a valid JSON array. No markdown, no extra text. Format:
129
+ [
130
+ {{
131
+ "type": "multiple-choice",
132
+ "question": "What is the derivative of f(x) = xยณ?",
133
+ "options": ["2xยฒ", "3xยฒ", "xยฒ", "3x"],
134
+ "correctAnswer": "3xยฒ",
135
+ "explanation": "Using the power rule: d/dx(xโฟ) = nxโฟโปยน. So d/dx(xยณ) = 3xยฒ."
136
+ }},
137
+ {{
138
+ "type": "true-false",
139
+ "question": "The sum of angles in a triangle is 180 degrees.",
140
+ "options": ["True", "False"],
141
+ "correctAnswer": "True",
142
+ "explanation": "By the triangle angle sum theorem, the interior angles of any Euclidean triangle sum to 180ยฐ."
143
+ }},
144
+ {{
145
+ "type": "fill-in-blank",
146
+ "question": "If f(x) = 2x + 3, then f(4) = ___",
147
+ "options": null,
148
+ "correctAnswer": "11",
149
+ "explanation": "Substitute x = 4: f(4) = 2(4) + 3 = 8 + 3 = 11."
150
+ }}
151
+ ]
152
+
153
+ IMPORTANT:
154
+ - Return ONLY the JSON array, no other text
155
+ - Ensure correctAnswer exactly matches one of the options (for MC/TF)
156
+ - For fill-in-blank, correctAnswer is the exact text that fills the blank
157
+ - Make questions feel fresh and varied โ€” do not copy verbatim from the context"""
158
+
159
+
160
+ # โ”€โ”€ Response Parser โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
161
+
162
+ def _parse_quiz_response(text: str, expected_count: int) -> List[Dict[str, Any]]:
163
+ """Parse and validate DeepSeek quiz generation response."""
164
+ cleaned = text.strip()
165
+
166
+ # Strip markdown fences
167
+ cleaned = re.sub(r"^```json\s*", "", cleaned, flags=re.IGNORECASE)
168
+ cleaned = re.sub(r"^```\s*", "", cleaned)
169
+ cleaned = re.sub(r"\s*```$", "", cleaned)
170
+ cleaned = cleaned.strip()
171
+
172
+ try:
173
+ questions = json.loads(cleaned)
174
+ except json.JSONDecodeError as e:
175
+ logger.error(f"Failed to parse quiz response as JSON: {e}")
176
+ # Try to extract JSON array from text
177
+ match = re.search(r"\[.*\]", cleaned, re.DOTALL)
178
+ if match:
179
+ try:
180
+ questions = json.loads(match.group())
181
+ except json.JSONDecodeError:
182
+ raise ValueError(f"Invalid JSON in quiz response: {e}")
183
+ else:
184
+ raise ValueError(f"No JSON array found in quiz response")
185
+
186
+ if not isinstance(questions, list):
187
+ raise ValueError("Quiz response is not a JSON array")
188
+
189
+ validated = []
190
+ for i, q in enumerate(questions):
191
+ if not isinstance(q, dict):
192
+ continue
193
+
194
+ # Ensure required fields
195
+ if "question" not in q or "correctAnswer" not in q:
196
+ continue
197
+
198
+ # Normalize field names
199
+ normalized = {
200
+ "id": i + 1,
201
+ "type": q.get("type", "multiple-choice"),
202
+ "question": q["question"],
203
+ "correctAnswer": q["correctAnswer"],
204
+ "explanation": q.get("explanation", ""),
205
+ }
206
+
207
+ # Handle options
208
+ if "options" in q and q["options"]:
209
+ normalized["options"] = q["options"]
210
+ elif "choices" in q and q["choices"]:
211
+ normalized["options"] = q["choices"]
212
+ else:
213
+ # For true-false, auto-populate options
214
+ if normalized["type"] == "true-false":
215
+ normalized["options"] = ["True", "False"]
216
+ else:
217
+ normalized["options"] = None
218
+
219
+ validated.append(normalized)
220
+
221
+ if len(validated) < min(expected_count, 3):
222
+ raise ValueError(f"Only {len(validated)} valid questions parsed, expected at least {min(expected_count, 3)}")
223
+
224
+ return validated[:expected_count]
225
+
226
+
227
+ # โ”€โ”€ Variance Application โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
228
+
229
+ def _apply_variance(questions: List[Dict[str, Any]], seed: int) -> List[Dict[str, Any]]:
230
+ """Apply deterministic variance to questions (shuffle choices, etc.)."""
231
+ rng = random.Random(seed)
232
+
233
+ for q in questions:
234
+ # Shuffle multiple-choice options while tracking correct answer
235
+ if q.get("type") == "multiple-choice" and q.get("options"):
236
+ options = q["options"].copy()
237
+ correct = q["correctAnswer"]
238
+
239
+ # Only shuffle if correct answer is in options
240
+ if correct in options:
241
+ rng.shuffle(options)
242
+ q["options"] = options
243
+ q["correctAnswer"] = correct # Keep original correct answer text
244
+
245
+ return questions
246
+
247
+
248
+ # โ”€โ”€ Endpoints โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
249
+
250
+ @router.post("/generate", response_model=QuizGenerationResponse)
251
+ async def generate_quiz(request: QuizGenerationRequest):
252
+ """
253
+ Generate a dynamic quiz using DeepSeek AI + RAG curriculum context.
254
+
255
+ This endpoint retrieves relevant curriculum chunks from the vectorstore,
256
+ then calls DeepSeek to generate varied quiz questions based on that context.
257
+ When new PDFs are ingested, they automatically become available via RAG.
258
+ """
259
+ try:
260
+ # 1. Retrieve curriculum context via RAG
261
+ query = request.lessonTitle or request.topic
262
+ chunks = retrieve_curriculum_context(
263
+ query=query,
264
+ subject=request.subject,
265
+ quarter=request.quarter,
266
+ module_id=request.moduleId,
267
+ lesson_id=request.lessonId,
268
+ competency_code=request.competencyCode,
269
+ storage_path=request.storagePath,
270
+ top_k=8,
271
+ )
272
+
273
+ if not chunks:
274
+ logger.warning(f"No curriculum chunks found for topic '{request.topic}' in subject '{request.subject}'")
275
+ raise HTTPException(
276
+ status_code=404,
277
+ detail=f"No curriculum content found for topic '{request.topic}'. Please ensure PDFs are ingested.",
278
+ )
279
+
280
+ # Format retrieved chunks for the prompt
281
+ formatted_context = "\n\n---\n\n".join(
282
+ f"[Source: {chunk.get('metadata', {}).get('source_file', 'Unknown')}, Page {chunk.get('metadata', {}).get('page', 'N/A')}]\n{chunk.get('document', '')}"
283
+ for chunk in chunks
284
+ )
285
+
286
+ confidence = summarize_retrieval_confidence(chunks)
287
+
288
+ # 2. Build generation prompt
289
+ prompt = _build_quiz_generation_prompt(
290
+ topic=request.topic,
291
+ subject=request.subject,
292
+ lesson_title=request.lessonTitle,
293
+ question_count=request.questionCount,
294
+ question_types=request.questionTypes,
295
+ difficulty=request.difficulty,
296
+ retrieved_context=formatted_context,
297
+ )
298
+
299
+ # 3. Call DeepSeek
300
+ inference_request = InferenceRequest(
301
+ messages=[
302
+ {"role": "system", "content": "You are a precise DepEd-aligned curriculum quiz generator."},
303
+ {"role": "user", "content": prompt},
304
+ ],
305
+ task_type="quiz_generation",
306
+ max_new_tokens=2000,
307
+ temperature=0.4,
308
+ top_p=0.9,
309
+ )
310
+
311
+ raw_response = _get_inference_client().generate_from_messages(inference_request)
312
+
313
+ # 4. Parse response
314
+ questions = _parse_quiz_response(raw_response, request.questionCount)
315
+
316
+ # 5. Apply variance
317
+ seed = hash(f"{request.topic}:{request.subject}:{request.lessonTitle or ''}") % (2**32)
318
+ varied_questions = _apply_variance(questions, seed)
319
+
320
+ # 6. Build response
321
+ return QuizGenerationResponse(
322
+ questions=[QuizQuestion(**q) for q in varied_questions],
323
+ retrievalConfidence=confidence,
324
+ sourceChunks=len(chunks),
325
+ generatedAt=__import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat(),
326
+ )
327
+
328
+ except HTTPException:
329
+ raise
330
+ except Exception as e:
331
+ logger.exception("Quiz generation failed")
332
+ raise HTTPException(status_code=500, detail=f"Quiz generation failed: {str(e)}")
333
+
334
+
335
+ @router.get("/health")
336
+ async def quiz_generation_health():
337
+ """Check quiz generation service health."""
338
+ model = get_model_for_task("quiz_generation")
339
+ return {
340
+ "status": "ok",
341
+ "activeModel": model,
342
+ "endpoint": "/api/quiz/generate",
343
+ "features": ["rag-retrieval", "deepseek-generation", "choice-shuffling", "auto-pdf-updates"],
344
+ }