mathpulse-api-v3test / services /tutor_nudge_service.py
github-actions[bot]
🚀 Auto-deploy backend from GitHub (3efade4)
8b6568e
"""
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"),
)