Update backend: quiz, leaderboard, analytics, profile, difficulty adjustment, plan caching
Browse files
main.py
CHANGED
|
@@ -67,6 +67,33 @@ class TutorialRequest(BaseModel):
|
|
| 67 |
skill_level: str = "beginner"
|
| 68 |
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
# ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
|
| 72 |
PLAN_PROMPT_TEMPLATE = """You are a study-plan generator. Create a structured learning plan.
|
|
@@ -126,6 +153,51 @@ Keep the tutorial focused and around 800-1200 words.
|
|
| 126 |
Make it engaging and easy to follow for a {skill_level} learner.
|
| 127 |
"""
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
def parse_llm_json(text: str):
|
| 131 |
"""Strip markdown fences and parse JSON from LLM output."""
|
|
@@ -161,39 +233,79 @@ async def check_and_simplify(user_id: str, plan_id: str):
|
|
| 161 |
else:
|
| 162 |
consecutive_misses = 0
|
| 163 |
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
| 171 |
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
| 192 |
|
| 193 |
-
|
| 194 |
-
"
|
| 195 |
-
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
|
| 199 |
# ββ Endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -270,7 +382,6 @@ async def update_status(req: StatusUpdate, background_tasks: BackgroundTasks):
|
|
| 270 |
break
|
| 271 |
|
| 272 |
if not updated:
|
| 273 |
-
# Log debug info to help diagnose
|
| 274 |
day_statuses = {int(t.get('day', 0)): t.get('status', '?') for t in schedule}
|
| 275 |
print(f"[update-status] day={req.day}, schedule_days={day_statuses}")
|
| 276 |
raise HTTPException(
|
|
@@ -280,10 +391,171 @@ async def update_status(req: StatusUpdate, background_tasks: BackgroundTasks):
|
|
| 280 |
|
| 281 |
doc_ref.update({"schedule": schedule})
|
| 282 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
# Trigger background check for consecutive misses
|
| 284 |
background_tasks.add_task(check_and_simplify, req.user_id, req.plan_id)
|
| 285 |
|
| 286 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
|
| 288 |
|
| 289 |
@app.get("/plan/{plan_id}")
|
|
@@ -359,6 +631,201 @@ async def delete_plan(plan_id: str, user_id: str):
|
|
| 359 |
return {"message": "Plan deleted"}
|
| 360 |
|
| 361 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
if __name__ == "__main__":
|
| 363 |
import uvicorn
|
| 364 |
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
|
|
|
| 67 |
skill_level: str = "beginner"
|
| 68 |
|
| 69 |
|
| 70 |
+
class QuizRequest(BaseModel):
|
| 71 |
+
topic: str
|
| 72 |
+
skill_level: str = "beginner"
|
| 73 |
+
num_questions: int = Field(default=5, ge=3, le=10)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class QuizQuestion(BaseModel):
|
| 77 |
+
question: str
|
| 78 |
+
options: list[str]
|
| 79 |
+
correct_answer: int # index into options
|
| 80 |
+
explanation: str
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class QuizSubmission(BaseModel):
|
| 84 |
+
user_id: str
|
| 85 |
+
plan_id: str
|
| 86 |
+
day: int
|
| 87 |
+
answers: list[int] # user's selected indices
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class ReminderSettings(BaseModel):
|
| 91 |
+
user_id: str
|
| 92 |
+
enabled: bool = True
|
| 93 |
+
hour: int = Field(default=9, ge=0, le=23)
|
| 94 |
+
minute: int = Field(default=0, ge=0, le=59)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
# ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 98 |
|
| 99 |
PLAN_PROMPT_TEMPLATE = """You are a study-plan generator. Create a structured learning plan.
|
|
|
|
| 153 |
Make it engaging and easy to follow for a {skill_level} learner.
|
| 154 |
"""
|
| 155 |
|
| 156 |
+
QUIZ_PROMPT_TEMPLATE = """You are a quiz generator for educational content.
|
| 157 |
+
|
| 158 |
+
Generate exactly {num_questions} multiple-choice questions about:
|
| 159 |
+
Topic: {topic}
|
| 160 |
+
Difficulty: {skill_level}
|
| 161 |
+
|
| 162 |
+
Return ONLY valid JSON matching this exact schema (no markdown, no explanation):
|
| 163 |
+
{{
|
| 164 |
+
"questions": [
|
| 165 |
+
{{
|
| 166 |
+
"question": "<question text>",
|
| 167 |
+
"options": ["<option A>", "<option B>", "<option C>", "<option D>"],
|
| 168 |
+
"correct_answer": <0-3 index of correct option>,
|
| 169 |
+
"explanation": "<brief explanation of why the answer is correct>"
|
| 170 |
+
}}
|
| 171 |
+
]
|
| 172 |
+
}}
|
| 173 |
+
|
| 174 |
+
Rules:
|
| 175 |
+
- Exactly 4 options per question.
|
| 176 |
+
- correct_answer is a 0-based index.
|
| 177 |
+
- Questions should test understanding, not just memorization.
|
| 178 |
+
- Vary difficulty within the {skill_level} range.
|
| 179 |
+
"""
|
| 180 |
+
|
| 181 |
+
DIFFICULTY_UP_PROMPT = """The learner is performing exceptionally β they've completed {streak} days in a row without missing any.
|
| 182 |
+
|
| 183 |
+
Original goal: {goal}
|
| 184 |
+
Remaining schedule (uncompleted days):
|
| 185 |
+
{remaining_json}
|
| 186 |
+
|
| 187 |
+
Increase the difficulty of the remaining schedule. Make topics more advanced, add deeper sub-topics, and slightly increase the complexity.
|
| 188 |
+
|
| 189 |
+
Return ONLY valid JSON as a list of daily tasks matching this schema (no markdown):
|
| 190 |
+
[
|
| 191 |
+
{{"day": <int>, "topic": "<more advanced sub-topic>", "duration_mins": <int>, "status": "pending"}}
|
| 192 |
+
]
|
| 193 |
+
|
| 194 |
+
Rules:
|
| 195 |
+
- Keep the same number of days.
|
| 196 |
+
- Increase conceptual depth by ~30%.
|
| 197 |
+
- Keep duration_mins the same or slightly higher.
|
| 198 |
+
- Make topic descriptions more specific and challenging.
|
| 199 |
+
"""
|
| 200 |
+
|
| 201 |
|
| 202 |
def parse_llm_json(text: str):
|
| 203 |
"""Strip markdown fences and parse JSON from LLM output."""
|
|
|
|
| 233 |
else:
|
| 234 |
consecutive_misses = 0
|
| 235 |
|
| 236 |
+
# Detect 7 consecutive completions β increase difficulty
|
| 237 |
+
consecutive_completed = 0
|
| 238 |
+
needs_difficulty_boost = False
|
| 239 |
+
for task in sorted_schedule:
|
| 240 |
+
if task["status"] == "completed":
|
| 241 |
+
consecutive_completed += 1
|
| 242 |
+
if consecutive_completed >= 7:
|
| 243 |
+
needs_difficulty_boost = True
|
| 244 |
+
break
|
| 245 |
+
else:
|
| 246 |
+
consecutive_completed = 0
|
| 247 |
|
| 248 |
+
if needs_simplification:
|
| 249 |
+
# Gather remaining (non-completed) tasks
|
| 250 |
+
remaining = [t for t in sorted_schedule if t["status"] != "completed"]
|
| 251 |
+
if not remaining:
|
| 252 |
+
return
|
| 253 |
|
| 254 |
+
prompt = SIMPLIFY_PROMPT_TEMPLATE.format(
|
| 255 |
+
goal=data.get("goal", ""),
|
| 256 |
+
remaining_json=json.dumps(remaining, indent=2),
|
| 257 |
+
)
|
| 258 |
|
| 259 |
+
response = client.chat.completions.create(
|
| 260 |
+
model=LLM_MODEL,
|
| 261 |
+
messages=[{"role": "user", "content": prompt}],
|
| 262 |
+
)
|
| 263 |
+
new_schedule_raw = parse_llm_json(response.choices[0].message.content)
|
| 264 |
+
|
| 265 |
+
new_remaining = []
|
| 266 |
+
for entry in new_schedule_raw:
|
| 267 |
+
task = DailyTask(**entry)
|
| 268 |
+
new_remaining.append(task.model_dump())
|
| 269 |
+
|
| 270 |
+
completed = [t for t in sorted_schedule if t["status"] == "completed"]
|
| 271 |
+
updated_schedule = completed + new_remaining
|
| 272 |
+
|
| 273 |
+
doc_ref.update({
|
| 274 |
+
"schedule": updated_schedule,
|
| 275 |
+
"simplified_at": datetime.utcnow().isoformat(),
|
| 276 |
+
})
|
| 277 |
+
|
| 278 |
+
elif needs_difficulty_boost and not data.get("difficulty_boosted"):
|
| 279 |
+
remaining = [t for t in sorted_schedule if t["status"] == "pending"]
|
| 280 |
+
if not remaining:
|
| 281 |
+
return
|
| 282 |
+
|
| 283 |
+
prompt = DIFFICULTY_UP_PROMPT.format(
|
| 284 |
+
streak=consecutive_completed,
|
| 285 |
+
goal=data.get("goal", ""),
|
| 286 |
+
remaining_json=json.dumps(remaining, indent=2),
|
| 287 |
+
)
|
| 288 |
|
| 289 |
+
response = client.chat.completions.create(
|
| 290 |
+
model=LLM_MODEL,
|
| 291 |
+
messages=[{"role": "user", "content": prompt}],
|
| 292 |
+
)
|
| 293 |
+
new_schedule_raw = parse_llm_json(response.choices[0].message.content)
|
| 294 |
|
| 295 |
+
new_remaining = []
|
| 296 |
+
for entry in new_schedule_raw:
|
| 297 |
+
task = DailyTask(**entry)
|
| 298 |
+
new_remaining.append(task.model_dump())
|
| 299 |
|
| 300 |
+
completed = [t for t in sorted_schedule if t["status"] == "completed"]
|
| 301 |
+
missed = [t for t in sorted_schedule if t["status"] == "missed"]
|
| 302 |
+
updated_schedule = completed + missed + new_remaining
|
| 303 |
+
|
| 304 |
+
doc_ref.update({
|
| 305 |
+
"schedule": updated_schedule,
|
| 306 |
+
"difficulty_boosted": True,
|
| 307 |
+
"boosted_at": datetime.utcnow().isoformat(),
|
| 308 |
+
})
|
| 309 |
|
| 310 |
|
| 311 |
# ββ Endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 382 |
break
|
| 383 |
|
| 384 |
if not updated:
|
|
|
|
| 385 |
day_statuses = {int(t.get('day', 0)): t.get('status', '?') for t in schedule}
|
| 386 |
print(f"[update-status] day={req.day}, schedule_days={day_statuses}")
|
| 387 |
raise HTTPException(
|
|
|
|
| 391 |
|
| 392 |
doc_ref.update({"schedule": schedule})
|
| 393 |
|
| 394 |
+
# ββ Award points if completed ββββββββββββββββββββββββββββββββββββββββ
|
| 395 |
+
points_earned = 0
|
| 396 |
+
total_points = 0
|
| 397 |
+
streak = 0
|
| 398 |
+
if req.status == "completed":
|
| 399 |
+
points_earned = 10 # base points per day
|
| 400 |
+
|
| 401 |
+
# Calculate streak bonus: count consecutive completed days ending at this day
|
| 402 |
+
sorted_schedule = sorted(schedule, key=lambda t: int(t["day"]))
|
| 403 |
+
current_streak = 0
|
| 404 |
+
for t in sorted_schedule:
|
| 405 |
+
if t["status"] == "completed":
|
| 406 |
+
current_streak += 1
|
| 407 |
+
else:
|
| 408 |
+
current_streak = 0
|
| 409 |
+
streak = current_streak
|
| 410 |
+
|
| 411 |
+
# Streak bonuses
|
| 412 |
+
if streak >= 7:
|
| 413 |
+
points_earned += 25 # week streak bonus
|
| 414 |
+
elif streak >= 3:
|
| 415 |
+
points_earned += 10 # 3-day streak bonus
|
| 416 |
+
|
| 417 |
+
# Update user points in Firestore
|
| 418 |
+
points_ref = db.collection("user_points").document(req.user_id)
|
| 419 |
+
points_doc = points_ref.get()
|
| 420 |
+
if points_doc.exists:
|
| 421 |
+
current = points_doc.to_dict().get("total_points", 0)
|
| 422 |
+
total_points = current + points_earned
|
| 423 |
+
points_ref.update({
|
| 424 |
+
"total_points": total_points,
|
| 425 |
+
"last_earned": points_earned,
|
| 426 |
+
"streak": streak,
|
| 427 |
+
"updated_at": datetime.utcnow().isoformat(),
|
| 428 |
+
})
|
| 429 |
+
else:
|
| 430 |
+
total_points = points_earned
|
| 431 |
+
points_ref.set({
|
| 432 |
+
"user_id": req.user_id,
|
| 433 |
+
"total_points": total_points,
|
| 434 |
+
"last_earned": points_earned,
|
| 435 |
+
"streak": streak,
|
| 436 |
+
"updated_at": datetime.utcnow().isoformat(),
|
| 437 |
+
})
|
| 438 |
+
|
| 439 |
# Trigger background check for consecutive misses
|
| 440 |
background_tasks.add_task(check_and_simplify, req.user_id, req.plan_id)
|
| 441 |
|
| 442 |
+
return {
|
| 443 |
+
"message": f"Day {req.day} marked as {req.status}",
|
| 444 |
+
"points_earned": points_earned,
|
| 445 |
+
"total_points": total_points,
|
| 446 |
+
"streak": streak,
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
@app.get("/points/{user_id}")
|
| 451 |
+
async def get_points(user_id: str):
|
| 452 |
+
"""Get a user's total points and streak."""
|
| 453 |
+
doc = db.collection("user_points").document(user_id).get()
|
| 454 |
+
if not doc.exists:
|
| 455 |
+
return {"total_points": 0, "streak": 0, "last_earned": 0}
|
| 456 |
+
data = doc.to_dict()
|
| 457 |
+
return {
|
| 458 |
+
"total_points": data.get("total_points", 0),
|
| 459 |
+
"streak": data.get("streak", 0),
|
| 460 |
+
"last_earned": data.get("last_earned", 0),
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
# ββ Profile / Achievements endpoint ββββββββββββββββββββββββββββββββββββββββββ
|
| 465 |
+
|
| 466 |
+
TROPHY_DEFINITIONS = [
|
| 467 |
+
{"id": "first_step", "name": "First Step", "desc": "Complete your first lesson", "icon": "β", "requirement": 1},
|
| 468 |
+
{"id": "getting_started", "name": "Getting Started", "desc": "Complete 5 lessons", "icon": "π", "requirement": 5},
|
| 469 |
+
{"id": "dedicated", "name": "Dedicated Learner", "desc": "Complete 10 lessons", "icon": "π", "requirement": 10},
|
| 470 |
+
{"id": "scholar", "name": "Scholar", "desc": "Complete 25 lessons", "icon": "π", "requirement": 25},
|
| 471 |
+
{"id": "master", "name": "Master", "desc": "Complete 50 lessons", "icon": "π", "requirement": 50},
|
| 472 |
+
{"id": "streak_3", "name": "On Fire", "desc": "Reach a 3-day streak", "icon": "π₯", "requirement": 3, "type": "streak"},
|
| 473 |
+
{"id": "streak_7", "name": "Week Warrior", "desc": "Reach a 7-day streak", "icon": "β‘", "requirement": 7, "type": "streak"},
|
| 474 |
+
{"id": "streak_14", "name": "Unstoppable", "desc": "Reach a 14-day streak", "icon": "π", "requirement": 14, "type": "streak"},
|
| 475 |
+
{"id": "multi_topic", "name": "Explorer", "desc": "Study 3 different topics", "icon": "π§", "requirement": 3, "type": "topics"},
|
| 476 |
+
{"id": "points_100", "name": "Century Club", "desc": "Earn 100 total XP", "icon": "π―", "requirement": 100, "type": "points"},
|
| 477 |
+
{"id": "points_500", "name": "XP Hunter", "desc": "Earn 500 total XP", "icon": "π", "requirement": 500, "type": "points"},
|
| 478 |
+
]
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
@app.get("/profile/{user_id}")
|
| 482 |
+
async def get_profile(user_id: str):
|
| 483 |
+
"""Get full user profile with stats, achievements, and trophies."""
|
| 484 |
+
# Gather points data
|
| 485 |
+
points_doc = db.collection("user_points").document(user_id).get()
|
| 486 |
+
points_data = points_doc.to_dict() if points_doc.exists else {}
|
| 487 |
+
total_points = points_data.get("total_points", 0)
|
| 488 |
+
current_streak = points_data.get("streak", 0)
|
| 489 |
+
best_streak = points_data.get("best_streak", current_streak)
|
| 490 |
+
|
| 491 |
+
# Gather plan stats
|
| 492 |
+
plans = db.collection("learning_plans").where("user_id", "==", user_id).stream()
|
| 493 |
+
total_completed = 0
|
| 494 |
+
total_missed = 0
|
| 495 |
+
total_pending = 0
|
| 496 |
+
total_plans = 0
|
| 497 |
+
topics_studied = set()
|
| 498 |
+
|
| 499 |
+
for doc in plans:
|
| 500 |
+
d = doc.to_dict()
|
| 501 |
+
total_plans += 1
|
| 502 |
+
topic = d.get("topic", "")
|
| 503 |
+
if topic:
|
| 504 |
+
topics_studied.add(topic.lower())
|
| 505 |
+
for task in d.get("schedule", []):
|
| 506 |
+
s = task.get("status", "pending")
|
| 507 |
+
if s == "completed":
|
| 508 |
+
total_completed += 1
|
| 509 |
+
elif s == "missed":
|
| 510 |
+
total_missed += 1
|
| 511 |
+
else:
|
| 512 |
+
total_pending += 1
|
| 513 |
+
|
| 514 |
+
# Calculate stars (1 star per 5 completed lessons)
|
| 515 |
+
stars = total_completed // 5
|
| 516 |
+
|
| 517 |
+
# Determine which trophies are unlocked
|
| 518 |
+
unlocked_trophies = []
|
| 519 |
+
for t in TROPHY_DEFINITIONS:
|
| 520 |
+
trophy_type = t.get("type", "lessons")
|
| 521 |
+
earned = False
|
| 522 |
+
if trophy_type == "streak":
|
| 523 |
+
earned = best_streak >= t["requirement"]
|
| 524 |
+
elif trophy_type == "topics":
|
| 525 |
+
earned = len(topics_studied) >= t["requirement"]
|
| 526 |
+
elif trophy_type == "points":
|
| 527 |
+
earned = total_points >= t["requirement"]
|
| 528 |
+
else: # lessons
|
| 529 |
+
earned = total_completed >= t["requirement"]
|
| 530 |
+
|
| 531 |
+
unlocked_trophies.append({
|
| 532 |
+
"id": t["id"],
|
| 533 |
+
"name": t["name"],
|
| 534 |
+
"desc": t["desc"],
|
| 535 |
+
"icon": t["icon"],
|
| 536 |
+
"unlocked": earned,
|
| 537 |
+
})
|
| 538 |
+
|
| 539 |
+
# Update best_streak if current is higher
|
| 540 |
+
if current_streak > best_streak:
|
| 541 |
+
best_streak = current_streak
|
| 542 |
+
db.collection("user_points").document(user_id).set(
|
| 543 |
+
{"best_streak": best_streak}, merge=True
|
| 544 |
+
)
|
| 545 |
+
|
| 546 |
+
return {
|
| 547 |
+
"user_id": user_id,
|
| 548 |
+
"total_points": total_points,
|
| 549 |
+
"stars": stars,
|
| 550 |
+
"current_streak": current_streak,
|
| 551 |
+
"best_streak": best_streak,
|
| 552 |
+
"total_completed": total_completed,
|
| 553 |
+
"total_missed": total_missed,
|
| 554 |
+
"total_pending": total_pending,
|
| 555 |
+
"total_plans": total_plans,
|
| 556 |
+
"topics_studied": list(topics_studied),
|
| 557 |
+
"trophies": unlocked_trophies,
|
| 558 |
+
}
|
| 559 |
|
| 560 |
|
| 561 |
@app.get("/plan/{plan_id}")
|
|
|
|
| 631 |
return {"message": "Plan deleted"}
|
| 632 |
|
| 633 |
|
| 634 |
+
# ββ Quiz endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 635 |
+
|
| 636 |
+
@app.post("/generate-quiz")
|
| 637 |
+
async def generate_quiz(req: QuizRequest):
|
| 638 |
+
"""Generate AI quiz questions for a topic."""
|
| 639 |
+
prompt = QUIZ_PROMPT_TEMPLATE.format(
|
| 640 |
+
topic=req.topic,
|
| 641 |
+
skill_level=req.skill_level,
|
| 642 |
+
num_questions=req.num_questions,
|
| 643 |
+
)
|
| 644 |
+
response = client.chat.completions.create(
|
| 645 |
+
model=LLM_MODEL,
|
| 646 |
+
messages=[{"role": "user", "content": prompt}],
|
| 647 |
+
)
|
| 648 |
+
quiz_data = parse_llm_json(response.choices[0].message.content)
|
| 649 |
+
questions = quiz_data.get("questions", quiz_data) if isinstance(quiz_data, dict) else quiz_data
|
| 650 |
+
# Validate
|
| 651 |
+
validated = []
|
| 652 |
+
for q in questions:
|
| 653 |
+
validated.append(QuizQuestion(**q).model_dump())
|
| 654 |
+
return {"topic": req.topic, "questions": validated}
|
| 655 |
+
|
| 656 |
+
|
| 657 |
+
@app.post("/submit-quiz")
|
| 658 |
+
async def submit_quiz(req: QuizSubmission):
|
| 659 |
+
"""Submit quiz answers, calculate score, award bonus XP."""
|
| 660 |
+
doc = db.collection("learning_plans").document(req.plan_id).get()
|
| 661 |
+
if not doc.exists:
|
| 662 |
+
raise HTTPException(status_code=404, detail="Plan not found")
|
| 663 |
+
|
| 664 |
+
# Find the day's topic to fetch from quiz cache or regenerate
|
| 665 |
+
data = doc.to_dict()
|
| 666 |
+
schedule = data.get("schedule", [])
|
| 667 |
+
day_task = next((t for t in schedule if int(t["day"]) == req.day), None)
|
| 668 |
+
if not day_task:
|
| 669 |
+
raise HTTPException(status_code=400, detail="Day not found")
|
| 670 |
+
|
| 671 |
+
# Score calculation (we trust the client sends correct number of answers)
|
| 672 |
+
# Award bonus XP based on score
|
| 673 |
+
correct_count = len([a for a in req.answers if a >= 0]) # placeholder
|
| 674 |
+
score_percent = (correct_count / max(len(req.answers), 1)) * 100
|
| 675 |
+
|
| 676 |
+
# Award bonus XP for quiz completion
|
| 677 |
+
bonus_xp = 5 # base quiz completion bonus
|
| 678 |
+
if score_percent >= 80:
|
| 679 |
+
bonus_xp = 15 # excellence bonus
|
| 680 |
+
elif score_percent >= 60:
|
| 681 |
+
bonus_xp = 10 # good score bonus
|
| 682 |
+
|
| 683 |
+
# Update user points
|
| 684 |
+
points_ref = db.collection("user_points").document(req.user_id)
|
| 685 |
+
points_doc = points_ref.get()
|
| 686 |
+
if points_doc.exists:
|
| 687 |
+
current = points_doc.to_dict().get("total_points", 0)
|
| 688 |
+
points_ref.update({
|
| 689 |
+
"total_points": current + bonus_xp,
|
| 690 |
+
"updated_at": datetime.utcnow().isoformat(),
|
| 691 |
+
})
|
| 692 |
+
total = current + bonus_xp
|
| 693 |
+
else:
|
| 694 |
+
points_ref.set({
|
| 695 |
+
"user_id": req.user_id,
|
| 696 |
+
"total_points": bonus_xp,
|
| 697 |
+
"updated_at": datetime.utcnow().isoformat(),
|
| 698 |
+
})
|
| 699 |
+
total = bonus_xp
|
| 700 |
+
|
| 701 |
+
return {
|
| 702 |
+
"score_percent": score_percent,
|
| 703 |
+
"bonus_xp": bonus_xp,
|
| 704 |
+
"total_points": total,
|
| 705 |
+
"message": f"Quiz completed! +{bonus_xp} XP"
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
|
| 709 |
+
# ββ Leaderboard endpoint ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 710 |
+
|
| 711 |
+
@app.get("/leaderboard")
|
| 712 |
+
async def get_leaderboard(limit: int = 20):
|
| 713 |
+
"""Get top users by XP."""
|
| 714 |
+
docs = db.collection("user_points").stream()
|
| 715 |
+
users = []
|
| 716 |
+
for doc in docs:
|
| 717 |
+
d = doc.to_dict()
|
| 718 |
+
user_id = d.get("user_id", doc.id)
|
| 719 |
+
|
| 720 |
+
# Try to get display name from Firebase Auth
|
| 721 |
+
display_name = None
|
| 722 |
+
try:
|
| 723 |
+
from firebase_admin import auth
|
| 724 |
+
user_record = auth.get_user(user_id)
|
| 725 |
+
display_name = user_record.display_name or user_record.email
|
| 726 |
+
except Exception:
|
| 727 |
+
pass
|
| 728 |
+
|
| 729 |
+
if not display_name:
|
| 730 |
+
display_name = f"Learner_{user_id[:6]}"
|
| 731 |
+
|
| 732 |
+
users.append({
|
| 733 |
+
"user_id": user_id,
|
| 734 |
+
"display_name": display_name,
|
| 735 |
+
"total_points": d.get("total_points", 0),
|
| 736 |
+
"streak": d.get("streak", 0),
|
| 737 |
+
"best_streak": d.get("best_streak", 0),
|
| 738 |
+
})
|
| 739 |
+
|
| 740 |
+
# Sort by points descending
|
| 741 |
+
users.sort(key=lambda u: u["total_points"], reverse=True)
|
| 742 |
+
return {"leaderboard": users[:limit]}
|
| 743 |
+
|
| 744 |
+
|
| 745 |
+
# ββ Analytics endpoint βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 746 |
+
|
| 747 |
+
@app.get("/analytics/{user_id}")
|
| 748 |
+
async def get_analytics(user_id: str):
|
| 749 |
+
"""Get learning analytics β daily completion rates, points history, etc."""
|
| 750 |
+
plans = db.collection("learning_plans").where("user_id", "==", user_id).stream()
|
| 751 |
+
|
| 752 |
+
daily_stats = [] # list of {day, status, topic, plan_topic}
|
| 753 |
+
plan_summaries = []
|
| 754 |
+
total_minutes_studied = 0
|
| 755 |
+
|
| 756 |
+
for doc in plans:
|
| 757 |
+
d = doc.to_dict()
|
| 758 |
+
plan_topic = d.get("topic", "Unknown")
|
| 759 |
+
schedule = d.get("schedule", [])
|
| 760 |
+
completed_count = 0
|
| 761 |
+
missed_count = 0
|
| 762 |
+
|
| 763 |
+
for task in schedule:
|
| 764 |
+
status = task.get("status", "pending")
|
| 765 |
+
if status == "completed":
|
| 766 |
+
completed_count += 1
|
| 767 |
+
total_minutes_studied += task.get("duration_mins", 0)
|
| 768 |
+
elif status == "missed":
|
| 769 |
+
missed_count += 1
|
| 770 |
+
|
| 771 |
+
daily_stats.append({
|
| 772 |
+
"day": task.get("day", 0),
|
| 773 |
+
"status": status,
|
| 774 |
+
"topic": task.get("topic", ""),
|
| 775 |
+
"plan_topic": plan_topic,
|
| 776 |
+
"duration_mins": task.get("duration_mins", 0),
|
| 777 |
+
})
|
| 778 |
+
|
| 779 |
+
plan_summaries.append({
|
| 780 |
+
"topic": plan_topic,
|
| 781 |
+
"total_days": len(schedule),
|
| 782 |
+
"completed": completed_count,
|
| 783 |
+
"missed": missed_count,
|
| 784 |
+
"pending": len(schedule) - completed_count - missed_count,
|
| 785 |
+
"completion_rate": round(completed_count / max(len(schedule), 1) * 100, 1),
|
| 786 |
+
})
|
| 787 |
+
|
| 788 |
+
# Overall stats
|
| 789 |
+
total_tasks = len(daily_stats)
|
| 790 |
+
total_completed = sum(1 for d in daily_stats if d["status"] == "completed")
|
| 791 |
+
total_missed = sum(1 for d in daily_stats if d["status"] == "missed")
|
| 792 |
+
|
| 793 |
+
return {
|
| 794 |
+
"total_tasks": total_tasks,
|
| 795 |
+
"total_completed": total_completed,
|
| 796 |
+
"total_missed": total_missed,
|
| 797 |
+
"total_pending": total_tasks - total_completed - total_missed,
|
| 798 |
+
"completion_rate": round(total_completed / max(total_tasks, 1) * 100, 1),
|
| 799 |
+
"total_minutes_studied": total_minutes_studied,
|
| 800 |
+
"plan_summaries": plan_summaries,
|
| 801 |
+
"daily_stats": daily_stats,
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
|
| 805 |
+
# ββ Reminder settings endpoint ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 806 |
+
|
| 807 |
+
@app.post("/reminder-settings")
|
| 808 |
+
async def save_reminder_settings(req: ReminderSettings):
|
| 809 |
+
"""Save user's reminder preferences."""
|
| 810 |
+
db.collection("reminder_settings").document(req.user_id).set({
|
| 811 |
+
"user_id": req.user_id,
|
| 812 |
+
"enabled": req.enabled,
|
| 813 |
+
"hour": req.hour,
|
| 814 |
+
"minute": req.minute,
|
| 815 |
+
"updated_at": datetime.utcnow().isoformat(),
|
| 816 |
+
})
|
| 817 |
+
return {"message": "Reminder settings saved"}
|
| 818 |
+
|
| 819 |
+
|
| 820 |
+
@app.get("/reminder-settings/{user_id}")
|
| 821 |
+
async def get_reminder_settings(user_id: str):
|
| 822 |
+
doc = db.collection("reminder_settings").document(user_id).get()
|
| 823 |
+
if not doc.exists:
|
| 824 |
+
return {"enabled": True, "hour": 9, "minute": 0}
|
| 825 |
+
d = doc.to_dict()
|
| 826 |
+
return {"enabled": d.get("enabled", True), "hour": d.get("hour", 9), "minute": d.get("minute", 0)}
|
| 827 |
+
|
| 828 |
+
|
| 829 |
if __name__ == "__main__":
|
| 830 |
import uvicorn
|
| 831 |
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|