github-actions[bot] commited on
Commit
900dd15
·
1 Parent(s): 8b6568e

🚀 Auto-deploy backend from GitHub (74961f4)

Browse files
main.py CHANGED
@@ -103,6 +103,7 @@ from routes.class_records_router import router as class_records_router
103
  from routes.risk_router import router as risk_router
104
  from routes.tutor_checkin import router as tutor_checkin_router
105
  from routes.practice import router as practice_router
 
106
  from routes.ai_monitoring import router as ai_monitoring_router
107
  from routes.class_analytics_routes import router as class_analytics_router
108
  from routes.intervention_routes import router as intervention_router
@@ -1167,6 +1168,7 @@ app.include_router(class_records_router)
1167
  app.include_router(risk_router)
1168
  app.include_router(tutor_checkin_router)
1169
  app.include_router(practice_router)
 
1170
  app.include_router(ai_monitoring_router)
1171
  app.include_router(class_analytics_router)
1172
  app.include_router(intervention_router)
 
103
  from routes.risk_router import router as risk_router
104
  from routes.tutor_checkin import router as tutor_checkin_router
105
  from routes.practice import router as practice_router
106
+ from routes.try_it_yourself import router as try_it_yourself_router
107
  from routes.ai_monitoring import router as ai_monitoring_router
108
  from routes.class_analytics_routes import router as class_analytics_router
109
  from routes.intervention_routes import router as intervention_router
 
1168
  app.include_router(risk_router)
1169
  app.include_router(tutor_checkin_router)
1170
  app.include_router(practice_router)
1171
+ app.include_router(try_it_yourself_router)
1172
  app.include_router(ai_monitoring_router)
1173
  app.include_router(class_analytics_router)
1174
  app.include_router(intervention_router)
routes/quiz_generation_routes.py CHANGED
@@ -139,14 +139,16 @@ Generate a "Try It Yourself" quiz for the following lesson.
139
  ## Instructions
140
  1. Generate EXACTLY {question_count} questions covering the topic above.
141
  2. Question types to use: {qt_str}
142
- 3. DISTRIBUTION (for {question_count} questions):
143
- - Include at least 1 "remember" (recall, definitions, fundamental facts)
144
- - Include at least 1 "understand" (explain concepts)
145
- - Include at least 1 "apply" (real-world context: pesos, jeepney, sari-sari store, barangay)
146
- - Difficulty: {difficulty} appropriate for {grade_level} Filipino STEM students.
 
147
  4. Use Filipino-localized context where possible (pesos, jeepney, barangay, sari-sari store, etc.).
148
  5. Each question must be mathematically accurate and curriculum-aligned.
149
- 6. Provide clear explanations for the correct answer.{variance_instruction}
 
150
 
151
  ## Question Type Rules
152
  - multiple-choice: 4 options as array of objects with "key" and "text" fields, exactly one correct
@@ -171,7 +173,8 @@ Generate a "Try It Yourself" quiz for the following lesson.
171
  "points": {points},
172
  "xp_reward": {xp_reward},
173
  "difficulty": "{difficulty}",
174
- "competency_code": "{competency_code or 'N/A'}"
 
175
  }},
176
  {{
177
  "id": "q2",
@@ -187,7 +190,8 @@ Generate a "Try It Yourself" quiz for the following lesson.
187
  "points": {points},
188
  "xp_reward": {xp_reward},
189
  "difficulty": "{difficulty}",
190
- "competency_code": "{competency_code or 'N/A'}"
 
191
  }},
192
  {{
193
  "id": "q3",
@@ -200,7 +204,8 @@ Generate a "Try It Yourself" quiz for the following lesson.
200
  "points": {points},
201
  "xp_reward": {xp_reward},
202
  "difficulty": "{difficulty}",
203
- "competency_code": "{competency_code or 'N/A'}"
 
204
  }}
205
  ]
206
 
@@ -210,7 +215,9 @@ IMPORTANT:
210
  - correct_answer must be the KEY ("A","B","C","D") that matches the correct option
