Spaces:
Running
Running
| """ | |
| MathPulse AI — Tutor Nudge Service | |
| Generates proactive AI tutor nudges for at-risk students using DeepSeek, | |
| then writes them to Firestore for the floating tutor to surface. | |
| """ | |
| import logging | |
| from datetime import datetime, timezone | |
| logger = logging.getLogger("mathpulse.tutor_nudge") | |
| NUDGE_COOLDOWN_HOURS = 24 | |
| SYSTEM_PROMPT = ( | |
| "You are MathPulse's AI tutor. Write a single short, friendly message " | |
| "to nudge the student to work on their weakest topic. " | |
| "No long explanation, just a nudge plus a concrete action. " | |
| "1-2 sentences max. No code, no LaTeX. Be warm and encouraging." | |
| ) | |
| def _get_db(): | |
| try: | |
| from firebase_admin import firestore as ff | |
| return ff.client() | |
| except Exception: | |
| return None | |
| def _has_recent_nudge(db, student_id: str, topic: str) -> bool: | |
| """Check if an unconsumed nudge for this topic exists within cooldown.""" | |
| from datetime import timedelta | |
| cutoff = datetime.now(timezone.utc) - timedelta(hours=NUDGE_COOLDOWN_HOURS) | |
| nudges_ref = db.collection("tutorNudges").document(student_id).collection("nudges") | |
| existing = ( | |
| nudges_ref | |
| .where("topic", "==", topic) | |
| .where("createdAt", ">=", cutoff) | |
| .limit(1) | |
| .get() | |
| ) | |
| return len(existing) > 0 | |
| async def generate_tutor_nudge_for_student( | |
| student_id: str, | |
| weak_topics: list[str], | |
| grade_level: str = "Grade 11", | |
| recent_score: float | None = None, | |
| ) -> dict | None: | |
| """Generate a nudge message via DeepSeek and write to Firestore.""" | |
| if not weak_topics: | |
| return None | |
| db = _get_db() | |
| if not db: | |
| logger.warning("Firestore unavailable, skipping nudge generation") | |
| return None | |
| # Pick the first weak topic that doesn't have a recent nudge | |
| topic = None | |
| for t in weak_topics[:3]: | |
| if not _has_recent_nudge(db, student_id, t): | |
| topic = t | |
| break | |
| if not topic: | |
| return None # All topics have recent nudges | |
| # Generate nudge via DeepSeek | |
| try: | |
| from services.ai_client import get_deepseek_client, CHAT_MODEL | |
| client = get_deepseek_client() | |
| user_content = ( | |
| f"Student grade: {grade_level}. " | |
| f"Weak topic: {topic}. " | |
| f"{'Recent score: ' + str(round(recent_score)) + '%.' if recent_score else ''} " | |
| f"Write a short nudge to encourage them to practice this topic." | |
| ) | |
| response = client.chat.completions.create( | |
| model=CHAT_MODEL, | |
| messages=[ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": user_content}, | |
| ], | |
| temperature=0.7, | |
| max_tokens=100, | |
| ) | |
| message = (response.choices[0].message.content or "").strip() | |
| if not message: | |
| return None | |
| except Exception as e: | |
| logger.error(f"DeepSeek nudge generation failed for {student_id}: {e}") | |
| return None | |
| # Write to Firestore | |
| nudge_data = { | |
| "message": message, | |
| "topic": topic, | |
| "createdAt": datetime.now(timezone.utc), | |
| "consumed": False, | |
| } | |
| try: | |
| db.collection("tutorNudges").document(student_id).collection("nudges").add(nudge_data) | |
| logger.info(f"Nudge written for {student_id}: topic={topic}") | |
| except Exception as e: | |
| logger.error(f"Failed to write nudge for {student_id}: {e}") | |
| return None | |
| return {"message": message, "topic": topic, "created_at": nudge_data["createdAt"].isoformat()} | |
| async def check_and_generate_nudge(student_id: str) -> dict | None: | |
| """ | |
| Check if a student already has risk data warranting a nudge, | |
| and generate one if no recent unconsumed nudge exists. | |
| Used for students who completed diagnostics but have no new pipeline events. | |
| """ | |
| db = _get_db() | |
| if not db: | |
| return None | |
| # Read existing risk profile from managedStudents | |
| managed_ref = db.collection("managedStudents").document(student_id) | |
| managed_snap = managed_ref.get() | |
| if not managed_snap.exists: | |
| return None | |
| data = managed_snap.to_dict() or {} | |
| risk_status = data.get("riskStatus") | |
| if risk_status not in ("watch", "intervene", "critical", "at_risk"): | |
| return None | |
| # Get weak topics from student_profiles | |
| profile_ref = db.collection("student_profiles").document(student_id) | |
| profile_snap = profile_ref.get() | |
| weak_topics: list[str] = [] | |
| if profile_snap.exists: | |
| profile = profile_snap.to_dict() or {} | |
| weak_topics = ( | |
| profile.get("quiz_performance", {}).get("lowest_accuracy_topics", []) | |
| or profile.get("diagnostic", {}).get("weak_topics", []) | |
| ) | |
| if not weak_topics: | |
| # Fallback: use atRiskSubjects from user doc | |
| user_ref = db.collection("users").document(student_id) | |
| user_snap = user_ref.get() | |
| if user_snap.exists: | |
| weak_topics = (user_snap.to_dict() or {}).get("atRiskSubjects", []) | |
| if not weak_topics: | |
| return None | |
| return await generate_tutor_nudge_for_student( | |
| student_id=student_id, | |
| weak_topics=weak_topics[:3], | |
| grade_level=data.get("grade", "Grade 11"), | |
| recent_score=data.get("systemPerformanceAvg") or data.get("diagnosticScore"), | |
| ) | |