Python 64.6%
TypeScript 33.7%
CSS 0.8%
1"""Anonymised analytics for the professor dashboard (no raw message content stored)."""23from __future__ import annotations45import json6import re7from datetime import UTC, datetime, timedelta8from typing import Any910from sqlalchemy import func, select1112from app.core.logging import get_logger13from app.db import SessionLocal14from app.llm.openrouter import get_llm15from app.llm.router import router16from app.models import AnalyticsEvent, Conversation, Message, User1718log = get_logger("analytics")1920CLASSIFY_PROMPT = """Classe la question d'un étudiant en évaluation immobilière. Réponds en JSON strict :21{"topic": "<notion du cours en 2-5 mots, ex. 'dépréciation âge-vie', 'principes de la valeur',22'méthode de comparaison', 'valeur du terrain', 'coûts indirects', 'rapport d'évaluation', 'UMPP',23'six fonctions du dollar', 'rôle d'évaluation', 'hors sujet'>",24 "reformulation": "<la question reformulée de façon générique et anonyme, sans nom, adresse, code25permanent ni détail personnel, max 25 mots>"}"""262728async def classify_and_record(user_id_hash: str, course: str, question: str) -> None:29 """Background: classify topic with MODEL_FAST and store an anonymised sample."""30 topic, sample = "", ""31 try:32 plan = router.plan("fast")33 text, _ = await get_llm().complete(34 [{"role": "system", "content": CLASSIFY_PROMPT},35 {"role": "user", "content": question[:1500]}],36 plan.models, temperature=0.0, max_tokens=150,37 response_format={"type": "json_object"}, user_id_hash=user_id_hash)38 m = re.search(r"\{.*\}", text, re.S)39 data = json.loads(m.group(0)) if m else {}40 topic = str(data.get("topic", ""))[:120]41 sample = str(data.get("reformulation", ""))[:400]42 except Exception as exc: # noqa: BLE00143 log.warning("classify_failed", error=str(exc))44 async with SessionLocal() as session:45 session.add(AnalyticsEvent(user_id_hash=user_id_hash, course_code=course,46 event_type="question", topic=topic, question_sample=sample))47 await session.commit()484950async def record_event(user_id_hash: str, course: str, event_type: str, topic: str = "") -> None:51 async with SessionLocal() as session:52 session.add(AnalyticsEvent(user_id_hash=user_id_hash, course_code=course,53 event_type=event_type, topic=topic))54 await session.commit()555657async def dashboard(days: int = 30) -> dict[str, Any]:58 since = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=days)59 async with SessionLocal() as session:60 day = func.date(Message.created_at)61 per_day = await session.execute(62 select(day, func.count()).where(Message.created_at >= since, Message.role == "user")63 .group_by(day).order_by(day))64 active_students = await session.scalar(65 select(func.count(func.distinct(AnalyticsEvent.user_id_hash)))66 .where(AnalyticsEvent.created_at >= since))67 total_users = await session.scalar(select(func.count(User.id)))68 hours = await session.execute(69 select(func.strftime("%H", Message.created_at), func.count())70 .where(Message.created_at >= since, Message.role == "user")71 .group_by(func.strftime("%H", Message.created_at)))72 topics = await session.execute(73 select(AnalyticsEvent.topic, AnalyticsEvent.course_code, func.count())74 .where(AnalyticsEvent.created_at >= since, AnalyticsEvent.event_type == "question",75 AnalyticsEvent.topic != "")76 .group_by(AnalyticsEvent.topic, AnalyticsEvent.course_code)77 .order_by(func.count().desc()).limit(15))78 samples = await session.execute(79 select(AnalyticsEvent.question_sample, AnalyticsEvent.topic, AnalyticsEvent.course_code,80 AnalyticsEvent.created_at)81 .where(AnalyticsEvent.created_at >= since, AnalyticsEvent.question_sample != "")82 .order_by(AnalyticsEvent.created_at.desc()).limit(40))83 feedback = await session.execute(84 select(Message.feedback, func.count()).where(Message.created_at >= since,85 Message.feedback.is_not(None))86 .group_by(Message.feedback))87 conv_count = await session.scalar(select(func.count(Conversation.id))88 .where(Conversation.created_at >= since))89 tool_usage = await session.execute(90 select(AnalyticsEvent.topic, func.count())91 .where(AnalyticsEvent.created_at >= since, AnalyticsEvent.event_type == "tool")92 .group_by(AnalyticsEvent.topic))93 return {94 "days": days,95 "messages_per_day": [{"day": str(d), "count": int(n)} for d, n in per_day],96 "active_students": int(active_students or 0),97 "total_users": int(total_users or 0),98 "conversations": int(conv_count or 0),99 "peak_hours": [{"hour": int(h), "count": int(n)} for h, n in hours if h is not None],100 "top_topics": [{"topic": t, "course": c, "count": int(n)} for t, c, n in topics],101 "question_samples": [{"question": q, "topic": t, "course": c, "at": a.isoformat()}102 for q, t, c, a in samples],103 "feedback": {str(f): int(n) for f, n in feedback},104 "tool_usage": [{"tool": t, "count": int(n)} for t, n in tool_usage],105 }106