211
  - For fill-in-blank, correct_answer is the exact text that fills the blank
212
  - Generate FRESH, VARIED questions — no two questions should be identical or nearly identical
213
- - Spread Bloom's taxonomy: include "remember", "understand", and "apply" level questions"""
 
 
214
 
215
 
216
  # ── Response Parser ────────────────────────────────────────────────────
@@ -294,6 +301,7 @@ def _parse_quiz_response(text: str, expected_count: int) -> List[Dict[str, Any]]
294
  "explanation": q.get("explanation", ""),
295
  "points": q.get("points", 1),
296
  "xpReward": q.get("xp_reward", 10),
 
297
  }
298
 
299
  validated.append(normalized)
@@ -395,7 +403,7 @@ async def generate_quiz(request: QuizGenerationRequest):
395
  {"role": "user", "content": prompt},
396
  ],
397
  task_type="quiz_generation",
398
- max_new_tokens=3000,
399
  temperature=0.7, # Higher temp for variance
400
  top_p=0.9,
401
  )
 
139
  ## Instructions
140
  1. Generate EXACTLY {question_count} questions covering the topic above.
141
  2. Question types to use: {qt_str}
142
+ 3. BLOOM'S TAXONOMY DISTRIBUTION (for {question_count} questions):
143
+ - "remember" + "understand" (recall, definitions, explain concepts): ~35% of questions
144
+ - "apply" (solve problems, real-world context: pesos, jeepney, sari-sari store, barangay): ~30% of questions
145
+ - "analyze" (compare, contrast, break down complex problems): ~20% of questions
146
+ - "evaluate" (judge, justify, determine best approach): ~15% of questions
147
+ Tag each question with its bloom_level field (one of: "remember", "understand", "apply", "analyze", "evaluate").
148
  4. Use Filipino-localized context where possible (pesos, jeepney, barangay, sari-sari store, etc.).
149
  5. Each question must be mathematically accurate and curriculum-aligned.
150
+ 6. Provide clear explanations for the correct answer.
151
+ 7. Questions at higher Bloom's levels should be genuinely harder — not just rephrased recall.{variance_instruction}
152
 
153
  ## Question Type Rules
154
  - multiple-choice: 4 options as array of objects with "key" and "text" fields, exactly one correct
 
173
  "points": {points},
174
  "xp_reward": {xp_reward},
175
  "difficulty": "{difficulty}",
176
+ "competency_code": "{competency_code or 'N/A'}",
177
+ "hints": ["Conceptual nudge: Recall the power rule for derivatives.", "Procedural setup: Identify the exponent and apply nxⁿ⁻¹.", "Final push: Compute 3x² from x³."]
178
  }},
179
  {{
180
  "id": "q2",
 
190
  "points": {points},
191
  "xp_reward": {xp_reward},
192
  "difficulty": "{difficulty}",
193
+ "competency_code": "{competency_code or 'N/A'}",
194
+ "hints": ["Conceptual nudge: Think about the interior angle sum property.", "Procedural setup: Consider what theorem governs triangle angles.", "Final push: Recall the triangle angle sum theorem states 180°."]
195
  }},
196
  {{
197
  "id": "q3",
 
204
  "points": {points},
205
  "xp_reward": {xp_reward},
206
  "difficulty": "{difficulty}",
207
+ "competency_code": "{competency_code or 'N/A'}",
208
+ "hints": ["Conceptual nudge: This is a substitution problem.", "Procedural setup: Replace x with 4 in the expression.", "Final push: Multiply 2×4, then add 3."]
209
  }}
210
  ]
211
 
 
215
  - correct_answer must be the KEY ("A","B","C","D") that matches the correct option
216
  - For fill-in-blank, correct_answer is the exact text that fills the blank
217
  - Generate FRESH, VARIED questions — no two questions should be identical or nearly identical
