github-actions[bot] commited on
Commit
65ba59e
·
1 Parent(s): 56f4e10

🚀 Auto-deploy backend from GitHub (c55d5fa)

Browse files
FIRESTORE_SCHEMA.md ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MathPulse AI — Firestore Schema (Practice Center)
2
+
3
+ ## Collection: practice_sessions
4
+
5
+ ### Document: practice_sessions/{session_id}
6
+
7
+ Purpose: stores the generated question set for a practice session.
8
+
9
+ Ownership: user-owned via `userId`.
10
+
11
+ Written by: backend `/api/practice/generate` endpoint.
12
+
13
+ Read by: backend `/api/practice/submit` endpoint.
14
+
15
+ Retention: kept for debugging and audit, can be cleaned up after result is stored.
16
+
17
+ | Field | Type | Description |
18
+ |-------|------|-------------|
19
+ | session_id | string | UUID |
20
+ | userId | string | Firebase UID |
21
+ | subject | string | Subject name |
22
+ | competency | string | Competency/topic |
23
+ | difficulty | string | Practice, Challenge, or Mastery |
24
+ | questions | array | Array of question objects |
25
+ | generated_at | timestamp | When generated |
26
+
27
+ ```json
28
+ {
29
+ "session_id": "uuid string",
30
+ "userId": "firebase uid",
31
+ "subject": "Algebra | Geometry | ...",
32
+ "competency": "string",
33
+ "difficulty": "Practice | Challenge | Mastery",
34
+ "questions": [
35
+ {
36
+ "id": "q1",
37
+ "question": "What is 2+2?",
38
+ "options": ["3", "4", "5", "6"],
39
+ "correct_index": 1,
40
+ "explanation": "Basic addition...",
41
+ "competency": "Basic Arithmetic",
42
+ "difficulty": "Practice",
43
+ "bloomsLevel": "Remember"
44
+ }
45
+ ],
46
+ "generated_at": "ISO timestamp"
47
+ }
48
+ ```
49
+
50
+ **Indexes needed:** None, single doc reads by `session_id`.
51
+
52
+ ## Collection: practice_results/{userId}/sessions
53
+
54
+ ### Document: practice_results/{userId}/sessions/{session_id}
55
+
56
+ Purpose: stores the result of a completed practice session.
57
+
58
+ Ownership: user-owned subcollection under `practice_results/{userId}`.
59
+
60
+ Written by: backend `/api/practice/submit` endpoint.
61
+
62
+ Read by: backend `/api/practice/stats` and `/api/practice/history` endpoints.
63
+
64
+ | Field | Type | Description |
65
+ |-------|------|-------------|
66
+ | session_id | string | UUID |
67
+ | userId | string | Firebase UID |
68
+ | score_percent | number | Score percentage |
69
+ | correct_count | number | Correct answers |
70
+ | total | number | Total questions |
71
+ | xp_earned | number | XP earned |
72
+ | subject | string | Subject name |
73
+ | difficulty | string | Practice, Challenge, or Mastery |
74
+ | answers | array | Selected answers per question |
75
+ | per_question_feedback | array | Per-question feedback objects |
76
+ | submitted_at | timestamp | When submitted |
77
+
78
+ ```json
79
+ {
80
+ "session_id": "uuid string",
81
+ "userId": "firebase uid",
82
+ "score_percent": 80,
83
+ "correct_count": 8,
84
+ "total": 10,
85
+ "xp_earned": 130,
86
+ "subject": "Algebra",
87
+ "difficulty": "Challenge",
88
+ "answers": [
89
+ { "question_id": "q1", "selected_index": 1 }
90
+ ],
91
+ "per_question_feedback": [
92
+ {
93
+ "question_id": "q1",
94
+ "selected_index": 1,
95
+ "correct_index": 1,
96
+ "is_correct": true,
97
+ "explanation": "..."
98
+ }
99
+ ],
100
+ "submitted_at": "ISO timestamp"
101
+ }
102
+ ```
103
+
104
+ **Indexes needed:** `submitted_at DESC` for history queries.
105
+
106
+ ## User Stats Updated by Practice
107
+
108
+ | Field | Update Rule |
109
+ |-------|-------------|
110
+ | totalXP | Increment by `xp_earned` |
111
+ | quizzesCompleted | Increment by 1 |
112
+ | averageScore | Rolling average: `(old_avg * old_count + new_score) / (old_count + 1)` |
113
+
114
+ ## Query Patterns
115
+
116
+ - `practice_results/{userId}/sessions` ordered by `submitted_at DESC` for history
117
+ - `practice_results/{userId}/sessions` limit 10 for recent sessions used in stats
118
+ - `users/{userId}` read `totalXP`, `quizzesCompleted`, `averageScore`
config/models.yaml CHANGED
@@ -39,6 +39,7 @@ routing:
39
  verify_solution: deepseek-reasoner
40
  lesson_generation: deepseek-chat
41
  quiz_generation: deepseek-chat
 
42
  learning_path: deepseek-chat
43
  daily_insight: deepseek-chat
44
  risk_classification: deepseek-chat
@@ -56,6 +57,8 @@ routing:
56
  - deepseek-chat
57
  quiz_generation:
58
  - deepseek-chat
 
 
59
  learning_path:
60
  - deepseek-chat
61
  daily_insight:
@@ -76,6 +79,7 @@ routing:
76
  verify_solution: deepseek
77
  lesson_generation: deepseek
78
  quiz_generation: deepseek
 
79
  learning_path: deepseek
80
  daily_insight: deepseek
81
  risk_classification: deepseek
 
39
  verify_solution: deepseek-reasoner
40
  lesson_generation: deepseek-chat
41
  quiz_generation: deepseek-chat
42
+ practice_generation: deepseek-chat
43
  learning_path: deepseek-chat
44
  daily_insight: deepseek-chat
45
  risk_classification: deepseek-chat
 
57
  - deepseek-chat
58
  quiz_generation:
59
  - deepseek-chat
60
+ practice_generation:
61
+ - deepseek-chat
62
  learning_path:
63
  - deepseek-chat
64
  daily_insight:
 
79
  verify_solution: deepseek
80
  lesson_generation: deepseek
81
  quiz_generation: deepseek
82
+ practice_generation: deepseek
83
  learning_path: deepseek
84
  daily_insight: deepseek
85
  risk_classification: deepseek
main.py CHANGED
@@ -102,6 +102,7 @@ from routes.teacher_materials import router as teacher_materials_router
102
  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
 
106
  # Rate limiting (slowapi)
107
  try:
@@ -1139,6 +1140,7 @@ app.include_router(teacher_materials_router)
1139
  app.include_router(class_records_router)
1140
  app.include_router(risk_router)
1141
  app.include_router(tutor_checkin_router)
 
1142
 
1143
 
1144
  # ─── Global Exception Handler ─────────────────────────────────
 
102
  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
 
107
  # Rate limiting (slowapi)
108
  try:
 
1140
  app.include_router(class_records_router)
1141
  app.include_router(risk_router)
1142
  app.include_router(tutor_checkin_router)
1143
+ app.include_router(practice_router)
1144
 
1145
 
1146
  # ─── Global Exception Handler ─────────────────────────────────
routes/practice.py ADDED
@@ -0,0 +1,617 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MathPulse AI - Practice Center Router
3
+ POST /api/practice/generate - Generate MCQ practice session via AI
4
+ POST /api/practice/submit - Score session, persist result, update XP
5
+ GET /api/practice/stats/{userId} - Aggregated stats + recent sessions
6
+ GET /api/practice/history/{userId} - Paginated session history
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import uuid
14
+ from collections import defaultdict
15
+ from datetime import datetime, timezone
16
+ from typing import Any, Dict, List, Literal, Optional
17
+
18
+ from fastapi import APIRouter, HTTPException, Request
19
+ from pydantic import BaseModel, Field
20
+
21
+ from services.ai_client import CHAT_MODEL, get_deepseek_client
22
+ import firebase_admin
23
+ from firebase_admin import firestore as fs
24
+
25
+ logger = logging.getLogger("mathpulse.practice")
26
+
27
+ router = APIRouter(prefix="/api/practice", tags=["practice"])
28
+
29
+ # In-memory fallback if Firestore unavailable
30
+ _in_memory_sessions: Dict[str, Dict[str, Any]] = defaultdict(dict)
31
+ _in_memory_results: Dict[str, Dict[str, Any]] = defaultdict(dict)
32
+
33
+
34
+ # ─── Request Models ────────────────────────────────────────────────────────────
35
+
36
+ class PracticeGenerateRequest(BaseModel):
37
+ userId: str
38
+ subject: str
39
+ competency: str
40
+ difficulty: Literal["Practice", "Challenge", "Mastery"] = "Practice"
41
+ count: int = Field(default=5, ge=1, le=20)
42
+
43
+
44
+ class AnswerItem(BaseModel):
45
+ question_id: str
46
+ selected_index: int
47
+
48
+
49
+ class PracticeSubmitRequest(BaseModel):
50
+ session_id: str
51
+ userId: str
52
+ answers: List[AnswerItem]
53
+
54
+
55
+ # ─── Response Models ──────────────────────────────────────────────────────────
56
+
57
+ class PracticeQuestion(BaseModel):
58
+ id: str
59
+ question: str
60
+ options: List[str]
61
+ correct_index: int
62
+ explanation: str
63
+ competency: str
64
+ difficulty: str
65
+ bloomsLevel: str
66
+
67
+
68
+ class PracticeGenerateResponse(BaseModel):
69
+ session_id: str
70
+ questions: List[PracticeQuestion]
71
+ generated_at: str
72
+
73
+
74
+ class PerQuestionFeedback(BaseModel):
75
+ question_id: str
76
+ selected_index: int
77
+ correct_index: int
78
+ is_correct: bool
79
+ explanation: str
80
+
81
+
82
+ class UpdatedStats(BaseModel):
83
+ totalXP: int
84
+ quizzesCompleted: int
85
+ averageScore: float
86
+
87
+
88
+ class PracticeSubmitResponse(BaseModel):
89
+ score_percent: float
90
+ correct_count: int
91
+ total: int
92
+ xp_earned: int
93
+ per_question_feedback: List[PerQuestionFeedback]
94
+ updated_stats: UpdatedStats
95
+
96
+
97
+ class RecentSession(BaseModel):
98
+ session_id: str
99
+ score_percent: float
100
+ subject: str
101
+ difficulty: str
102
+ timestamp: str
103
+
104
+
105
+ class CompetencyBreakdownEntry(BaseModel):
106
+ total: int
107
+ correct: int
108
+ percent: float
109
+
110
+
111
+ class PracticeStatsResponse(BaseModel):
112
+ quizzesCompleted: int
113
+ totalXPEarned: int
114
+ averageScore: float
115
+ recentSessions: List[RecentSession]
116
+ competencyBreakdown: Dict[str, CompetencyBreakdownEntry]
117
+
118
+
119
+ class HistoryItem(BaseModel):
120
+ session_id: str
121
+ score_percent: float
122
+ subject: str
123
+ difficulty: str
124
+ submitted_at: str
125
+
126
+
127
+ class PracticeHistoryResponse(BaseModel):
128
+ page: int
129
+ limit: int
130
+ hasMore: bool
131
+ total: int
132
+ items: List[HistoryItem]
133
+
134
+
135
+ # ─── Helpers ───────────────────────────────────────────────────────────────────
136
+
137
+ def _get_firestore():
138
+ if firebase_admin._apps:
139
+ return firebase_admin.firestore.client()
140
+ return None
141
+
142
+
143
+ async def _call_deepseek(system_prompt: str, user_message: str, temperature: float = 0.7) -> str:
144
+ """Call DeepSeek with JSON mode for structured output."""
145
+ try:
146
+ client = get_deepseek_client()
147
+ response = client.chat.completions.create(
148
+ model=CHAT_MODEL,
149
+ messages=[
150
+ {"role": "system", "content": system_prompt},
151
+ {"role": "user", "content": user_message},
152
+ ],
153
+ temperature=temperature,
154
+ response_format={"type": "json_object"},
155
+ )
156
+ return response.choices[0].message.content or ""
157
+ except Exception as e:
158
+ logger.error(f"DeepSeek API error: {e}")
159
+ raise HTTPException(status_code=500, detail="AI model unavailable. Please try again later.")
160
+
161
+
162
+ def _parse_questions_response(raw: str, count: int) -> List[Dict[str, Any]]:
163
+ """Extract question list from AI JSON response."""
164
+ cleaned = raw.strip()
165
+ cleaned = cleaned.replace("```json", "").replace("```", "").strip()
166
+ try:
167
+ data = json.loads(cleaned)
168
+ except json.JSONDecodeError:
169
+ raise HTTPException(status_code=500, detail="Failed to parse AI response. Please try again.")
170
+
171
+ questions = None
172
+ if isinstance(data, dict):
173
+ for key in ("questions", "items", "data", "results", "practice_questions"):
174
+ if key in data and isinstance(data[key], list):
175
+ questions = data[key]
176
+ break
177
+ if questions is None and len(data) > 0:
178
+ for v in data.values():
179
+ if isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict):
180
+ questions = v
181
+ break
182
+ elif isinstance(data, list):
183
+ questions = data
184
+
185
+ if not questions:
186
+ raise HTTPException(status_code=500, detail="AI response missing questions. Please try again.")
187
+
188
+ # Ensure we have exactly `count` questions
189
+ questions = questions[:count]
190
+ return questions
191
+
192
+
193
+ def _build_question_prompt(subject: str, competency: str, difficulty: str, count: int) -> tuple[str, str]:
194
+ system_prompt = (
195
+ "You are an expert Filipino math educator. "
196
+ "Generate exactly " + str(count) + " multiple-choice math questions "
197
+ "for the subject \"" + subject + "\" focused on competency: \"" + competency + "\". "
198
+ "Difficulty level: " + difficulty + ". "
199
+ "Return ONLY valid JSON with this exact structure: "
200
+ "{ \"questions\": [{ \"id\": \"q1\", \"question\": \"...\", "
201
+ "\"options\": [\"A: ...\", \"B: ...\", \"C: ...\", \"D: ...\"], "
202
+ "\"correct_index\": 0-3, \"explanation\": \"...\", "
203
+ "\"competency\": \"...\", \"difficulty\": \"...\", "
204
+ "\"bloomsLevel\": \"Remember|Understand|Apply|Analyze|Evaluate|Create\" }] }. "
205
+ "Use Filipino context where appropriate. Make questions clear and unambiguous."
206
+ )
207
+ user_message = (
208
+ f"Generate {count} multiple-choice math questions for {subject}, "
209
+ f"competency: {competency}, difficulty: {difficulty}. "
210
+ f"Return only the JSON, no explanation."
211
+ )
212
+ return system_prompt, user_message
213
+
214
+
215
+ def _authenticate(request: Request, userId: str) -> None:
216
+ """Verify the requesting user matches the userId in the payload."""
217
+ user = getattr(request.state, "user", None)
218
+ if not user:
219
+ raise HTTPException(status_code=401, detail="Authentication required")
220
+ uid = getattr(user, "uid", None)
221
+ if uid != userId:
222
+ raise HTTPException(status_code=403, detail="Not authorized for this user")
223
+
224
+
225
+ # ─── Endpoints ────────────────────────────────────────────────────────────────
226
+
227
+ @router.post("/generate", response_model=PracticeGenerateResponse)
228
+ async def generate_practice(request: Request, body: PracticeGenerateRequest):
229
+ """
230
+ Generate a practice session with count MCQ questions aligned to
231
+ subject, competency, and difficulty.
232
+ """
233
+ # Auth check
234
+ _authenticate(request, body.userId)
235
+
236
+ system_prompt, user_message = _build_question_prompt(
237
+ body.subject, body.competency, body.difficulty, body.count
238
+ )
239
+
240
+ # Call AI
241
+ raw_response = await _call_deepseek(system_prompt, user_message, temperature=0.7)
242
+
243
+ # Parse questions
244
+ raw_questions = _parse_questions_response(raw_response, body.count)
245
+
246
+ # Normalize into PracticeQuestion list
247
+ questions: List[PracticeQuestion] = []
248
+ for i, q in enumerate(raw_questions):
249
+ q_id = q.get("id") or f"q{i+1}"
250
+ correct_idx = int(q.get("correct_index", 0))
251
+ questions.append(
252
+ PracticeQuestion(
253
+ id=q_id,
254
+ question=q.get("question", ""),
255
+ options=q.get("options", ["", "", "", ""]),
256
+ correct_index=correct_idx,
257
+ explanation=q.get("explanation", "No explanation available."),
258
+ competency=q.get("competency", body.competency),
259
+ difficulty=q.get("difficulty", body.difficulty),
260
+ bloomsLevel=q.get("bloomsLevel", "Apply"),
261
+ )
262
+ )
263
+
264
+ session_id = str(uuid.uuid4())
265
+ generated_at = datetime.now(timezone.utc).isoformat()
266
+
267
+ # Build Firestore document
268
+ session_doc = {
269
+ "session_id": session_id,
270
+ "userId": body.userId,
271
+ "subject": body.subject,
272
+ "competency": body.competency,
273
+ "difficulty": body.difficulty,
274
+ "questions": [q.model_dump() for q in questions],
275
+ "generated_at": generated_at,
276
+ }
277
+
278
+ # Store in Firestore (fallback to in-memory)
279
+ db = _get_firestore()
280
+ if db:
281
+ try:
282
+ db.collection("practice_sessions").document(session_id).set(session_doc)
283
+ except Exception as e:
284
+ logger.warning("Firestore write failed for session %s: %s", session_id, e)
285
+ _in_memory_sessions[session_id] = session_doc
286
+ else:
287
+ _in_memory_sessions[session_id] = session_doc
288
+
289
+ return PracticeGenerateResponse(
290
+ session_id=session_id,
291
+ questions=questions,
292
+ generated_at=generated_at,
293
+ )
294
+
295
+
296
+ @router.post("/submit", response_model=PracticeSubmitResponse)
297
+ async def submit_practice(request: Request, body: PracticeSubmitRequest):
298
+ """
299
+ Score a practice session, compute XP, persist result, update user stats.
300
+ XP formula: 10 XP per correct answer + 50 XP bonus if score >= 80%.
301
+ """
302
+ _authenticate(request, body.userId)
303
+
304
+ session_id = body.session_id
305
+ userId = body.userId
306
+
307
+ # Retrieve session
308
+ db = _get_firestore()
309
+ questions_data: List[Dict[str, Any]] = []
310
+ session_subject = ""
311
+ session_difficulty = ""
312
+ session_competency = ""
313
+
314
+ if db:
315
+ try:
316
+ doc = db.collection("practice_sessions").document(session_id).get()
317
+ if doc.exists:
318
+ data = doc.to_dict()
319
+ questions_data = data.get("questions", [])
320
+ session_subject = data.get("subject", "")
321
+ session_difficulty = data.get("difficulty", "")
322
+ session_competency = data.get("competency", "")
323
+ except Exception as e:
324
+ logger.warning("Firestore read failed for session %s: %s", session_id, e)
325
+ else:
326
+ sess = _in_memory_sessions.get(session_id, {})
327
+ questions_data = sess.get("questions", [])
328
+ session_subject = sess.get("subject", "")
329
+ session_difficulty = sess.get("difficulty", "")
330
+ session_competency = sess.get("competency", "")
331
+
332
+ if not questions_data:
333
+ raise HTTPException(status_code=404, detail="Session not found or expired.")
334
+
335
+ # Build question lookup
336
+ q_lookup: Dict[str, Dict[str, Any]] = {q["id"]: q for q in questions_data}
337
+
338
+ # Score
339
+ correct_count = 0
340
+ total = len(body.answers)
341
+ per_question_feedback: List[PerQuestionFeedback] = []
342
+
343
+ for answer in body.answers:
344
+ q = q_lookup.get(answer.question_id, {})
345
+ correct_idx = int(q.get("correct_index", -1))
346
+ is_correct = answer.selected_index == correct_idx
347
+ if is_correct:
348
+ correct_count += 1
349
+ per_question_feedback.append(
350
+ PerQuestionFeedback(
351
+ question_id=answer.question_id,
352
+ selected_index=answer.selected_index,
353
+ correct_index=correct_idx,
354
+ is_correct=is_correct,
355
+ explanation=q.get("explanation", ""),
356
+ )
357
+ )
358
+
359
+ score_percent = round((correct_count / total) * 100, 1) if total > 0 else 0.0
360
+ xp_earned = correct_count * 10 + (50 if score_percent >= 80 else 0)
361
+
362
+ submitted_at = datetime.now(timezone.utc).isoformat()
363
+
364
+ # Build result doc
365
+ result_doc = {
366
+ "session_id": session_id,
367
+ "userId": userId,
368
+ "score_percent": score_percent,
369
+ "correct_count": correct_count,
370
+ "total": total,
371
+ "xp_earned": xp_earned,
372
+ "subject": session_subject,
373
+ "competency": session_competency,
374
+ "difficulty": session_difficulty,
375
+ "answers": [a.model_dump() for a in body.answers],
376
+ "per_question_feedback": [f.model_dump() for f in per_question_feedback],
377
+ "submitted_at": submitted_at,
378
+ }
379
+
380
+ # Store result
381
+ if db:
382
+ try:
383
+ db.collection("practice_results").document(userId).collection("sessions").document(session_id).set(result_doc)
384
+ except Exception as e:
385
+ logger.warning("Firestore write failed for result %s: %s", session_id, e)
386
+ _in_memory_results[f"{userId}:{session_id}"] = result_doc
387
+ else:
388
+ _in_memory_results[f"{userId}:{session_id}"] = result_doc
389
+
390
+ # Update user stats atomically
391
+ if db:
392
+ try:
393
+ user_ref = db.collection("users").document(userId)
394
+ user_doc = user_ref.get()
395
+ if user_doc.exists:
396
+ current = user_doc.to_dict()
397
+ current_quizzes = current.get("quizzesCompleted", 0) or 0
398
+ current_avg = current.get("averageScore", 0.0) or 0.0
399
+ new_quizzes = current_quizzes + 1
400
+ new_avg = round((current_avg * current_quizzes + score_percent) / new_quizzes, 1)
401
+ user_ref.update({
402
+ "totalXP": fs.Increment(xp_earned),
403
+ "quizzesCompleted": fs.Increment(1),
404
+ "averageScore": new_avg,
405
+ })
406
+ updated_total_xp = (current.get("totalXP", 0) or 0) + xp_earned
407
+ updated_stats = UpdatedStats(
408
+ totalXP=updated_total_xp,
409
+ quizzesCompleted=new_quizzes,
410
+ averageScore=new_avg,
411
+ )
412
+ else:
413
+ updated_stats = UpdatedStats(
414
+ totalXP=xp_earned,
415
+ quizzesCompleted=1,
416
+ averageScore=score_percent,
417
+ )
418
+ except Exception as e:
419
+ logger.warning("User stats update failed: %s", e)
420
+ updated_stats = UpdatedStats(
421
+ totalXP=xp_earned,
422
+ quizzesCompleted=1,
423
+ averageScore=score_percent,
424
+ )
425
+ else:
426
+ updated_stats = UpdatedStats(
427
+ totalXP=xp_earned,
428
+ quizzesCompleted=1,
429
+ averageScore=score_percent,
430
+ )
431
+
432
+ return PracticeSubmitResponse(
433
+ score_percent=score_percent,
434
+ correct_count=correct_count,
435
+ total=total,
436
+ xp_earned=xp_earned,
437
+ per_question_feedback=per_question_feedback,
438
+ updated_stats=updated_stats,
439
+ )
440
+
441
+
442
+ @router.get("/stats/{userId}", response_model=PracticeStatsResponse)
443
+ async def get_practice_stats(request: Request, userId: str):
444
+ """
445
+ Return aggregated stats for a user:
446
+ quizzesCompleted, totalXPEarned, averageScore, recentSessions (last 10),
447
+ competencyBreakdown.
448
+ """
449
+ _authenticate(request, userId)
450
+
451
+ db = _get_firestore()
452
+
453
+ # Read user doc
454
+ total_xp = 0
455
+ quizzes_completed = 0
456
+ average_score = 0.0
457
+
458
+ if db:
459
+ try:
460
+ user_doc = db.collection("users").document(userId).get()
461
+ if user_doc.exists:
462
+ d = user_doc.to_dict()
463
+ total_xp = d.get("totalXP", 0) or 0
464
+ quizzes_completed = d.get("quizzesCompleted", 0) or 0
465
+ average_score = d.get("averageScore", 0.0) or 0.0
466
+ except Exception as e:
467
+ logger.warning("Error reading user stats for %s: %s", userId, e)
468
+ else:
469
+ # Fallback: sum from in-memory results
470
+ for key, val in _in_memory_results.items():
471
+ if key.startswith(f"{userId}:"):
472
+ quizzes_completed += 1
473
+ total_xp += val.get("xp_earned", 0)
474
+
475
+ # Read recent sessions from practice_results
476
+ recent_sessions: List[RecentSession] = []
477
+ competency_breakdown: Dict[str, Dict[str, Any]] = defaultdict(lambda: {"total": 0, "correct": 0})
478
+
479
+ if db:
480
+ try:
481
+ results_ref = db.collection("practice_results").document(userId).collection("sessions")
482
+ all_results = results_ref.order_by("submitted_at", direction=fs.Query.DESCENDING).limit(50).get()
483
+
484
+ for doc in all_results:
485
+ d = doc.to_dict()
486
+ score = d.get("score_percent", 0)
487
+ total = d.get("total", 1)
488
+ correct = d.get("correct_count", 0)
489
+ submitted = d.get("submitted_at", "")
490
+ subject = d.get("subject", "")
491
+ difficulty = d.get("difficulty", "")
492
+ competency = d.get("competency", "")
493
+
494
+ # Recent sessions (last 10)
495
+ if len(recent_sessions) < 10:
496
+ recent_sessions.append(RecentSession(
497
+ session_id=d.get("session_id", ""),
498
+ score_percent=score,
499
+ subject=subject,
500
+ difficulty=difficulty,
501
+ timestamp=submitted,
502
+ ))
503
+
504
+ # Competency breakdown
505
+ if competency:
506
+ competency_breakdown[competency]["total"] += total
507
+ competency_breakdown[competency]["correct"] += correct
508
+ except Exception as e:
509
+ logger.warning("Error reading practice results for %s: %s", userId, e)
510
+ else:
511
+ # Fallback from in-memory
512
+ for key, val in _in_memory_results.items():
513
+ if key.startswith(f"{userId}:"):
514
+ if len(recent_sessions) < 10:
515
+ recent_sessions.append(RecentSession(
516
+ session_id=val.get("session_id", ""),
517
+ score_percent=val.get("score_percent", 0),
518
+ subject=val.get("subject", ""),
519
+ difficulty=val.get("difficulty", ""),
520
+ timestamp=val.get("submitted_at", ""),
521
+ ))
522
+
523
+ # Compute competency percentages
524
+ competency_result: Dict[str, CompetencyBreakdownEntry] = {}
525
+ for comp, vals in competency_breakdown.items():
526
+ total_q = vals["total"]
527
+ correct_q = vals["correct"]
528
+ pct = round((correct_q / total_q) * 100, 1) if total_q > 0 else 0.0
529
+ competency_result[comp] = CompetencyBreakdownEntry(
530
+ total=total_q,
531
+ correct=correct_q,
532
+ percent=pct,
533
+ )
534
+
535
+ return PracticeStatsResponse(
536
+ quizzesCompleted=quizzes_completed,
537
+ totalXPEarned=total_xp,
538
+ averageScore=average_score,
539
+ recentSessions=recent_sessions,
540
+ competencyBreakdown=competency_result,
541
+ )
542
+
543
+
544
+ @router.get("/history/{userId}", response_model=PracticeHistoryResponse)
545
+ async def get_practice_history(
546
+ request: Request,
547
+ userId: str,
548
+ page: int = 1,
549
+ limit: int = 10,
550
+ ):
551
+ """
552
+ Return paginated practice history for a user, sorted by submitted_at DESC.
553
+ """
554
+ _authenticate(request, userId)
555
+
556
+ page = max(1, page)
557
+ limit = max(1, min(50, limit))
558
+ offset = (page - 1) * limit
559
+
560
+ db = _get_firestore()
561
+ items: List[HistoryItem] = []
562
+ total = 0
563
+ has_more = False
564
+
565
+ if db:
566
+ try:
567
+ results_ref = db.collection("practice_results").document(userId).collection("sessions")
568
+ # Get total count
569
+ all_docs = results_ref.order_by("submitted_at", direction=fs.Query.DESCENDING).get()
570
+ total = len(all_docs)
571
+
572
+ # Get page
573
+ page_docs = (
574
+ results_ref
575
+ .order_by("submitted_at", direction=fs.Query.DESCENDING)
576
+ .offset(offset)
577
+ .limit(limit)
578
+ .get()
579
+ )
580
+ for doc in page_docs:
581
+ d = doc.to_dict()
582
+ items.append(HistoryItem(
583
+ session_id=d.get("session_id", ""),
584
+ score_percent=d.get("score_percent", 0),
585
+ subject=d.get("subject", ""),
586
+ difficulty=d.get("difficulty", ""),
587
+ submitted_at=d.get("submitted_at", ""),
588
+ ))
589
+
590
+ has_more = offset + len(items) < total
591
+ except Exception as e:
592
+ logger.warning("Error reading practice history for %s: %s", userId, e)
593
+ else:
594
+ # Fallback: filter in-memory
595
+ all_results = [
596
+ v for k, v in _in_memory_results.items() if k.startswith(f"{userId}:")
597
+ ]
598
+ all_results.sort(key=lambda x: x.get("submitted_at", ""), reverse=True)
599
+ total = len(all_results)
600
+ paginated = all_results[offset:offset + limit]
601
+ for v in paginated:
602
+ items.append(HistoryItem(
603
+ session_id=v.get("session_id", ""),
604
+ score_percent=v.get("score_percent", 0),
605
+ subject=v.get("subject", ""),
606
+ difficulty=v.get("difficulty", ""),
607
+ submitted_at=v.get("submitted_at", ""),
608
+ ))
609
+ has_more = offset + len(items) < total
610
+
611
+ return PracticeHistoryResponse(
612
+ page=page,
613
+ limit=limit,
614
+ hasMore=has_more,
615
+ total=total,
616
+ items=items,
617
+ )
services/inference_client.py CHANGED
@@ -188,6 +188,7 @@ def get_model_for_task(task_type: str) -> str:
188
  task_key_map = {
189
  "chat": "INFERENCE_CHAT_MODEL_ID",
190
  "quiz_generation": "HF_QUIZ_MODEL_ID",
 
191
  "rag_lesson": "HF_RAG_MODEL_ID",
192
  "rag_problem": "HF_RAG_MODEL_ID",
193
  "rag_analysis_context": "HF_RAG_MODEL_ID",
 
188
  task_key_map = {
189
  "chat": "INFERENCE_CHAT_MODEL_ID",
190
  "quiz_generation": "HF_QUIZ_MODEL_ID",
191
+ "practice_generation": "HF_QUIZ_MODEL_ID",
192
  "rag_lesson": "HF_RAG_MODEL_ID",
193
  "rag_problem": "HF_RAG_MODEL_ID",
194
  "rag_analysis_context": "HF_RAG_MODEL_ID",
tests/conftest.py CHANGED
@@ -7,8 +7,8 @@ This conftest:
7
  1. Ensures backend/ is at sys.path[0]
8
  2. Evicts any stale 'services' package from sys.modules so subsequent
9
  imports resolve to backend/services/ instead of the project-root copy.
10
- 3. Re-evicts before every test function via autouse fixture (some test
11
- files re-import stale services during collection, polluting others).
12
  """
13
 
14
  import sys
@@ -16,6 +16,59 @@ import os
16
 
17
  import pytest
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  _backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
20
 
21
  # 1. Force backend/ to sys.path[0]
@@ -48,3 +101,25 @@ def _auto_evict_stale_services():
48
  """Re-evict stale services before every test to prevent cross-file pollution."""
49
  _evict_stale_services()
50
  yield
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  1. Ensures backend/ is at sys.path[0]
8
  2. Evicts any stale 'services' package from sys.modules so subsequent
9
  imports resolve to backend/services/ instead of the project-root copy.
10
+ 3. Mocks Firebase Admin auth so test tokens (Bearer mock_token_<uid>)
11
+ work without real Firebase credentials.
12
  """
13
 
14
  import sys
 
16
 
17
  import pytest
18
 
19
+ # ─── Firebase Auth Mock ────────────────────────────────────────────────────────
20
+ # Intercept firebase_admin.auth.verify_id_token so that test tokens like
21
+ # "Bearer mock_token_<uid>" work without real Firebase credentials.
22
+ # The mock extracts the uid from the token string (e.g. "mock_token_abc" → uid="abc").
23
+ # Non-mock tokens fall through to the real Firebase implementation.
24
+
25
+ _mock_orig_verify = None # Lazily resolved
26
+
27
+
28
+ def _get_mock_verify():
29
+ """Return the real verify_id_token, lazily resolved."""
30
+ global _mock_orig_verify
31
+ if _mock_orig_verify is None:
32
+ import firebase_admin.auth as fa_auth
33
+
34
+ _mock_orig_verify = getattr(fa_auth, "verify_id_token", None) or (lambda t, **k: {}.get("uid"))
35
+ return _mock_orig_verify
36
+
37
+
38
+ def _mock_verify_id_token(token: str, *, check_revoked: bool = False) -> dict:
39
+ """Return fake Firebase claims dict for tokens with 'mock_token_' prefix."""
40
+ if token and token.startswith("mock_token_"):
41
+ uid = token[len("mock_token_"):]
42
+ return {
43
+ "uid": uid,
44
+ "sub": uid,
45
+ "email": f"{uid}@test.mathpulse.ai",
46
+ "email_verified": True,
47
+ "role": "student",
48
+ }
49
+ # Non-mock tokens: call the real Firebase implementation
50
+ real_verify = _get_mock_verify()
51
+ return real_verify(token, check_revoked=check_revoked)
52
+
53
+
54
+ def _apply_firebase_mock():
55
+ """Apply the mock to firebase_admin.auth.verify_id_token (idempotent)."""
56
+ try:
57
+ import firebase_admin.auth
58
+
59
+ current = firebase_admin.auth.verify_id_token
60
+ if not hasattr(current, "_mathpulse_mock"):
61
+ firebase_admin.auth.verify_id_token = _mock_verify_id_token
62
+ firebase_admin.auth.verify_id_token._mathpulse_mock = True
63
+ except Exception:
64
+ pass
65
+
66
+
67
+ # Apply immediately at conftest load time (covers tests that import main.py
68
+ # before any test runs, e.g. via module-level TestClient instantiation).
69
+ _apply_firebase_mock()
70
+
71
+
72
  _backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
73
 
74
  # 1. Force backend/ to sys.path[0]
 
101
  """Re-evict stale services before every test to prevent cross-file pollution."""
102
  _evict_stale_services()
103
  yield
104
+
105
+
106
+ @pytest.fixture(autouse=True)
107
+ def _mock_firebase_auth():
108
+ """Re-apply Firebase auth mock before every test.
109
+
110
+ Some test files import main.py after conftest.py is loaded, which may
111
+ overwrite the firebase_admin.auth.verify_id_token reference. This fixture
112
+ ensures the mock is always active when tests run.
113
+ """
114
+ try:
115
+ import firebase_admin
116
+ import firebase_admin.auth
117
+
118
+ # Only re-apply if not already our mock (avoid infinite recursion)
119
+ current = firebase_admin.auth.verify_id_token
120
+ if not getattr(current, "_is_mocked", False):
121
+ firebase_admin.auth.verify_id_token = _mock_verify_id_token
122
+ firebase_admin.auth.verify_id_token._is_mocked = True
123
+ except Exception:
124
+ pass
125
+ yield
tests/test_practice_model_routing.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test that practice_generation task type is properly routed to a model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ import pytest
9
+
10
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
11
+
12
+ from services.inference_client import get_model_for_task
13
+
14
+
15
+ class TestPracticeRouting:
16
+ """Verify practice_generation routing."""
17
+
18
+ def test_practice_generation_resolves_to_model(self):
19
+ """get_model_for_task('practice_generation') should return a model string."""
20
+ model = get_model_for_task("practice_generation")
21
+ assert model is not None
22
+ assert isinstance(model, str)
23
+ assert len(model) > 0
24
+
25
+ def test_practice_generation_not_same_as_quiz_generation_alias(self):
26
+ """practice_generation and quiz_generation are distinct task types."""
27
+ practice_model = get_model_for_task("practice_generation")
28
+ quiz_model = get_model_for_task("quiz_generation")
29
+ # Both route to deepseek-chat but via separate config keys
30
+ # The important thing is they map through different env vars
31
+ assert "deepseek" in practice_model.lower()
32
+ assert "deepseek" in quiz_model.lower()
tests/test_practice_router.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Contract tests for Practice Center router endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ import uuid
9
+ from unittest.mock import MagicMock, patch
10
+
11
+ import pytest
12
+
13
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
14
+
15
+ from fastapi.testclient import TestClient
16
+
17
+ # We import the app via main to get router registered
18
+ import main as main_module
19
+ from main import app
20
+
21
+ client = TestClient(app)
22
+
23
+
24
+ @pytest.fixture(autouse=True)
25
+ def _patch_firebase_auth():
26
+ """Ensure firebase_auth extracts uid from mock tokens for practice tests.
27
+
28
+ test_api.py sets a static uid mock at module level; this fixture overrides
29
+ it per-test so practice auth checks (uid == userId) pass.
30
+ """
31
+ def _verify(token, **kwargs):
32
+ if token and token.startswith("mock_token_"):
33
+ uid = token[len("mock_token_"):]
34
+ return {"uid": uid, "email": f"{uid}@test.mathpulse.ai", "role": "student"}
35
+ return {"uid": "unknown", "role": "student"}
36
+
37
+ original = main_module.firebase_auth
38
+ mock_auth = MagicMock()
39
+ mock_auth.verify_id_token = _verify
40
+ main_module.firebase_auth = mock_auth
41
+ yield
42
+ main_module.firebase_auth = original
43
+
44
+
45
+ def _mock_user(uid: str):
46
+ user = MagicMock()
47
+ user.uid = uid
48
+ return user
49
+
50
+
51
+ def _auth_header(uid: str):
52
+ return {"Authorization": f"Bearer mock_token_{uid}"}
53
+
54
+
55
+ class TestPracticeGenerate:
56
+ """POST /api/practice/generate"""
57
+
58
+ @patch("routes.practice._call_deepseek")
59
+ def test_generate_returns_session_and_questions(self, mock_call, monkeypatch):
60
+ """Should return session_id, questions list, and generated_at."""
61
+ mock_call.return_value = json.dumps({
62
+ "questions": [
63
+ {
64
+ "id": "q1",
65
+ "question": "What is 2+2?",
66
+ "options": ["3", "4", "5", "6"],
67
+ "correct_index": 1,
68
+ "explanation": "Basic addition",
69
+ "competency": "Arithmetic",
70
+ "difficulty": "Practice",
71
+ "bloomsLevel": "Remember",
72
+ }
73
+ ]
74
+ })
75
+
76
+ user_id = "test_user_123"
77
+ response = client.post(
78
+ "/api/practice/generate",
79
+ json={
80
+ "userId": user_id,
81
+ "subject": "Algebra",
82
+ "competency": "Linear Equations",
83
+ "difficulty": "Practice",
84
+ "count": 1,
85
+ },
86
+ headers=_auth_header(user_id),
87
+ )
88
+
89
+ assert response.status_code == 200
90
+ data = response.json()
91
+ assert "session_id" in data
92
+ assert "questions" in data
93
+ assert len(data["questions"]) == 1
94
+ assert "generated_at" in data
95
+ assert data["questions"][0]["id"] == "q1"
96
+
97
+ def test_generate_rejects_mismatched_user(self):
98
+ """Request with mismatched auth userId should return 403."""
99
+ response = client.post(
100
+ "/api/practice/generate",
101
+ json={
102
+ "userId": "user_a",
103
+ "subject": "Algebra",
104
+ "competency": "Linear Equations",
105
+ "difficulty": "Practice",
106
+ "count": 5,
107
+ },
108
+ headers=_auth_header("user_b"), # mismatch
109
+ )
110
+ assert response.status_code == 403
111
+
112
+ def test_generate_requires_auth(self):
113
+ """Request without auth should return 401/403."""
114
+ response = client.post(
115
+ "/api/practice/generate",
116
+ json={
117
+ "userId": "user_a",
118
+ "subject": "Algebra",
119
+ "competency": "Linear Equations",
120
+ "difficulty": "Practice",
121
+ "count": 5,
122
+ },
123
+ )
124
+ assert response.status_code in (401, 403)
125
+
126
+
127
+ class TestPracticeSubmit:
128
+ """POST /api/practice/submit"""
129
+
130
+ @patch("routes.practice._get_firestore")
131
+ @patch("routes.practice._call_deepseek")
132
+ def test_submit_scores_correctly(self, mock_call, mock_get_db, monkeypatch):
133
+ """XP = correct*10 + 50 bonus if score>=80%."""
134
+ mock_call.return_value = json.dumps({
135
+ "questions": [
136
+ {
137
+ "id": "q1", "question": "Q1", "options": ["A", "B", "C", "D"],
138
+ "correct_index": 1, "explanation": "Exp", "competency": "Arith",
139
+ "difficulty": "Practice", "bloomsLevel": "Remember",
140
+ },
141
+ {
142
+ "id": "q2", "question": "Q2", "options": ["A", "B", "C", "D"],
143
+ "correct_index": 2, "explanation": "Exp", "competency": "Arith",
144
+ "difficulty": "Practice", "bloomsLevel": "Remember",
145
+ },
146
+ ]
147
+ })
148
+
149
+ # First generate a session
150
+ user_id = "test_user_123"
151
+ gen_response = client.post(
152
+ "/api/practice/generate",
153
+ json={
154
+ "userId": user_id,
155
+ "subject": "Algebra",
156
+ "competency": "Arithmetic",
157
+ "difficulty": "Practice",
158
+ "count": 2,
159
+ },
160
+ headers=_auth_header(user_id),
161
+ )
162
+ session_id = gen_response.json()["session_id"]
163
+
164
+ # Mock DB: return session for generate, skip update for submit
165
+ mock_db = MagicMock()
166
+ mock_get_db.return_value = mock_db
167
+ mock_session_doc = MagicMock()
168
+ mock_session_doc.exists = True
169
+ mock_session_doc.to_dict.return_value = {
170
+ "questions": [
171
+ {"id": "q1", "correct_index": 1, "explanation": "Exp", "competency": "Arith", "difficulty": "Practice", "question": "Q1", "options": ["A", "B", "C", "D"], "bloomsLevel": "Remember"},
172
+ {"id": "q2", "correct_index": 2, "explanation": "Exp", "competency": "Arith", "difficulty": "Practice", "question": "Q2", "options": ["A", "B", "C", "D"], "bloomsLevel": "Remember"},
173
+ ],
174
+ "subject": "Algebra",
175
+ "competency": "Arithmetic",
176
+ "difficulty": "Practice",
177
+ }
178
+ mock_db.collection.return_value.document.return_value.get.return_value = mock_session_doc
179
+ mock_db.collection.return_value.document.return_value.set.return_value = None
180
+ mock_db.collection.return_value.document.return_value.update.return_value = None
181
+ mock_user_doc = MagicMock()
182
+ mock_user_doc.exists = True
183
+ mock_user_doc.to_dict.return_value = {"totalXP": 100, "quizzesCompleted": 5, "averageScore": 75.0}
184
+ mock_db.collection.return_value.document.return_value.get.return_value = mock_session_doc
185
+
186
+ # Submit: q1 correct (index 1), q2 wrong (index 0 vs correct 2)
187
+ submit_response = client.post(
188
+ "/api/practice/submit",
189
+ json={
190
+ "session_id": session_id,
191
+ "userId": user_id,
192
+ "answers": [
193
+ {"question_id": "q1", "selected_index": 1},
194
+ {"question_id": "q2", "selected_index": 0},
195
+ ],
196
+ },
197
+ headers=_auth_header(user_id),
198
+ )
199
+
200
+ assert submit_response.status_code == 200
201
+ data = submit_response.json()
202
+ assert data["correct_count"] == 1 # 1 out of 2
203
+ assert data["total"] == 2
204
+ # XP: 1*10 + 0 (no bonus, score=50% < 80%)
205
+ assert data["xp_earned"] == 10
206
+ assert data["score_percent"] == 50.0
207
+ assert "per_question_feedback" in data
208
+ assert "updated_stats" in data
209
+
210
+ def test_submit_rejects_mismatched_user(self):
211
+ """Submit with auth userId != payload userId should return 403."""
212
+ response = client.post(
213
+ "/api/practice/submit",
214
+ json={
215
+ "session_id": "some_session",
216
+ "userId": "user_a",
217
+ "answers": [],
218
+ },
219
+ headers=_auth_header("user_b"),
220
+ )
221
+ assert response.status_code == 403
222
+
223
+
224
+ class TestPracticeStats:
225
+ """GET /api/practice/stats/{userId}"""
226
+
227
+ def test_stats_requires_auth(self):
228
+ """Stats endpoint should require authentication."""
229
+ response = client.get("/api/practice/stats/test_user")
230
+ assert response.status_code in (401, 403)
231
+
232
+ def test_stats_rejects_mismatched_user(self):
233
+ """Getting stats for different user should return 403."""
234
+ response = client.get(
235
+ "/api/practice/stats/user_a",
236
+ headers=_auth_header("user_b"),
237
+ )
238
+ assert response.status_code == 403
239
+
240
+
241
+ class TestPracticeHistory:
242
+ """GET /api/practice/history/{userId}"""
243
+
244
+ def test_history_requires_auth(self):
245
+ """History endpoint should require authentication."""
246
+ response = client.get("/api/practice/history/test_user")
247
+ assert response.status_code in (401, 403)
248
+
249
+ def test_history_pagination_params(self):
250
+ """History should accept page and limit query params."""
251
+ user_id = "test_user"
252
+ response = client.get(
253
+ f"/api/practice/history/{user_id}?page=2&limit=5",
254
+ headers=_auth_header(user_id),
255
+ )
256
+ # Should not 422 (validation error) - params are optional ints
257
+ assert response.status_code != 422
258
+
259
+ def test_history_rejects_mismatched_user(self):
260
+ """History for different user should return 403."""
261
+ response = client.get(
262
+ "/api/practice/history/user_a?page=1&limit=10",
263
+ headers=_auth_header("user_b"),
264
+ )
265
+ assert response.status_code == 403