218
+ - Spread Bloom's taxonomy: include "remember", "understand", "apply", "analyze", and "evaluate" level questions
219
+ - bloom_level MUST be one of: "remember", "understand", "apply", "analyze", "evaluate"
220
+ """
221
 
222
 
223
  # ── Response Parser ────────────────────────────────────────────────────
 
301
  "explanation": q.get("explanation", ""),
302
  "points": q.get("points", 1),
303
  "xpReward": q.get("xp_reward", 10),
304
+ "hints": q.get("hints", []),
305
  }
306
 
307
  validated.append(normalized)
 
403
  {"role": "user", "content": prompt},
404
  ],
405
  task_type="quiz_generation",
406
+ max_new_tokens=6000,
407
  temperature=0.7, # Higher temp for variance
408
  top_p=0.9,
409
  )
routes/try_it_yourself.py ADDED
@@ -0,0 +1,453 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MathPulse AI - Try It Yourself Quiz State Management
3
+ POST /api/try-it-yourself/resolve-question - Report question resolution (correct/revealed)
4
+ POST /api/try-it-yourself/use-hint - Record hint usage, return next hint tier
5
+ POST /api/try-it-yourself/complete-session - Finalize session, calculate server-side XP
6
+
7
+ Implements:
8
+ - user_question_states Firestore collection (per-user, per-question progress)
9
+ - Server-side XP decay based on hints_used and attempts
10
+ - Struggle flag for shadow retry injection
11
+ - Brute Force Floor XP (never 0 for correct answers)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from datetime import datetime, timezone
18
+ from typing import Any, Dict, List, Literal, Optional
19
+
20
+ from fastapi import APIRouter, HTTPException, Request
21
+ from pydantic import BaseModel, Field
22
+
23
+ import firebase_admin
24
+ from firebase_admin import firestore as fs
25
+
26
+ logger = logging.getLogger("mathpulse.try_it_yourself")
27
+ router = APIRouter(prefix="/api/try-it-yourself", tags=["try-it-yourself"])
28
+
29
+
30
+ # --- XP Decay Constants ---
31
+ BASE_XP_PER_QUESTION = 10
32
+ HINT_DECAY = {0: 1.0, 1: 0.7, 2: 0.4, 3: 0.2} # multiplier per hints_used
33
+ BRUTE_FORCE_FLOOR_XP = 2 # minimum XP for correct answer (never 0)
34
+ REVEAL_XP = 0 # complete forfeit
35
+ STRUGGLE_THRESHOLD = 3 # attempts before flagging struggle
36
+
37
+
38
+ # --- Request/Response Models ---
39
+
40
+ class ResolveQuestionRequest(BaseModel):
41
+ userId: str
42
+ sessionId: str
43
+ questionId: str
44
+ resolution: Literal["correct", "revealed"]
45
+ attempts: int = Field(ge=1, description="Total attempts on this question")
46
+ hintsUsed: int = Field(ge=0, le=3, description="Number of hints used (0-3)")
47
+
48
+
49
+ class ResolveQuestionResponse(BaseModel):
50
+ xpAwarded: int
51
+ status: str # New | Retry | Learning | Mastered
52
+ struggleFlag: bool
53
+
54
+
55
+ class UseHintRequest(BaseModel):
56
+ userId: str
57
+ sessionId: str
58
+ questionId: str
59
+ currentHintTier: int = Field(ge=0, le=2, description="0-indexed hint tier being requested")
60
+
61
+
62
+ class UseHintResponse(BaseModel):
63
+ hintsUsed: int
64
+ xpMultiplier: float
65
+ acknowledged: bool
66
+
67
+
68
+ class CompleteSessionRequest(BaseModel):
69
+ userId: str
70
+ sessionId: str
71
+ questionResults: List[Dict[str, Any]] # [{questionId, resolution, attempts, hintsUsed, xpAwarded}]
72
+
73
+
74
+ class CompleteSessionResponse(BaseModel):
75
+ totalXP: int
76
+ questionsResolved: int
77
+ questionsRevealed: int
78
+ averageAttempts: float
79
+ struggleTopics: List[str]
80
+
81
+
82
+ # --- Helpers ---
83
+
84
+ def _get_firestore():
85
+ if firebase_admin._apps:
86
+ return firebase_admin.firestore.client()
87
+ return None
88
+
89
+
90
+ def _calculate_xp(hints_used: int, attempts: int, resolution: str) -> int:
91
+ """Calculate XP for a resolved question using decay model."""
92
+ if resolution == "revealed":
93
+ return REVEAL_XP
94
+
95
+ # Base XP with hint decay
96
+ multiplier = HINT_DECAY.get(min(hints_used, 3), 0.2)
97
+ xp = int(BASE_XP_PER_QUESTION * multiplier)
98
+
99
+ # Brute force floor: never 0 for correct answers
100
+ return max(xp, BRUTE_FORCE_FLOOR_XP)
101
+
102
+
103
+ def _determine_status(attempts: int, hints_used: int, resolution: str, previous_status: str = "New") -> str:
104
+ """
105
+ Determine question mastery status based on performance AND previous status.
106
+ Follows Bloom's taxonomy phase progression:
107
+ New → Learning (correct in Phase 1)
108
+ Learning → Mastered (correct in Phase 2+)
109
+ Any → Retry (revealed or excessive attempts)
110
+ Retry → Learning (correct on retry)
111
+ """
112
+ if resolution == "revealed":
113
+ return "Retry"
114
+
115
+ # Correct answer — promote based on previous status
116
+ if previous_status == "New":
117
+ if hints_used == 0 and attempts == 1:
118
+ return "Learning" # Clean pass → skip to Learning
119
+ return "Learning" # Any correct from New → Learning
120
+ elif previous_status == "Retry":
121
+ return "Learning" # Correct retry → back to Learning
122
+ elif previous_status == "Learning":
123
+ if hints_used <= 1 and attempts <= 2:
124
+ return "Mastered" # Clean-ish pass → Mastered
125
+ return "Learning" # Struggled but correct → stay Learning
126
+ elif previous_status == "Mastered":
127
+ return "Mastered" # Already mastered, stays mastered
128
+
129
+ return "Learning"
130
+
131
+
132
+ def _authenticate(request: Request, userId: str) -> None:
133
+ user = getattr(request.state, "user", None)
134
+ if not user:
135
+ raise HTTPException(status_code=401, detail="Authentication required")
136
+ uid = getattr(user, "uid", None)
137
+ if uid != userId:
138
+ raise HTTPException(status_code=403, detail="Not authorized for this user")
139
+
140
+
141
+ # --- Endpoints ---
142
+
143
+ @router.post("/resolve-question", response_model=ResolveQuestionResponse)
144
+ async def resolve_question(request: Request, body: ResolveQuestionRequest):
145
+ """
146
+ Called when a question is formally resolved (answered correctly or revealed).
147
+ Calculates server-side XP, updates user_question_states in Firestore.
148
+ """
149
+ _authenticate(request, body.userId)
150
+
151
+ xp = _calculate_xp(body.hintsUsed, body.attempts, body.resolution)
152
+ struggle_flag = body.attempts >= STRUGGLE_THRESHOLD
153
+
154
+ # Fetch previous status for proper state transition
155
+ previous_status = "New"
156
+ db = _get_firestore()
157
+ if db:
158
+ try:
159
+ state_ref = db.collection("user_question_states").document(f"{body.userId}_{body.questionId}")
160
+ existing = state_ref.get()
161
+ if existing.exists:
162
+ previous_status = existing.to_dict().get("status", "New")
163
+ except Exception as e:
164
+ logger.warning("Firestore read failed for previous status: %s", e)
165
+
166
+ status = _determine_status(body.attempts, body.hintsUsed, body.resolution, previous_status)
167
+
168
+ if db:
169
+ try:
170
+ state_ref = db.collection("user_question_states").document(f"{body.userId}_{body.questionId}")
171
+ state_ref.set({
172
+ "user_id": body.userId,
173
+ "question_id": body.questionId,
174
+ "session_id": body.sessionId,
175
+ "status": status,
176
+ "attempts": body.attempts,
177
+ "hints_used": body.hintsUsed,
178
+ "struggle_flag": struggle_flag,
179
+ "resolution": body.resolution,
180
+ "xp_awarded": xp,
181
+ "resolved_at": datetime.now(timezone.utc).isoformat(),
182
+ }, merge=True)
183
+ except Exception as e:
184
+ logger.warning("Firestore write failed for question state: %s", e)
185
+
186
+ return ResolveQuestionResponse(
187
+ xpAwarded=xp,
188
+ status=status,
189
+ struggleFlag=struggle_flag,
190
+ )
191
+
192
+
193
+ @router.post("/use-hint", response_model=UseHintResponse)
194
+ async def use_hint(request: Request, body: UseHintRequest):
195
+ """
196
+ Record hint usage for a question. Returns updated hint count and XP multiplier.
197
+ Frontend uses this to track decay — backend is the source of truth.
198
+ """
199
+ _authenticate(request, body.userId)
200
+
201
+ new_hints_used = body.currentHintTier + 1
202
+ multiplier = HINT_DECAY.get(min(new_hints_used, 3), 0.2)
203
+
204
+ db = _get_firestore()
205
+ if db:
206
+ try:
207
+ state_ref = db.collection("user_question_states").document(f"{body.userId}_{body.questionId}")
208
+ state_ref.set({
209
+ "user_id": body.userId,
210
+ "question_id": body.questionId,
211
+ "session_id": body.sessionId,
212
+ "hints_used": new_hints_used,
213
+ "last_hint_at": datetime.now(timezone.utc).isoformat(),
214
+ }, merge=True)
215
+ except Exception as e:
216
+ logger.warning("Firestore write failed for hint usage: %s", e)
217
+
218
+ return UseHintResponse(
219
+ hintsUsed=new_hints_used,
220
+ xpMultiplier=multiplier,
221
+ acknowledged=True,
222
+ )
223
+
224
+
225
+ @router.post("/complete-session", response_model=CompleteSessionResponse)
226
+ async def complete_session(request: Request, body: CompleteSessionRequest):
227
+ """
228
+ Finalize a Try It Yourself session. Calculates total XP server-side,
229
+ identifies struggle topics, and updates user profile.
230
+ """
231
+ _authenticate(request, body.userId)
232
+
233
+ total_xp = 0
234
+ questions_resolved = 0
235
+ questions_revealed = 0
236
+ total_attempts = 0
237
+ struggle_topics: List[str] = []
238
+
239
+ for result in body.questionResults:
240
+ resolution = result.get("resolution", "correct")
241
+ hints_used = result.get("hintsUsed", 0)
242
+ attempts = result.get("attempts", 1)
243
+
244
+ # Server-side XP recalculation (never trust frontend)
245
+ xp = _calculate_xp(hints_used, attempts, resolution)
246
+ total_xp += xp
247
+ total_attempts += attempts
248
+
249
+ if resolution == "correct":
250
+ questions_resolved += 1
251
+ else:
252
+ questions_revealed += 1
253
+
254
+ if attempts >= STRUGGLE_THRESHOLD:
255
+ topic = result.get("topic", "Unknown")
256
+ if topic not in struggle_topics:
257
+ struggle_topics.append(topic)
258
+
259
+ avg_attempts = total_attempts / max(len(body.questionResults), 1)
260
+
261
+ # Update user XP in Firestore
262
+ db = _get_firestore()
263
+ if db and total_xp > 0:
264
+ try:
265
+ user_ref = db.collection("users").document(body.userId)
266
+ user_ref.update({
267
+ "totalXP": fs.Increment(total_xp),
268
+ "currentXP": fs.Increment(total_xp),
269
+ })
270
+ except Exception as e:
271
+ logger.warning("User XP update failed: %s", e)
272
+
273
+ return CompleteSessionResponse(
274
+ totalXP=total_xp,
275
+ questionsResolved=questions_resolved,
276
+ questionsRevealed=questions_revealed,
277
+ averageAttempts=round(avg_attempts, 1),
278
+ struggleTopics=struggle_topics,
279
+ )
280
+
281
+ class ShadowRetryRequest(BaseModel):
282
+ userId: str
283
+ sessionId: str
284
+ struggleTopics: List[str] = Field(..., description="Topics the student struggled with in previous phase")
285
+ subject: str
286
+ difficulty: str = "medium"
287
+ count: int = Field(default=3, ge=1, le=5)
288
+
289
+
290
+ class ShadowRetryResponse(BaseModel):
291
+ variants: List[Dict[str, Any]]
292
+ generated: bool
293
+
294
+
295
+ # --- Generate Round Models ---
296
+
297
+ class GenerateRoundRequest(BaseModel):
298
+ userId: str
299
+ sessionId: str
300
+ questionIds: List[str] = Field(..., description="All question IDs available for this quiz")
301
+
302
+
303
+ class PhaseQuestions(BaseModel):
304
+ phase: int
305
+ label: str # "Foundation", "Application", "Complexity", "Gauntlet"
306
+ questionIds: List[str]
307
+
308
+
309
+ class GenerateRoundResponse(BaseModel):
310
+ phases: List[PhaseQuestions]
311
+ questionStatuses: Dict[str, str] # questionId -> status (New/Retry/Learning/Mastered)
312
+
313
+
314
+ @router.post("/generate-round", response_model=GenerateRoundResponse)
315
+ async def generate_round(request: Request, body: GenerateRoundRequest):
316
+ """
317
+ Queries user_question_states for all provided question IDs and groups them
318
+ into Bloom's taxonomy phases based on their mastery status:
319
+ Phase 1 (Foundation): New + Retry questions (Remembering & Understanding)
320
+ Phase 2 (Application): Learning questions (Applying)
321
+ Phase 3 (Complexity): Learning questions via spaced repetition
322
+ Phase 4 (Gauntlet): Mastered questions (Analyzing)
323
+
324
+ Returns phase-grouped question IDs so the frontend can build the quiz flow.
325
+ """
326
+ _authenticate(request, body.userId)
327
+
328
+ # Fetch all user_question_states for these questions
329
+ statuses: Dict[str, str] = {}
330
+ db = _get_firestore()
331
+
332
+ if db and body.questionIds:
333
+ try:
334
+ # Batch fetch all question states in one round-trip
335
+ doc_refs = [db.collection("user_question_states").document(f"{body.userId}_{qid}") for qid in body.questionIds]
336
+ docs = db.get_all(doc_refs)
337
+ for qid, doc in zip(body.questionIds, docs):
338
+ if doc.exists:
339
+ statuses[qid] = doc.to_dict().get("status", "New")
340
+ else:
341
+ statuses[qid] = "New"
342
+ except Exception as e:
343
+ logger.warning("Firestore batch read failed: %s", e)
344
+ # Default all to New if read fails
345
+ for qid in body.questionIds:
346
+ statuses[qid] = "New"
347
+ else:
348
+ for qid in body.questionIds:
349
+ statuses[qid] = "New"
350
+
351
+ # Group questions by status
352
+ new_questions = [qid for qid, s in statuses.items() if s == "New"]
353
+ retry_questions = [qid for qid, s in statuses.items() if s == "Retry"]
354
+ learning_questions = [qid for qid, s in statuses.items() if s == "Learning"]
355
+ mastered_questions = [qid for qid, s in statuses.items() if s == "Mastered"]
356
+
357
+ # Build phases per spec v7
358
+ phases: List[PhaseQuestions] = []
359
+
360
+ # Phase 1 (Foundation): New + Retry
361
+ phase1_ids = new_questions + retry_questions
362
+ if phase1_ids:
363
+ phases.append(PhaseQuestions(phase=1, label="Foundation", questionIds=phase1_ids))
364
+
365
+ # Phase 2 (Application): Learning
366
+ if learning_questions:
367
+ phases.append(PhaseQuestions(phase=2, label="Application", questionIds=learning_questions))
368
+
369
+ # Phase 3 (Complexity): Learning spaced repetition (re-include learning if enough)
370
+ # Only activate if there are learning questions AND we already have a Phase 1
371
+ if learning_questions and phase1_ids:
372
+ phases.append(PhaseQuestions(phase=3, label="Complexity", questionIds=learning_questions))
373
+
374
+ # Phase 4 (Gauntlet): Mastered questions
375
+ if mastered_questions:
376
+ phases.append(PhaseQuestions(phase=4, label="Gauntlet", questionIds=mastered_questions))
377
+
378
+ # If no phases were built (all questions are new), put everything in Phase 1
379
+ if not phases:
380
+ phases.append(PhaseQuestions(phase=1, label="Foundation", questionIds=body.questionIds))
381
+
382
+ return GenerateRoundResponse(phases=phases, questionStatuses=statuses)
383
+
384
+
385
+ @router.post("/shadow-retry", response_model=ShadowRetryResponse)
386
+ async def generate_shadow_retries(request: Request, body: ShadowRetryRequest):
387
+ """
388
+ Generate variant questions for topics the student struggled with.
389
+ Called between phases during the round summary screen.
390
+ Returns lightweight variant questions targeting weak areas.
391
+ """
392
+ _authenticate(request, body.userId)
393
+
394
+ # Import quiz generation utilities
395
+ try:
396
+ from routes.quiz_generation_routes import _get_inference_client, _parse_quiz_response
397
+ from services.inference_client import InferenceRequest
398
+ from rag.curriculum_rag import retrieve_curriculum_context
399
+ except ImportError as e:
400
+ logger.warning("Shadow retry generation unavailable: %s", e)
401
+ return ShadowRetryResponse(variants=[], generated=False)
402
+
403
+ if not body.struggleTopics:
404
+ return ShadowRetryResponse(variants=[], generated=False)
405
+
406
+ # Build a focused prompt for variant generation
407
+ topics_str = ", ".join(body.struggleTopics[:3]) # Max 3 topics
408
+
409
+ try:
410
+ # Retrieve curriculum context for the struggle topics
411
+ chunks = retrieve_curriculum_context(
412
+ query=topics_str,
413
+ subject=body.subject,
414
+ top_k=4,
415
+ )
416
+ context = "\n".join(chunk.get("document", "") for chunk in chunks[:4]) if chunks else topics_str
417
+
418
+ prompt = f"""Generate {body.count} VARIANT math questions for topics the student struggled with.
419
+ Topics: {topics_str}
420
+ Subject: {body.subject}
421
+ Difficulty: {body.difficulty}
422
+
423
+ These are RETRY questions — test the SAME concepts but with different numbers, scenarios, or phrasing.
424
+ Include 3 progressive hints per question.
425
+
426
+ Context: {context[:2000]}
427
+
428
+ Return JSON array:
429
+ [{{"id": "v1", "question_text": "...", "type": "multiple_choice", "bloom_level": "understand",
430
+ "options": [{{"key": "A", "text": "..."}}, ...], "correct_answer": "A",
431
+ "explanation": "...", "hints": ["hint1", "hint2", "hint3"],
432
+ "points": 1, "xp_reward": 10, "difficulty": "{body.difficulty}", "competency_code": "N/A"}}]
433
+
434
+ Return ONLY valid JSON array."""
435
+
436
+ inference_request = InferenceRequest(
437
+ messages=[
438
+ {"role": "system", "content": "You are a math question generator creating retry variants for struggling students."},
439
+ {"role": "user", "content": prompt},
440
+ ],
441
+ task_type="quiz_generation",
442
+ max_new_tokens=2000,
443
+ temperature=0.8,
444
+ )
445
+
446
+ raw = _get_inference_client().generate_from_messages(inference_request)
447
+ variants = _parse_quiz_response(raw, body.count)
448
+
449
+ return ShadowRetryResponse(variants=variants, generated=True)
450
+
451
+ except Exception as e:
452
+ logger.warning("Shadow retry generation failed: %s", e)
453
+ return ShadowRetryResponse(variants=[], generated=False)