github-actions[bot] commited on
Commit
cc25b3c
ยท
1 Parent(s): d0ce97d

๐Ÿš€ Auto-deploy backend from GitHub (f081589)

Browse files
Dockerfile CHANGED
@@ -1,35 +1,34 @@
1
- FROM python:3.11-slim
2
-
3
- ENV PYTHONDONTWRITEBYTECODE=1 \
4
- PYTHONUNBUFFERED=1 \
5
- PIP_DISABLE_PIP_VERSION_CHECK=1 \
6
- PIP_NO_CACHE_DIR=1 \
7
- HF_HOME=/data/.huggingface \
8
- HUGGINGFACE_HUB_CACHE=/data/.huggingface/hub \
9
- TRANSFORMERS_CACHE=/data/.huggingface/transformers \
10
- MPLCONFIGDIR=/tmp/matplotlib
11
-
12
- WORKDIR /app
13
-
14
- # Keep OS layer minimal and wheel-friendly.
15
- RUN apt-get update && apt-get install -y --no-install-recommends \
16
- ca-certificates \
17
- && rm -rf /var/lib/apt/lists/*
18
-
19
- # Dependency layer first for better cache reuse.
20
- COPY requirements.txt /app/requirements.txt
21
- RUN python -m pip install --upgrade pip setuptools wheel && \
22
- python -m pip install --prefer-binary --retries 5 -r /app/requirements.txt
23
-
24
- # Copy only runtime sources to reduce invalidation surface.
25
- COPY main.py /app/main.py
26
- COPY startup_validation.py /app/startup_validation.py
27
- COPY analytics.py /app/analytics.py
28
- COPY automation_engine.py /app/automation_engine.py
29
- COPY services /app/services
30
- COPY models /app/models
31
- COPY config /app/config
32
-
33
- EXPOSE 7860
34
-
35
- CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
6
+ PIP_NO_CACHE_DIR=1 \
7
+ HF_HOME=/data/.huggingface \
8
+ HUGGINGFACE_HUB_CACHE=/data/.huggingface/hub \
9
+ TRANSFORMERS_CACHE=/data/.huggingface/transformers \
10
+ MPLCONFIGDIR=/tmp/matplotlib
11
+
12
+ WORKDIR /app
13
+
14
+ # Keep OS layer minimal and wheel-friendly.
15
+ RUN apt-get update && apt-get install -y --no-install-recommends \
16
+ ca-certificates \
17
+ && rm -rf /var/lib/apt/lists/*
18
+
19
+ # Dependency layer first for better cache reuse.
20
+ COPY requirements.txt /app/requirements.txt
21
+ RUN python -m pip install --upgrade pip setuptools wheel && \
22
+ python -m pip install --prefer-binary --retries 5 -r /app/requirements.txt
23
+
24
+ # Copy only runtime sources to reduce invalidation surface.
25
+ COPY main.py /app/main.py
26
+ COPY analytics.py /app/analytics.py
27
+ COPY automation_engine.py /app/automation_engine.py
28
+ COPY services /app/services
29
+ COPY models /app/models
30
+ COPY config /app/config
31
+
32
+ EXPOSE 7860
33
+
34
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
 
analytics.py CHANGED
The diff for this file is too large to render. See raw diff
 
automation_engine.py CHANGED
@@ -1,693 +1,693 @@
1
- """
2
- MathPulse AI - Event-Driven Automation Engine
3
-
4
- Processes educational workflows based on a diagnostic-first, risk-driven
5
- intervention model. Trigger points:
6
-
7
- 1. Diagnostic Assessment Completion (highest priority)
8
- 2. Quiz / Assessment Submission (continuous)
9
- 3. New Student Enrollment
10
- 4. External Data Import (teacher action)
11
- 5. Admin Content Updates
12
-
13
- Each event is routed to a dedicated handler that orchestrates
14
- classification, quiz generation, notifications and dashboard updates.
15
- """
16
-
17
- import os
18
- import json
19
- import math
20
- import logging
21
- import traceback
22
- from typing import List, Optional, Dict, Any, Tuple
23
- from datetime import datetime, timedelta
24
-
25
- from pydantic import BaseModel, Field
26
-
27
- logger = logging.getLogger("mathpulse.automation")
28
-
29
- # โ”€โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
30
-
31
- AT_RISK_THRESHOLD = 60 # < 60 % โ†’ At Risk
32
- WEAK_TOPIC_THRESHOLD = 0.50 # < 50 % accuracy โ†’ weak topic
33
- HIGH_RISK_RATIO = 0.75 # 75 %+ subjects at risk
34
- MEDIUM_RISK_RATIO = 0.50 # 50-75 %
35
-
36
- REMEDIAL_CONFIG = {
37
- "High": {"questions": 15, "dist": {"easy": 60, "medium": 30, "hard": 10}},
38
- "Medium": {"questions": 12, "dist": {"easy": 50, "medium": 35, "hard": 15}},
39
- "Low": {"questions": 10, "dist": {"easy": 40, "medium": 40, "hard": 20}},
40
- }
41
-
42
- # โ”€โ”€โ”€ Request / Response Models โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
43
-
44
-
45
- class DiagnosticResult(BaseModel):
46
- """Per-subject score from diagnostic assessment."""
47
- subject: str
48
- score: float = Field(..., ge=0, le=100)
49
-
50
-
51
- class DiagnosticCompletionPayload(BaseModel):
52
- """Payload sent when a student completes the diagnostic."""
53
- studentId: str
54
- results: List[DiagnosticResult]
55
- gradeLevel: str = "Grade 10"
56
- questionBreakdown: Optional[Dict[str, list]] = None # topic โ†’ [{correct: bool, โ€ฆ}]
57
-
58
-
59
- class QuizSubmissionPayload(BaseModel):
60
- """Payload sent on quiz / assessment submission."""
61
- studentId: str
62
- quizId: str
63
- subject: str
64
- score: float = Field(..., ge=0, le=100)
65
- totalQuestions: int
66
- correctAnswers: int
67
- timeSpentSeconds: int
68
- answers: Optional[List[Dict[str, Any]]] = None
69
-
70
-
71
- class StudentEnrollmentPayload(BaseModel):
72
- """Payload sent when a new student account is created."""
73
- studentId: str
74
- name: str
75
- email: str
76
- gradeLevel: str = "Grade 10"
77
- teacherId: Optional[str] = None
78
-
79
-
80
- class DataImportPayload(BaseModel):
81
- """Payload sent after a teacher uploads a spreadsheet."""
82
- teacherId: str
83
- students: List[Dict[str, Any]] # parsed student rows
84
- columnMapping: Dict[str, str]
85
-
86
-
87
- class ContentUpdatePayload(BaseModel):
88
- """Payload sent when admin performs CRUD on curriculum."""
89
- adminId: str
90
- action: str # create | update | delete
91
- contentType: str # lesson | quiz | module | subject
92
- contentId: str
93
- subjectId: Optional[str] = None
94
- details: Optional[str] = None
95
-
96
-
97
- # โ”€โ”€โ”€ Risk classification helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
98
-
99
-
100
- class SubjectRiskClassification(BaseModel):
101
- status: str # "At Risk" | "On Track"
102
- score: float
103
- confidence: float
104
- needsIntervention: bool
105
-
106
-
107
- class AutomationResult(BaseModel):
108
- """Standardised result returned by every handler."""
109
- success: bool
110
- event: str
111
- studentId: Optional[str] = None
112
- message: str
113
- riskClassifications: Optional[Dict[str, Dict[str, Any]]] = None
114
- overallRisk: Optional[str] = None
115
- atRiskSubjects: Optional[List[str]] = None
116
- weakTopics: Optional[List[Dict[str, Any]]] = None
117
- learningPath: Optional[str] = None
118
- remedialQuizzesCreated: int = 0
119
- interventions: Optional[str] = None
120
- notifications: List[str] = Field(default_factory=list)
121
-
122
-
123
- # โ”€โ”€โ”€ Automation Engine โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
124
-
125
-
126
- class MathPulseAutomationEngine:
127
- """
128
- Stateless event-driven automation system.
129
-
130
- Each ``handle_*`` method is an independent, self-contained handler that
131
- receives a validated Pydantic payload and returns an ``AutomationResult``.
132
- Firebase / Hugging Face calls are only attempted when available.
133
- """
134
-
135
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
136
- # 1. DIAGNOSTIC COMPLETION (highest-priority)
137
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€๏ฟฝ๏ฟฝ๏ฟฝโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
138
-
139
- async def handle_diagnostic_completion(
140
- self, payload: DiagnosticCompletionPayload
141
- ) -> AutomationResult:
142
- """
143
- Runs when a student completes the mandatory diagnostic.
144
-
145
- Steps:
146
- 1. Classify per-subject risk
147
- 2. Identify weak topics
148
- 3. Compute overall risk
149
- 4. Generate personalised learning path (AI)
150
- 5. Create remedial quiz assignments
151
- 6. Generate teacher intervention recommendations (AI)
152
- 7. Persist everything & notify
153
- """
154
- student_id = payload.studentId
155
- logger.info(f"๐Ÿ“Š DIAGNOSTIC COMPLETED for {student_id}")
156
- notifications: list[str] = []
157
-
158
- # 1 โ€” subject-level risk
159
- risk_classifications = self._classify_subject_risks(payload.results)
160
-
161
- # 2 โ€” weak topics
162
- weak_topics = self._identify_weak_topics(payload.questionBreakdown)
163
-
164
- # 3 โ€” overall risk
165
- overall_risk = self._calculate_overall_risk(risk_classifications)
166
-
167
- at_risk_subjects = [
168
- subj for subj, data in risk_classifications.items()
169
- if data["status"] == "At Risk"
170
- ]
171
-
172
- # 4 โ€” learning path (AI call)
173
- learning_path: Optional[str] = None
174
- if at_risk_subjects:
175
- learning_path = await self._generate_learning_path(
176
- at_risk_subjects, weak_topics, payload.gradeLevel
177
- )
178
-
179
- # 5 โ€” remedial quizzes
180
- remedial_count = 0
181
- remedial_quizzes: list[dict] = []
182
- if at_risk_subjects:
183
- remedial_quizzes = self._build_remedial_quiz_configs(
184
- student_id, at_risk_subjects, overall_risk, payload.gradeLevel
185
- )
186
- remedial_count = len(remedial_quizzes)
187
-
188
- # 6 โ€” teacher interventions (AI call)
189
- interventions: Optional[str] = None
190
- if at_risk_subjects:
191
- interventions = await self._generate_teacher_interventions(
192
- risk_classifications, weak_topics
193
- )
194
-
195
- # 7 โ€” notification messages
196
- if at_risk_subjects:
197
- notifications.append(
198
- f"Diagnostic complete โ€” {len(at_risk_subjects)} subject(s) flagged At Risk: "
199
- + ", ".join(at_risk_subjects)
200
- )
201
- else:
202
- notifications.append("Diagnostic complete โ€” all subjects On Track!")
203
-
204
- logger.info(
205
- f"โœ… DIAGNOSTIC PROCESSING COMPLETE for {student_id} | "
206
- f"Overall={overall_risk} | AtRisk={at_risk_subjects}"
207
- )
208
-
209
- return AutomationResult(
210
- success=True,
211
- event="diagnostic_completed",
212
- studentId=student_id,
213
- message=f"Diagnostic processed for {student_id}",
214
- riskClassifications=risk_classifications,
215
- overallRisk=overall_risk,
216
- atRiskSubjects=at_risk_subjects,
217
- weakTopics=weak_topics,
218
- learningPath=learning_path,
219
- remedialQuizzesCreated=remedial_count,
220
- interventions=interventions,
221
- notifications=notifications,
222
- )
223
-
224
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
225
- # 2. QUIZ SUBMISSION (continuous)
226
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
227
-
228
- async def handle_quiz_submission(
229
- self, payload: QuizSubmissionPayload
230
- ) -> AutomationResult:
231
- """Recalculate risk for a subject after a quiz is submitted."""
232
- student_id = payload.studentId
233
- logger.info(f"๐Ÿ“ QUIZ SUBMITTED by {student_id} โ€” {payload.subject} ({payload.score}%)")
234
- notifications: list[str] = []
235
-
236
- # Determine new status for this subject
237
- new_status = "At Risk" if payload.score < AT_RISK_THRESHOLD else "On Track"
238
- confidence = (
239
- (AT_RISK_THRESHOLD - payload.score) / AT_RISK_THRESHOLD
240
- if new_status == "At Risk"
241
- else (payload.score - AT_RISK_THRESHOLD) / (100 - AT_RISK_THRESHOLD)
242
- )
243
-
244
- risk_classifications = {
245
- payload.subject: {
246
- "status": new_status,
247
- "score": payload.score,
248
- "confidence": round(abs(confidence), 2),
249
- "needsIntervention": new_status == "At Risk",
250
- }
251
- }
252
-
253
- at_risk = [payload.subject] if new_status == "At Risk" else []
254
-
255
- if new_status == "At Risk":
256
- notifications.append(
257
- f"Quiz result: {payload.subject} scored {payload.score}% โ€” status changed to At Risk"
258
- )
259
- else:
260
- notifications.append(
261
- f"Quiz result: {payload.subject} scored {payload.score}% โ€” On Track"
262
- )
263
-
264
- return AutomationResult(
265
- success=True,
266
- event="quiz_submitted",
267
- studentId=student_id,
268
- message=f"Quiz processed for {student_id}",
269
- riskClassifications=risk_classifications,
270
- overallRisk=None, # single-subject update โ€” overall recalculated on frontend
271
- atRiskSubjects=at_risk,
272
- notifications=notifications,
273
- )
274
-
275
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
276
- # 3. STUDENT ENROLLMENT
277
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
278
-
279
- async def handle_student_enrollment(
280
- self, payload: StudentEnrollmentPayload
281
- ) -> AutomationResult:
282
- """
283
- Prepare a new student:
284
- - Create empty progress record skeleton
285
- - Initialise gamification (XP 0, Level 1, no streaks)
286
- - Flag as needing diagnostic
287
- """
288
- student_id = payload.studentId
289
- logger.info(f"๐Ÿ†• NEW STUDENT ENROLLED: {student_id}")
290
-
291
- progress_skeleton = {
292
- "userId": student_id,
293
- "subjects": {},
294
- "lessons": {},
295
- "quizAttempts": [],
296
- "totalLessonsCompleted": 0,
297
- "totalQuizzesCompleted": 0,
298
- "averageScore": 0,
299
- }
300
-
301
- gamification_init = {
302
- "level": 1,
303
- "currentXP": 0,
304
- "totalXP": 0,
305
- "streak": 0,
306
- "hasTakenDiagnostic": False,
307
- "atRiskSubjects": [],
308
- }
309
-
310
- notifications: list[str] = [
311
- f"Welcome {payload.name}! Please complete the diagnostic assessment to personalise your learning path.",
312
- ]
313
-
314
- if payload.teacherId:
315
- notifications.append(
316
- f"New student {payload.name} enrolled โ€” diagnostic pending."
317
- )
318
-
319
- return AutomationResult(
320
- success=True,
321
- event="student_enrolled",
322
- studentId=student_id,
323
- message=f"Student {payload.name} enrolled and initialised",
324
- notifications=notifications,
325
- )
326
-
327
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
328
- # 4. DATA IMPORT (teacher action)
329
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
330
-
331
- async def handle_data_import(
332
- self, payload: DataImportPayload
333
- ) -> AutomationResult:
334
- """
335
- After a teacher uploads a spreadsheet, recalculate risk for every
336
- imported student and flag any status changes.
337
- """
338
- logger.info(f"๐Ÿ“‚ DATA IMPORT by teacher {payload.teacherId} โ€” {len(payload.students)} students")
339
- notifications: list[str] = []
340
- high_risk_students: list[str] = []
341
- medium_risk_count = 0
342
- low_risk_count = 0
343
- weak_topic_counts: Dict[str, int] = {}
344
-
345
- for student_row in payload.students:
346
- name = str(student_row.get("name") or "Unknown").strip() or "Unknown"
347
- avg_score = self._safe_float(student_row.get("avgQuizScore"), 0.0)
348
- attendance = self._safe_float(student_row.get("attendance"), 0.0)
349
- engagement = self._safe_float(student_row.get("engagementScore"), 0.0)
350
- completion_raw = student_row.get("assignmentCompletion")
351
- completion = (
352
- self._safe_float(completion_raw, 0.0)
353
- if completion_raw not in (None, "")
354
- else None
355
- )
356
-
357
- risk_level = self._classify_import_risk(
358
- avg_score=avg_score,
359
- attendance=attendance,
360
- engagement=engagement,
361
- completion=completion,
362
- )
363
- if risk_level == "High":
364
- high_risk_students.append(name)
365
- elif risk_level == "Medium":
366
- medium_risk_count += 1
367
- else:
368
- low_risk_count += 1
369
-
370
- topic_label = self._extract_import_topic(student_row)
371
- if topic_label:
372
- weak_topic_counts[topic_label] = weak_topic_counts.get(topic_label, 0) + 1
373
-
374
- if high_risk_students:
375
- notifications.append(
376
- f"Data import flagged {len(high_risk_students)} high-risk student(s): "
377
- + ", ".join(high_risk_students[:5])
378
- + ("..." if len(high_risk_students) > 5 else "")
379
- )
380
-
381
- notifications.append(
382
- "Risk interpretation summary โ€” "
383
- f"High: {len(high_risk_students)}, Medium: {medium_risk_count}, Low: {low_risk_count}."
384
- )
385
-
386
- if weak_topic_counts:
387
- top_topics = sorted(
388
- weak_topic_counts.items(),
389
- key=lambda item: (-item[1], item[0]),
390
- )[:3]
391
- notifications.append(
392
- "Most frequent weak-topic signals: "
393
- + ", ".join(f"{topic} ({count})" for topic, count in top_topics)
394
- )
395
-
396
- notifications.append(
397
- f"Data import complete โ€” {len(payload.students)} student records processed."
398
- )
399
-
400
- return AutomationResult(
401
- success=True,
402
- event="data_imported",
403
- studentId=None,
404
- message=f"Data import processed for {len(payload.students)} students",
405
- atRiskSubjects=None,
406
- notifications=notifications,
407
- )
408
-
409
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
410
- # 5. CONTENT UPDATE (admin action)
411
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
412
-
413
- async def handle_content_update(
414
- self, payload: ContentUpdatePayload
415
- ) -> AutomationResult:
416
- """
417
- After admin CRUD on curriculum, log & notify.
418
- """
419
- logger.info(
420
- f"๐Ÿ“š CONTENT UPDATE by admin {payload.adminId}: "
421
- f"{payload.action} {payload.contentType} {payload.contentId}"
422
- )
423
- notifications: list[str] = [
424
- f"Curriculum update: {payload.action}d {payload.contentType} "
425
- f"({payload.contentId}). Teachers may want to review affected quizzes.",
426
- ]
427
-
428
- return AutomationResult(
429
- success=True,
430
- event="content_updated",
431
- studentId=None,
432
- message=f"Content {payload.action} processed for {payload.contentType}",
433
- notifications=notifications,
434
- )
435
-
436
- # โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
437
- # INTERNAL HELPERS
438
- # โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
439
-
440
- # --- risk classification ---
441
-
442
- @staticmethod
443
- def _safe_float(value: Any, default: float = 0.0) -> float:
444
- try:
445
- parsed = float(value)
446
- if math.isnan(parsed) or math.isinf(parsed):
447
- return default
448
- return parsed
449
- except (TypeError, ValueError):
450
- return default
451
-
452
- @staticmethod
453
- def _classify_import_risk(
454
- *,
455
- avg_score: float,
456
- attendance: float,
457
- engagement: float,
458
- completion: Optional[float],
459
- ) -> str:
460
- high_flags = int(avg_score < 60) + int(attendance < 75) + int(engagement < 55)
461
- medium_flags = int(avg_score < 75) + int(attendance < 85) + int(engagement < 70)
462
-
463
- if completion is not None:
464
- high_flags += int(completion < 60)
465
- medium_flags += int(completion < 75)
466
-
467
- if high_flags >= 2 or (avg_score < 55 and (attendance < 80 or engagement < 65)):
468
- return "High"
469
- if medium_flags >= 2:
470
- return "Medium"
471
- return "Low"
472
-
473
- @staticmethod
474
- def _extract_import_topic(student_row: Dict[str, Any]) -> Optional[str]:
475
- explicit_topic = str(student_row.get("weakestTopic") or "").strip()
476
- if explicit_topic:
477
- return explicit_topic
478
-
479
- assessment_name = str(student_row.get("assessmentName") or "").strip()
480
- if assessment_name and assessment_name.lower() != "general-assessment":
481
- return assessment_name
482
-
483
- return None
484
-
485
- @staticmethod
486
- def _classify_subject_risks(
487
- results: List[DiagnosticResult],
488
- ) -> Dict[str, Dict[str, Any]]:
489
- """Classify each subject as 'At Risk' or 'On Track'."""
490
- classifications: Dict[str, Dict[str, Any]] = {}
491
- for r in results:
492
- if r.score < AT_RISK_THRESHOLD:
493
- status = "At Risk"
494
- confidence = round((AT_RISK_THRESHOLD - r.score) / AT_RISK_THRESHOLD, 2)
495
- else:
496
- status = "On Track"
497
- confidence = round(
498
- (r.score - AT_RISK_THRESHOLD) / (100 - AT_RISK_THRESHOLD), 2
499
- )
500
- classifications[r.subject] = {
501
- "status": status,
502
- "score": r.score,
503
- "confidence": confidence,
504
- "needsIntervention": status == "At Risk",
505
- }
506
- return classifications
507
-
508
- @staticmethod
509
- def _identify_weak_topics(
510
- question_breakdown: Optional[Dict[str, list]],
511
- ) -> List[Dict[str, Any]]:
512
- """
513
- Drill into per-topic accuracy from diagnostic question-level data.
514
- Returns topics sorted weakest-first.
515
- """
516
- if not question_breakdown:
517
- return []
518
-
519
- weak: list[dict] = []
520
- for topic, questions in question_breakdown.items():
521
- if not questions:
522
- continue
523
- correct_count = sum(1 for q in questions if q.get("correct"))
524
- accuracy = correct_count / len(questions)
525
- if accuracy < WEAK_TOPIC_THRESHOLD:
526
- weak.append({
527
- "topic": topic,
528
- "accuracy": round(accuracy, 2),
529
- "questionsAttempted": len(questions),
530
- "priority": "high" if accuracy < 0.3 else "medium",
531
- })
532
- weak.sort(key=lambda x: x["accuracy"])
533
- return weak
534
-
535
- @staticmethod
536
- def _calculate_overall_risk(
537
- classifications: Dict[str, Dict[str, Any]],
538
- ) -> str:
539
- total = len(classifications)
540
- if total == 0:
541
- return "Low"
542
- at_risk_count = sum(
543
- 1 for d in classifications.values() if d["status"] == "At Risk"
544
- )
545
- ratio = at_risk_count / total
546
- if ratio >= HIGH_RISK_RATIO:
547
- return "High"
548
- elif ratio >= MEDIUM_RISK_RATIO:
549
- return "Medium"
550
- return "Low"
551
-
552
- # --- remedial quiz configs ---
553
-
554
- @staticmethod
555
- def _build_remedial_quiz_configs(
556
- student_id: str,
557
- at_risk_subjects: List[str],
558
- overall_risk: str,
559
- grade_level: str,
560
- ) -> List[Dict[str, Any]]:
561
- """Return list of quiz configuration dicts ready for persistence."""
562
- cfg = REMEDIAL_CONFIG.get(overall_risk, REMEDIAL_CONFIG["Low"])
563
- quizzes: list[dict] = []
564
- for subject in at_risk_subjects:
565
- quizzes.append({
566
- "studentId": student_id,
567
- "subject": subject,
568
- "quizConfig": {
569
- "topics": [subject],
570
- "gradeLevel": grade_level,
571
- "numQuestions": cfg["questions"],
572
- "questionTypes": [
573
- "identification",
574
- "enumeration",
575
- "multiple_choice",
576
- "word_problem",
577
- ],
578
- "difficultyDistribution": cfg["dist"],
579
- "bloomLevels": ["remember", "understand", "apply"],
580
- "includeGraphs": False,
581
- "excludeTopics": [],
582
- "purpose": "remedial",
583
- "targetStudent": student_id,
584
- },
585
- "status": "pending",
586
- "autoGenerated": True,
587
- "reason": f'Diagnostic identified "{subject}" as At Risk',
588
- "priority": "high" if overall_risk == "High" else "medium",
589
- "dueInDays": 7,
590
- })
591
- return quizzes
592
-
593
- # --- AI helpers (Hugging Face) ---
594
-
595
- async def _generate_learning_path(
596
- self,
597
- at_risk_subjects: List[str],
598
- weak_topics: List[Dict[str, Any]],
599
- grade_level: str,
600
- ) -> Optional[str]:
601
- """Generate a personalised learning path via HF Serverless Inference."""
602
- try:
603
- from main import call_hf_chat
604
-
605
- weakness_lines = ", ".join(at_risk_subjects)
606
- topic_lines = "\n".join(
607
- f" - {t['topic']} ({t['accuracy']*100:.0f}% accuracy)"
608
- for t in weak_topics[:5]
609
- )
610
-
611
- prompt = (
612
- f"Generate a personalised math learning path for a {grade_level} student.\n\n"
613
- f"Weak subjects: {weakness_lines}\n"
614
- f"Weak topics:\n{topic_lines}\n\n"
615
- "Create 5-7 specific activities. For each give:\n"
616
- "1. Activity title\n"
617
- "2. Brief description (1-2 sentences)\n"
618
- "3. Estimated duration\n"
619
- "4. Type (video, practice, quiz, reading, interactive)\n\n"
620
- "Format as a numbered list. Be specific."
621
- )
622
-
623
- return call_hf_chat(
624
- messages=[
625
- {
626
- "role": "system",
627
- "content": (
628
- "You are an educational curriculum expert specialising in "
629
- "mathematics. Create clear, actionable learning paths."
630
- ),
631
- },
632
- {"role": "user", "content": prompt},
633
- ],
634
- max_tokens=1500,
635
- temperature=0.7,
636
- )
637
- except Exception as e:
638
- logger.warning(f"Learning-path AI call failed: {e}")
639
- return None
640
-
641
- async def _generate_teacher_interventions(
642
- self,
643
- risk_classifications: Dict[str, Dict[str, Any]],
644
- weak_topics: List[Dict[str, Any]],
645
- ) -> Optional[str]:
646
- """Generate teacher intervention recommendations via HF Serverless Inference."""
647
- try:
648
- from main import call_hf_chat
649
-
650
- at_risk = [
651
- subj for subj, data in risk_classifications.items()
652
- if data["status"] == "At Risk"
653
- ]
654
- topic_lines = "\n".join(
655
- f"- {t['topic']} ({t['accuracy']*100:.0f}% accuracy)"
656
- for t in weak_topics[:5]
657
- )
658
-
659
- prompt = (
660
- "You are an educational intervention specialist. A student has completed "
661
- "their diagnostic assessment with the following results:\n\n"
662
- f"At-Risk Subjects: {', '.join(at_risk)}\n\n"
663
- f"Weak Topics Identified:\n{topic_lines}\n\n"
664
- "Generate a 'Remedial Path Timeline' with:\n"
665
- "1. Prioritised list of topics to address (most critical first)\n"
666
- "2. Suggested teaching strategies for each topic\n"
667
- "3. Recommended one-on-one intervention activities\n"
668
- "4. Timeline for reassessment\n"
669
- "5. Warning signs that student needs additional support\n\n"
670
- "Keep response under 300 words, structured with clear sections."
671
- )
672
-
673
- return call_hf_chat(
674
- messages=[
675
- {
676
- "role": "system",
677
- "content": (
678
- "You are an expert educational intervention specialist. "
679
- "Provide actionable, structured recommendations for teachers."
680
- ),
681
- },
682
- {"role": "user", "content": prompt},
683
- ],
684
- max_tokens=1000,
685
- temperature=0.5,
686
- )
687
- except Exception as e:
688
- logger.warning(f"Teacher-intervention AI call failed: {e}")
689
- return None
690
-
691
-
692
- # Module-level singleton
693
- automation_engine = MathPulseAutomationEngine()
 
1
+ """
2
+ MathPulse AI - Event-Driven Automation Engine
3
+
4
+ Processes educational workflows based on a diagnostic-first, risk-driven
5
+ intervention model. Trigger points:
6
+
7
+ 1. Diagnostic Assessment Completion (highest priority)
8
+ 2. Quiz / Assessment Submission (continuous)
9
+ 3. New Student Enrollment
10
+ 4. External Data Import (teacher action)
11
+ 5. Admin Content Updates
12
+
13
+ Each event is routed to a dedicated handler that orchestrates
14
+ classification, quiz generation, notifications and dashboard updates.
15
+ """
16
+
17
+ import os
18
+ import json
19
+ import math
20
+ import logging
21
+ import traceback
22
+ from typing import List, Optional, Dict, Any, Tuple
23
+ from datetime import datetime, timedelta
24
+
25
+ from pydantic import BaseModel, Field
26
+
27
+ logger = logging.getLogger("mathpulse.automation")
28
+
29
+ # โ”€โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
30
+
31
+ AT_RISK_THRESHOLD = 60 # < 60 % โ†’ At Risk
32
+ WEAK_TOPIC_THRESHOLD = 0.50 # < 50 % accuracy โ†’ weak topic
33
+ HIGH_RISK_RATIO = 0.75 # 75 %+ subjects at risk
34
+ MEDIUM_RISK_RATIO = 0.50 # 50-75 %
35
+
36
+ REMEDIAL_CONFIG = {
37
+ "High": {"questions": 15, "dist": {"easy": 60, "medium": 30, "hard": 10}},
38
+ "Medium": {"questions": 12, "dist": {"easy": 50, "medium": 35, "hard": 15}},
39
+ "Low": {"questions": 10, "dist": {"easy": 40, "medium": 40, "hard": 20}},
40
+ }
41
+
42
+ # โ”€โ”€โ”€ Request / Response Models โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
43
+
44
+
45
+ class DiagnosticResult(BaseModel):
46
+ """Per-subject score from diagnostic assessment."""
47
+ subject: str
48
+ score: float = Field(..., ge=0, le=100)
49
+
50
+
51
+ class DiagnosticCompletionPayload(BaseModel):
52
+ """Payload sent when a student completes the diagnostic."""
53
+ studentId: str
54
+ results: List[DiagnosticResult]
55
+ gradeLevel: str = "Grade 10"
56
+ questionBreakdown: Optional[Dict[str, list]] = None # topic โ†’ [{correct: bool, โ€ฆ}]
57
+
58
+
59
+ class QuizSubmissionPayload(BaseModel):
60
+ """Payload sent on quiz / assessment submission."""
61
+ studentId: str
62
+ quizId: str
63
+ subject: str
64
+ score: float = Field(..., ge=0, le=100)
65
+ totalQuestions: int
66
+ correctAnswers: int
67
+ timeSpentSeconds: int
68
+ answers: Optional[List[Dict[str, Any]]] = None
69
+
70
+
71
+ class StudentEnrollmentPayload(BaseModel):
72
+ """Payload sent when a new student account is created."""
73
+ studentId: str
74
+ name: str
75
+ email: str
76
+ gradeLevel: str = "Grade 10"
77
+ teacherId: Optional[str] = None
78
+
79
+
80
+ class DataImportPayload(BaseModel):
81
+ """Payload sent after a teacher uploads a spreadsheet."""
82
+ teacherId: str
83
+ students: List[Dict[str, Any]] # parsed student rows
84
+ columnMapping: Dict[str, str]
85
+
86
+
87
+ class ContentUpdatePayload(BaseModel):
88
+ """Payload sent when admin performs CRUD on curriculum."""
89
+ adminId: str
90
+ action: str # create | update | delete
91
+ contentType: str # lesson | quiz | module | subject
92
+ contentId: str
93
+ subjectId: Optional[str] = None
94
+ details: Optional[str] = None
95
+
96
+
97
+ # โ”€โ”€โ”€ Risk classification helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
98
+
99
+
100
+ class SubjectRiskClassification(BaseModel):
101
+ status: str # "At Risk" | "On Track"
102
+ score: float
103
+ confidence: float
104
+ needsIntervention: bool
105
+
106
+
107
+ class AutomationResult(BaseModel):
108
+ """Standardised result returned by every handler."""
109
+ success: bool
110
+ event: str
111
+ studentId: Optional[str] = None
112
+ message: str
113
+ riskClassifications: Optional[Dict[str, Dict[str, Any]]] = None
114
+ overallRisk: Optional[str] = None
115
+ atRiskSubjects: Optional[List[str]] = None
116
+ weakTopics: Optional[List[Dict[str, Any]]] = None
117
+ learningPath: Optional[str] = None
118
+ remedialQuizzesCreated: int = 0
119
+ interventions: Optional[str] = None
120
+ notifications: List[str] = Field(default_factory=list)
121
+
122
+
123
+ # โ”€โ”€โ”€ Automation Engine โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
124
+
125
+
126
+ class MathPulseAutomationEngine:
127
+ """
128
+ Stateless event-driven automation system.
129
+
130
+ Each ``handle_*`` method is an independent, self-contained handler that
131
+ receives a validated Pydantic payload and returns an ``AutomationResult``.
132
+ Firebase / Hugging Face calls are only attempted when available.
133
+ """
134
+
135
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
136
+ # 1. DIAGNOSTIC COMPLETION (highest-priority)
137
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
138
+
139
+ async def handle_diagnostic_completion(
140
+ self, payload: DiagnosticCompletionPayload
141
+ ) -> AutomationResult:
142
+ """
143
+ Runs when a student completes the mandatory diagnostic.
144
+
145
+ Steps:
146
+ 1. Classify per-subject risk
147
+ 2. Identify weak topics
148
+ 3. Compute overall risk
149
+ 4. Generate personalised learning path (AI)
150
+ 5. Create remedial quiz assignments
151
+ 6. Generate teacher intervention recommendations (AI)
152
+ 7. Persist everything & notify
153
+ """
154
+ student_id = payload.studentId
155
+ logger.info(f"๐Ÿ“Š DIAGNOSTIC COMPLETED for {student_id}")
156
+ notifications: list[str] = []
157
+
158
+ # 1 โ€” subject-level risk
159
+ risk_classifications = self._classify_subject_risks(payload.results)
160
+
161
+ # 2 โ€” weak topics
162
+ weak_topics = self._identify_weak_topics(payload.questionBreakdown)
163
+
164
+ # 3 โ€” overall risk
165
+ overall_risk = self._calculate_overall_risk(risk_classifications)
166
+
167
+ at_risk_subjects = [
168
+ subj for subj, data in risk_classifications.items()
169
+ if data["status"] == "At Risk"
170
+ ]
171
+
172
+ # 4 โ€” learning path (AI call)
173
+ learning_path: Optional[str] = None
174
+ if at_risk_subjects:
175
+ learning_path = await self._generate_learning_path(
176
+ at_risk_subjects, weak_topics, payload.gradeLevel
177
+ )
178
+
179
+ # 5 โ€” remedial quizzes
180
+ remedial_count = 0
181
+ remedial_quizzes: list[dict] = []
182
+ if at_risk_subjects:
183
+ remedial_quizzes = self._build_remedial_quiz_configs(
184
+ student_id, at_risk_subjects, overall_risk, payload.gradeLevel
185
+ )
186
+ remedial_count = len(remedial_quizzes)
187
+
188
+ # 6 โ€” teacher interventions (AI call)
189
+ interventions: Optional[str] = None
190
+ if at_risk_subjects:
191
+ interventions = await self._generate_teacher_interventions(
192
+ risk_classifications, weak_topics
193
+ )
194
+
195
+ # 7 โ€” notification messages
196
+ if at_risk_subjects:
197
+ notifications.append(
198
+ f"Diagnostic complete โ€” {len(at_risk_subjects)} subject(s) flagged At Risk: "
199
+ + ", ".join(at_risk_subjects)
200
+ )
201
+ else:
202
+ notifications.append("Diagnostic complete โ€” all subjects On Track!")
203
+
204
+ logger.info(
205
+ f"โœ… DIAGNOSTIC PROCESSING COMPLETE for {student_id} | "
206
+ f"Overall={overall_risk} | AtRisk={at_risk_subjects}"
207
+ )
208
+
209
+ return AutomationResult(
210
+ success=True,
211
+ event="diagnostic_completed",
212
+ studentId=student_id,
213
+ message=f"Diagnostic processed for {student_id}",
214
+ riskClassifications=risk_classifications,
215
+ overallRisk=overall_risk,
216
+ atRiskSubjects=at_risk_subjects,
217
+ weakTopics=weak_topics,
218
+ learningPath=learning_path,
219
+ remedialQuizzesCreated=remedial_count,
220
+ interventions=interventions,
221
+ notifications=notifications,
222
+ )
223
+
224
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
225
+ # 2. QUIZ SUBMISSION (continuous)
226
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
227
+
228
+ async def handle_quiz_submission(
229
+ self, payload: QuizSubmissionPayload
230
+ ) -> AutomationResult:
231
+ """Recalculate risk for a subject after a quiz is submitted."""
232
+ student_id = payload.studentId
233
+ logger.info(f"๐Ÿ“ QUIZ SUBMITTED by {student_id} โ€” {payload.subject} ({payload.score}%)")
234
+ notifications: list[str] = []
235
+
236
+ # Determine new status for this subject
237
+ new_status = "At Risk" if payload.score < AT_RISK_THRESHOLD else "On Track"
238
+ confidence = (
239
+ (AT_RISK_THRESHOLD - payload.score) / AT_RISK_THRESHOLD
240
+ if new_status == "At Risk"
241
+ else (payload.score - AT_RISK_THRESHOLD) / (100 - AT_RISK_THRESHOLD)
242
+ )
243
+
244
+ risk_classifications = {
245
+ payload.subject: {
246
+ "status": new_status,
247
+ "score": payload.score,
248
+ "confidence": round(abs(confidence), 2),
249
+ "needsIntervention": new_status == "At Risk",
250
+ }
251
+ }
252
+
253
+ at_risk = [payload.subject] if new_status == "At Risk" else []
254
+
255
+ if new_status == "At Risk":
256
+ notifications.append(
257
+ f"Quiz result: {payload.subject} scored {payload.score}% โ€” status changed to At Risk"
258
+ )
259
+ else:
260
+ notifications.append(
261
+ f"Quiz result: {payload.subject} scored {payload.score}% โ€” On Track"
262
+ )
263
+
264
+ return AutomationResult(
265
+ success=True,
266
+ event="quiz_submitted",
267
+ studentId=student_id,
268
+ message=f"Quiz processed for {student_id}",
269
+ riskClassifications=risk_classifications,
270
+ overallRisk=None, # single-subject update โ€” overall recalculated on frontend
271
+ atRiskSubjects=at_risk,
272
+ notifications=notifications,
273
+ )
274
+
275
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
276
+ # 3. STUDENT ENROLLMENT
277
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
278
+
279
+ async def handle_student_enrollment(
280
+ self, payload: StudentEnrollmentPayload
281
+ ) -> AutomationResult:
282
+ """
283
+ Prepare a new student:
284
+ - Create empty progress record skeleton
285
+ - Initialise gamification (XP 0, Level 1, no streaks)
286
+ - Flag as needing diagnostic
287
+ """
288
+ student_id = payload.studentId
289
+ logger.info(f"๐Ÿ†• NEW STUDENT ENROLLED: {student_id}")
290
+
291
+ progress_skeleton = {
292
+ "userId": student_id,
293
+ "subjects": {},
294
+ "lessons": {},
295
+ "quizAttempts": [],
296
+ "totalLessonsCompleted": 0,
297
+ "totalQuizzesCompleted": 0,
298
+ "averageScore": 0,
299
+ }
300
+
301
+ gamification_init = {
302
+ "level": 1,
303
+ "currentXP": 0,
304
+ "totalXP": 0,
305
+ "streak": 0,
306
+ "hasTakenDiagnostic": False,
307
+ "atRiskSubjects": [],
308
+ }
309
+
310
+ notifications: list[str] = [
311
+ f"Welcome {payload.name}! Please complete the diagnostic assessment to personalise your learning path.",
312
+ ]
313
+
314
+ if payload.teacherId:
315
+ notifications.append(
316
+ f"New student {payload.name} enrolled โ€” diagnostic pending."
317
+ )
318
+
319
+ return AutomationResult(
320
+ success=True,
321
+ event="student_enrolled",
322
+ studentId=student_id,
323
+ message=f"Student {payload.name} enrolled and initialised",
324
+ notifications=notifications,
325
+ )
326
+
327
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
328
+ # 4. DATA IMPORT (teacher action)
329
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€๏ฟฝ๏ฟฝ๏ฟฝโ”€
330
+
331
+ async def handle_data_import(
332
+ self, payload: DataImportPayload
333
+ ) -> AutomationResult:
334
+ """
335
+ After a teacher uploads a spreadsheet, recalculate risk for every
336
+ imported student and flag any status changes.
337
+ """
338
+ logger.info(f"๐Ÿ“‚ DATA IMPORT by teacher {payload.teacherId} โ€” {len(payload.students)} students")
339
+ notifications: list[str] = []
340
+ high_risk_students: list[str] = []
341
+ medium_risk_count = 0
342
+ low_risk_count = 0
343
+ weak_topic_counts: Dict[str, int] = {}
344
+
345
+ for student_row in payload.students:
346
+ name = str(student_row.get("name") or "Unknown").strip() or "Unknown"
347
+ avg_score = self._safe_float(student_row.get("avgQuizScore"), 0.0)
348
+ attendance = self._safe_float(student_row.get("attendance"), 0.0)
349
+ engagement = self._safe_float(student_row.get("engagementScore"), 0.0)
350
+ completion_raw = student_row.get("assignmentCompletion")
351
+ completion = (
352
+ self._safe_float(completion_raw, 0.0)
353
+ if completion_raw not in (None, "")
354
+ else None
355
+ )
356
+
357
+ risk_level = self._classify_import_risk(
358
+ avg_score=avg_score,
359
+ attendance=attendance,
360
+ engagement=engagement,
361
+ completion=completion,
362
+ )
363
+ if risk_level == "High":
364
+ high_risk_students.append(name)
365
+ elif risk_level == "Medium":
366
+ medium_risk_count += 1
367
+ else:
368
+ low_risk_count += 1
369
+
370
+ topic_label = self._extract_import_topic(student_row)
371
+ if topic_label:
372
+ weak_topic_counts[topic_label] = weak_topic_counts.get(topic_label, 0) + 1
373
+
374
+ if high_risk_students:
375
+ notifications.append(
376
+ f"Data import flagged {len(high_risk_students)} high-risk student(s): "
377
+ + ", ".join(high_risk_students[:5])
378
+ + ("..." if len(high_risk_students) > 5 else "")
379
+ )
380
+
381
+ notifications.append(
382
+ "Risk interpretation summary โ€” "
383
+ f"High: {len(high_risk_students)}, Medium: {medium_risk_count}, Low: {low_risk_count}."
384
+ )
385
+
386
+ if weak_topic_counts:
387
+ top_topics = sorted(
388
+ weak_topic_counts.items(),
389
+ key=lambda item: (-item[1], item[0]),
390
+ )[:3]
391
+ notifications.append(
392
+ "Most frequent weak-topic signals: "
393
+ + ", ".join(f"{topic} ({count})" for topic, count in top_topics)
394
+ )
395
+
396
+ notifications.append(
397
+ f"Data import complete โ€” {len(payload.students)} student records processed."
398
+ )
399
+
400
+ return AutomationResult(
401
+ success=True,
402
+ event="data_imported",
403
+ studentId=None,
404
+ message=f"Data import processed for {len(payload.students)} students",
405
+ atRiskSubjects=None,
406
+ notifications=notifications,
407
+ )
408
+
409
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
410
+ # 5. CONTENT UPDATE (admin action)
411
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
412
+
413
+ async def handle_content_update(
414
+ self, payload: ContentUpdatePayload
415
+ ) -> AutomationResult:
416
+ """
417
+ After admin CRUD on curriculum, log & notify.
418
+ """
419
+ logger.info(
420
+ f"๐Ÿ“š CONTENT UPDATE by admin {payload.adminId}: "
421
+ f"{payload.action} {payload.contentType} {payload.contentId}"
422
+ )
423
+ notifications: list[str] = [
424
+ f"Curriculum update: {payload.action}d {payload.contentType} "
425
+ f"({payload.contentId}). Teachers may want to review affected quizzes.",
426
+ ]
427
+
428
+ return AutomationResult(
429
+ success=True,
430
+ event="content_updated",
431
+ studentId=None,
432
+ message=f"Content {payload.action} processed for {payload.contentType}",
433
+ notifications=notifications,
434
+ )
435
+
436
+ # โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
437
+ # INTERNAL HELPERS
438
+ # โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
439
+
440
+ # --- risk classification ---
441
+
442
+ @staticmethod
443
+ def _safe_float(value: Any, default: float = 0.0) -> float:
444
+ try:
445
+ parsed = float(value)
446
+ if math.isnan(parsed) or math.isinf(parsed):
447
+ return default
448
+ return parsed
449
+ except (TypeError, ValueError):
450
+ return default
451
+
452
+ @staticmethod
453
+ def _classify_import_risk(
454
+ *,
455
+ avg_score: float,
456
+ attendance: float,
457
+ engagement: float,
458
+ completion: Optional[float],
459
+ ) -> str:
460
+ high_flags = int(avg_score < 60) + int(attendance < 75) + int(engagement < 55)
461
+ medium_flags = int(avg_score < 75) + int(attendance < 85) + int(engagement < 70)
462
+
463
+ if completion is not None:
464
+ high_flags += int(completion < 60)
465
+ medium_flags += int(completion < 75)
466
+
467
+ if high_flags >= 2 or (avg_score < 55 and (attendance < 80 or engagement < 65)):
468
+ return "High"
469
+ if medium_flags >= 2:
470
+ return "Medium"
471
+ return "Low"
472
+
473
+ @staticmethod
474
+ def _extract_import_topic(student_row: Dict[str, Any]) -> Optional[str]:
475
+ explicit_topic = str(student_row.get("weakestTopic") or "").strip()
476
+ if explicit_topic:
477
+ return explicit_topic
478
+
479
+ assessment_name = str(student_row.get("assessmentName") or "").strip()
480
+ if assessment_name and assessment_name.lower() != "general-assessment":
481
+ return assessment_name
482
+
483
+ return None
484
+
485
+ @staticmethod
486
+ def _classify_subject_risks(
487
+ results: List[DiagnosticResult],
488
+ ) -> Dict[str, Dict[str, Any]]:
489
+ """Classify each subject as 'At Risk' or 'On Track'."""
490
+ classifications: Dict[str, Dict[str, Any]] = {}
491
+ for r in results:
492
+ if r.score < AT_RISK_THRESHOLD:
493
+ status = "At Risk"
494
+ confidence = round((AT_RISK_THRESHOLD - r.score) / AT_RISK_THRESHOLD, 2)
495
+ else:
496
+ status = "On Track"
497
+ confidence = round(
498
+ (r.score - AT_RISK_THRESHOLD) / (100 - AT_RISK_THRESHOLD), 2
499
+ )
500
+ classifications[r.subject] = {
501
+ "status": status,
502
+ "score": r.score,
503
+ "confidence": confidence,
504
+ "needsIntervention": status == "At Risk",
505
+ }
506
+ return classifications
507
+
508
+ @staticmethod
509
+ def _identify_weak_topics(
510
+ question_breakdown: Optional[Dict[str, list]],
511
+ ) -> List[Dict[str, Any]]:
512
+ """
513
+ Drill into per-topic accuracy from diagnostic question-level data.
514
+ Returns topics sorted weakest-first.
515
+ """
516
+ if not question_breakdown:
517
+ return []
518
+
519
+ weak: list[dict] = []
520
+ for topic, questions in question_breakdown.items():
521
+ if not questions:
522
+ continue
523
+ correct_count = sum(1 for q in questions if q.get("correct"))
524
+ accuracy = correct_count / len(questions)
525
+ if accuracy < WEAK_TOPIC_THRESHOLD:
526
+ weak.append({
527
+ "topic": topic,
528
+ "accuracy": round(accuracy, 2),
529
+ "questionsAttempted": len(questions),
530
+ "priority": "high" if accuracy < 0.3 else "medium",
531
+ })
532
+ weak.sort(key=lambda x: x["accuracy"])
533
+ return weak
534
+
535
+ @staticmethod
536
+ def _calculate_overall_risk(
537
+ classifications: Dict[str, Dict[str, Any]],
538
+ ) -> str:
539
+ total = len(classifications)
540
+ if total == 0:
541
+ return "Low"
542
+ at_risk_count = sum(
543
+ 1 for d in classifications.values() if d["status"] == "At Risk"
544
+ )
545
+ ratio = at_risk_count / total
546
+ if ratio >= HIGH_RISK_RATIO:
547
+ return "High"
548
+ elif ratio >= MEDIUM_RISK_RATIO:
549
+ return "Medium"
550
+ return "Low"
551
+
552
+ # --- remedial quiz configs ---
553
+
554
+ @staticmethod
555
+ def _build_remedial_quiz_configs(
556
+ student_id: str,
557
+ at_risk_subjects: List[str],
558
+ overall_risk: str,
559
+ grade_level: str,
560
+ ) -> List[Dict[str, Any]]:
561
+ """Return list of quiz configuration dicts ready for persistence."""
562
+ cfg = REMEDIAL_CONFIG.get(overall_risk, REMEDIAL_CONFIG["Low"])
563
+ quizzes: list[dict] = []
564
+ for subject in at_risk_subjects:
565
+ quizzes.append({
566
+ "studentId": student_id,
567
+ "subject": subject,
568
+ "quizConfig": {
569
+ "topics": [subject],
570
+ "gradeLevel": grade_level,
571
+ "numQuestions": cfg["questions"],
572
+ "questionTypes": [
573
+ "identification",
574
+ "enumeration",
575
+ "multiple_choice",
576
+ "word_problem",
577
+ ],
578
+ "difficultyDistribution": cfg["dist"],
579
+ "bloomLevels": ["remember", "understand", "apply"],
580
+ "includeGraphs": False,
581
+ "excludeTopics": [],
582
+ "purpose": "remedial",
583
+ "targetStudent": student_id,
584
+ },
585
+ "status": "pending",
586
+ "autoGenerated": True,
587
+ "reason": f'Diagnostic identified "{subject}" as At Risk',
588
+ "priority": "high" if overall_risk == "High" else "medium",
589
+ "dueInDays": 7,
590
+ })
591
+ return quizzes
592
+
593
+ # --- AI helpers (Hugging Face) ---
594
+
595
+ async def _generate_learning_path(
596
+ self,
597
+ at_risk_subjects: List[str],
598
+ weak_topics: List[Dict[str, Any]],
599
+ grade_level: str,
600
+ ) -> Optional[str]:
601
+ """Generate a personalised learning path via HF Serverless Inference."""
602
+ try:
603
+ from main import call_hf_chat
604
+
605
+ weakness_lines = ", ".join(at_risk_subjects)
606
+ topic_lines = "\n".join(
607
+ f" - {t['topic']} ({t['accuracy']*100:.0f}% accuracy)"
608
+ for t in weak_topics[:5]
609
+ )
610
+
611
+ prompt = (
612
+ f"Generate a personalised math learning path for a {grade_level} student.\n\n"
613
+ f"Weak subjects: {weakness_lines}\n"
614
+ f"Weak topics:\n{topic_lines}\n\n"
615
+ "Create 5-7 specific activities. For each give:\n"
616
+ "1. Activity title\n"
617
+ "2. Brief description (1-2 sentences)\n"
618
+ "3. Estimated duration\n"
619
+ "4. Type (video, practice, quiz, reading, interactive)\n\n"
620
+ "Format as a numbered list. Be specific."
621
+ )
622
+
623
+ return call_hf_chat(
624
+ messages=[
625
+ {
626
+ "role": "system",
627
+ "content": (
628
+ "You are an educational curriculum expert specialising in "
629
+ "mathematics. Create clear, actionable learning paths."
630
+ ),
631
+ },
632
+ {"role": "user", "content": prompt},
633
+ ],
634
+ max_tokens=1500,
635
+ temperature=0.7,
636
+ )
637
+ except Exception as e:
638
+ logger.warning(f"Learning-path AI call failed: {e}")
639
+ return None
640
+
641
+ async def _generate_teacher_interventions(
642
+ self,
643
+ risk_classifications: Dict[str, Dict[str, Any]],
644
+ weak_topics: List[Dict[str, Any]],
645
+ ) -> Optional[str]:
646
+ """Generate teacher intervention recommendations via HF Serverless Inference."""
647
+ try:
648
+ from main import call_hf_chat
649
+
650
+ at_risk = [
651
+ subj for subj, data in risk_classifications.items()
652
+ if data["status"] == "At Risk"
653
+ ]
654
+ topic_lines = "\n".join(
655
+ f"- {t['topic']} ({t['accuracy']*100:.0f}% accuracy)"
656
+ for t in weak_topics[:5]
657
+ )
658
+
659
+ prompt = (
660
+ "You are an educational intervention specialist. A student has completed "
661
+ "their diagnostic assessment with the following results:\n\n"
662
+ f"At-Risk Subjects: {', '.join(at_risk)}\n\n"
663
+ f"Weak Topics Identified:\n{topic_lines}\n\n"
664
+ "Generate a 'Remedial Path Timeline' with:\n"
665
+ "1. Prioritised list of topics to address (most critical first)\n"
666
+ "2. Suggested teaching strategies for each topic\n"
667
+ "3. Recommended one-on-one intervention activities\n"
668
+ "4. Timeline for reassessment\n"
669
+ "5. Warning signs that student needs additional support\n\n"
670
+ "Keep response under 300 words, structured with clear sections."
671
+ )
672
+
673
+ return call_hf_chat(
674
+ messages=[
675
+ {
676
+ "role": "system",
677
+ "content": (
678
+ "You are an expert educational intervention specialist. "
679
+ "Provide actionable, structured recommendations for teachers."
680
+ ),
681
+ },
682
+ {"role": "user", "content": prompt},
683
+ ],
684
+ max_tokens=1000,
685
+ temperature=0.5,
686
+ )
687
+ except Exception as e:
688
+ logger.warning(f"Teacher-intervention AI call failed: {e}")
689
+ return None
690
+
691
+
692
+ # Module-level singleton
693
+ automation_engine = MathPulseAutomationEngine()
config/env.sample CHANGED
@@ -1,97 +1,78 @@
1
- # Inference provider selection
2
- # CI trigger marker: keep this file touchable to force backend deploy workflow runs when needed.
3
- INFERENCE_PROVIDER=local_peft
4
- INFERENCE_PRO_ENABLED=true
5
- INFERENCE_PRO_PROVIDER=hf_inference
6
- INFERENCE_GPU_PROVIDER=hf_inference
7
- INFERENCE_CPU_PROVIDER=hf_inference
8
- INFERENCE_ENABLE_PROVIDER_FALLBACK=true
9
- INFERENCE_PRO_PRIORITY_TASKS=chat,verify_solution
10
- INFERENCE_PRO_ROUTE_HEADER_NAME=
11
- INFERENCE_PRO_ROUTE_HEADER_VALUE=true
12
-
13
- # task policy sets, comma-separated
14
- INFERENCE_GPU_REQUIRED_TASKS=chat
15
- INFERENCE_CPU_ONLY_TASKS=risk_classification,analytics_aggregation,file_parsing,auth,default_cpu
16
- INFERENCE_INTERACTIVE_TASKS=chat,verify_solution,daily_insight
17
- ENABLE_LLM_RISK_RECOMMENDATIONS=true
18
-
19
- # local_space provider settings
20
- # Accepts either runtime host (https://<owner>-<space>.hf.space) or
21
- # Space page URL (https://huggingface.co/spaces/<owner>/<space>).
22
- # Example: https://huggingface.co/spaces/Deign86/mathpulse-ai
23
- INFERENCE_LOCAL_SPACE_URL=http://127.0.0.1:7860
24
- INFERENCE_LOCAL_SPACE_GENERATE_PATH=/gradio_api/call/generate
25
- INFERENCE_LOCAL_SPACE_TIMEOUT_SEC=180
26
-
27
- # local_peft provider settings (base model + LoRA adapter, no merge required)
28
- LORA_BASE_MODEL_ID=Qwen/Qwen2.5-7B-Instruct
29
- LORA_ADAPTER_MODEL_ID=Deign86/deped-math-qwen2.5-7b-checkpoint-700-lora
30
- LORA_LOAD_IN_4BIT=true
31
- LORA_DEVICE_MAP=auto
32
- LORA_DTYPE=float16
33
- LORA_MAX_NEW_TOKENS=576
34
- LORA_CACHE_DIR=
35
- LOCAL_PEFT_STREAM_TOKEN_TIMEOUT_SEC=30
36
- LOCAL_PEFT_WORKER_JOIN_TIMEOUT_SEC=45
37
- LOCAL_PEFT_GENERATE_MAX_TIME_SEC=0
38
- LOCAL_PEFT_LOG_MEMORY=false
39
-
40
- # hf_inference provider settings
41
- # Alternative env names accepted by runtime/startup checks: HUGGING_FACE_API_TOKEN, HUGGINGFACE_API_TOKEN
42
- HF_TOKEN=your_hf_token
43
- FIREBASE_AUTH_PROJECT_ID=mathpulse-ai-2026
44
- # Prefer one of the options below for backend Firestore/Admin access in deployment:
45
- # FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account",...}
46
- # FIREBASE_SERVICE_ACCOUNT_FILE=/path/to/service-account.json
47
- INFERENCE_HF_BASE_URL=https://router.huggingface.co/hf-inference/models
48
- INFERENCE_HF_CHAT_URL=https://router.huggingface.co/v1/chat/completions
49
- INFERENCE_HF_TIMEOUT_SEC=90
50
- INFERENCE_INTERACTIVE_TIMEOUT_SEC=55
51
- INFERENCE_BACKGROUND_TIMEOUT_SEC=120
52
-
53
- # model defaults (active)
54
- INFERENCE_MODEL_ID=Qwen/Qwen2.5-7B-Instruct
55
- INFERENCE_MAX_NEW_TOKENS=640
56
- INFERENCE_TEMPERATURE=0.2
57
- INFERENCE_TOP_P=0.9
58
- INFERENCE_CHAT_MODEL_ID=Qwen/Qwen2.5-7B-Instruct
59
- # rollback backups (pre-Qwen switch)
60
- # Note: backup vars are for manual rollback and are not consumed automatically.
61
- INFERENCE_MODEL_ID_BACKUP=meta-llama/Llama-3.1-8B-Instruct
62
- INFERENCE_CHAT_MODEL_ID_BACKUP=meta-llama/Llama-3.1-8B-Instruct
63
- INFERENCE_CHAT_HARD_MODEL_ID=meta-llama/Meta-Llama-3-70B-Instruct
64
- INFERENCE_CHAT_HARD_TRIGGER_ENABLED=true
65
- INFERENCE_CHAT_HARD_PROMPT_CHARS=650
66
- INFERENCE_CHAT_HARD_HISTORY_CHARS=1500
67
- INFERENCE_CHAT_HARD_KEYWORDS=step-by-step,show all steps,explain each step,justify each step,derive,derivation,proof,prove,rigorous,multi-step,word problem
68
- CHAT_MAX_NEW_TOKENS=768
69
- CHAT_STREAM_NO_TOKEN_TIMEOUT_SEC=30
70
- CHAT_STREAM_TOTAL_TIMEOUT_SEC=180
71
- # Optional: force quiz-generation model. Leave empty to use routing.task_model_map.quiz_generation.
72
- HF_QUIZ_MODEL_ID=
73
- HF_QUIZ_JSON_REPAIR_MODEL_ID=meta-llama/Llama-3.1-8B-Instruct
74
-
75
- # retry behavior
76
- INFERENCE_MAX_RETRIES=3
77
- INFERENCE_BACKOFF_SEC=1.5
78
- INFERENCE_INTERACTIVE_MAX_RETRIES=1
79
- INFERENCE_BACKGROUND_MAX_RETRIES=3
80
- INFERENCE_INTERACTIVE_BACKOFF_SEC=1.0
81
- INFERENCE_BACKGROUND_BACKOFF_SEC=1.75
82
- INFERENCE_INTERACTIVE_MAX_FALLBACK_DEPTH=1
83
- # Max simultaneous blocking HF calls allowed from async endpoints.
84
- HF_BLOCKING_CALL_CONCURRENCY=16
85
- HF_ASYNC_MAX_CONNECTIONS=64
86
- HF_ASYNC_MAX_KEEPALIVE_CONNECTIONS=32
87
- HF_ASYNC_CONNECT_TIMEOUT_SEC=10.0
88
- HF_ASYNC_WRITE_TIMEOUT_SEC=30.0
89
- HF_ASYNC_POOL_TIMEOUT_SEC=10.0
90
-
91
- # fallback model ids, comma-separated
92
- INFERENCE_FALLBACK_MODELS=meta-llama/Meta-Llama-3-70B-Instruct,google/gemma-2-2b-it
93
-
94
- # async generation controls
95
- ENABLE_ASYNC_GENERATION=true
96
- ASYNC_TASK_TTL_SECONDS=3600
97
- ASYNC_TASK_MAX_ITEMS=400
 
1
+ # Inference provider selection
2
+ # CI trigger marker: keep this file touchable to force backend deploy workflow runs when needed.
3
+ INFERENCE_PROVIDER=hf_inference
4
+ INFERENCE_PRO_ENABLED=true
5
+ INFERENCE_PRO_PROVIDER=hf_inference
6
+ INFERENCE_GPU_PROVIDER=hf_inference
7
+ INFERENCE_CPU_PROVIDER=hf_inference
8
+ INFERENCE_ENABLE_PROVIDER_FALLBACK=true
9
+ INFERENCE_PRO_PRIORITY_TASKS=chat,verify_solution
10
+ INFERENCE_PRO_ROUTE_HEADER_NAME=
11
+ INFERENCE_PRO_ROUTE_HEADER_VALUE=true
12
+
13
+ # task policy sets, comma-separated
14
+ INFERENCE_GPU_REQUIRED_TASKS=chat
15
+ INFERENCE_CPU_ONLY_TASKS=risk_classification,analytics_aggregation,file_parsing,auth,default_cpu
16
+ INFERENCE_INTERACTIVE_TASKS=chat,verify_solution,daily_insight
17
+ ENABLE_LLM_RISK_RECOMMENDATIONS=true
18
+
19
+ # local_space provider settings
20
+ # Accepts either runtime host (https://<owner>-<space>.hf.space) or
21
+ # Space page URL (https://huggingface.co/spaces/<owner>/<space>).
22
+ # Example: https://huggingface.co/spaces/Deign86/mathpulse-ai
23
+ INFERENCE_LOCAL_SPACE_URL=http://127.0.0.1:7860
24
+ INFERENCE_LOCAL_SPACE_GENERATE_PATH=/gradio_api/call/generate
25
+ INFERENCE_LOCAL_SPACE_TIMEOUT_SEC=180
26
+
27
+ # hf_inference provider settings
28
+ # Alternative env names accepted by runtime/startup checks: HUGGING_FACE_API_TOKEN, HUGGINGFACE_API_TOKEN
29
+ HF_TOKEN=your_hf_token
30
+ FIREBASE_AUTH_PROJECT_ID=mathpulse-ai-2026
31
+ # Prefer one of the options below for backend Firestore/Admin access in deployment:
32
+ # FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account",...}
33
+ # FIREBASE_SERVICE_ACCOUNT_FILE=/path/to/service-account.json
34
+ INFERENCE_HF_BASE_URL=https://router.huggingface.co/hf-inference/models
35
+ INFERENCE_HF_CHAT_URL=https://router.huggingface.co/v1/chat/completions
36
+ INFERENCE_HF_TIMEOUT_SEC=90
37
+ INFERENCE_INTERACTIVE_TIMEOUT_SEC=55
38
+ INFERENCE_BACKGROUND_TIMEOUT_SEC=120
39
+
40
+ # model defaults
41
+ INFERENCE_MODEL_ID=meta-llama/Llama-3.1-8B-Instruct
42
+ INFERENCE_MAX_NEW_TOKENS=640
43
+ INFERENCE_TEMPERATURE=0.2
44
+ INFERENCE_TOP_P=0.9
45
+ INFERENCE_CHAT_MODEL_ID=meta-llama/Llama-3.1-8B-Instruct
46
+ INFERENCE_CHAT_HARD_MODEL_ID=meta-llama/Meta-Llama-3-70B-Instruct
47
+ INFERENCE_CHAT_HARD_TRIGGER_ENABLED=true
48
+ INFERENCE_CHAT_HARD_PROMPT_CHARS=650
49
+ INFERENCE_CHAT_HARD_HISTORY_CHARS=1500
50
+ INFERENCE_CHAT_HARD_KEYWORDS=step-by-step,show all steps,explain each step,justify each step,derive,derivation,proof,prove,rigorous,multi-step,word problem
51
+ CHAT_MAX_NEW_TOKENS=768
52
+ # Optional: force quiz-generation model. Leave empty to use routing.task_model_map.quiz_generation.
53
+ HF_QUIZ_MODEL_ID=
54
+ HF_QUIZ_JSON_REPAIR_MODEL_ID=meta-llama/Llama-3.1-8B-Instruct
55
+
56
+ # retry behavior
57
+ INFERENCE_MAX_RETRIES=3
58
+ INFERENCE_BACKOFF_SEC=1.5
59
+ INFERENCE_INTERACTIVE_MAX_RETRIES=1
60
+ INFERENCE_BACKGROUND_MAX_RETRIES=3
61
+ INFERENCE_INTERACTIVE_BACKOFF_SEC=1.0
62
+ INFERENCE_BACKGROUND_BACKOFF_SEC=1.75
63
+ INFERENCE_INTERACTIVE_MAX_FALLBACK_DEPTH=1
64
+ # Max simultaneous blocking HF calls allowed from async endpoints.
65
+ HF_BLOCKING_CALL_CONCURRENCY=16
66
+ HF_ASYNC_MAX_CONNECTIONS=64
67
+ HF_ASYNC_MAX_KEEPALIVE_CONNECTIONS=32
68
+ HF_ASYNC_CONNECT_TIMEOUT_SEC=10.0
69
+ HF_ASYNC_WRITE_TIMEOUT_SEC=30.0
70
+ HF_ASYNC_POOL_TIMEOUT_SEC=10.0
71
+
72
+ # fallback model ids, comma-separated
73
+ INFERENCE_FALLBACK_MODELS=meta-llama/Meta-Llama-3-70B-Instruct,google/gemma-2-2b-it
74
+
75
+ # async generation controls
76
+ ENABLE_ASYNC_GENERATION=true
77
+ ASYNC_TASK_TTL_SECONDS=3600
78
+ ASYNC_TASK_MAX_ITEMS=400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
config/models.yaml CHANGED
@@ -1,60 +1,58 @@
1
- models:
2
- primary:
3
- id: meta-llama/Llama-3.1-8B-Instruct
4
- description: Fast default instruction model for interactive Grade 11-12 math tutoring
5
- max_new_tokens: 640
6
- temperature: 0.25
7
- top_p: 0.9
8
-
9
- backup:
10
- - id: meta-llama/Meta-Llama-3-70B-Instruct
11
- description: High-quality model used for harder multi-step prompts
12
- max_new_tokens: 768
13
- temperature: 0.3
14
- top_p: 0.9
15
- - id: google/gemma-2-2b-it
16
- description: Secondary backup with broad instruction coverage
17
- max_new_tokens: 384
18
- temperature: 0.2
19
- top_p: 0.9
20
-
21
- experimental:
22
- - id: mistralai/Mistral-7B-Instruct-v0.3
23
- notes: Prompt/procedure experimentation
24
- - id: meta-llama/Meta-Llama-3-8B-Instruct
25
- notes: Baseline comparison against legacy deployment
26
-
27
- routing:
28
- task_model_map:
29
- # Chat default: Qwen2.5-7B for improved math quality and latency.
30
- # Hard prompts can escalate to 70B via runtime policy in inference_client.
31
- chat: Qwen/Qwen2.5-7B-Instruct
32
- verify_solution: NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO
33
- lesson_generation: meta-llama/Llama-3.1-8B-Instruct
34
- quiz_generation: meta-llama/Llama-3.1-8B-Instruct
35
- learning_path: meta-llama/Llama-3.1-8B-Instruct
36
- daily_insight: meta-llama/Llama-3.1-8B-Instruct
37
- risk_classification: meta-llama/Llama-3.1-8B-Instruct
38
- risk_narrative: meta-llama/Llama-3.1-8B-Instruct
39
-
40
- task_fallback_model_map:
41
- chat:
42
- - meta-llama/Meta-Llama-3-70B-Instruct # Hard/fallback quality tier
43
- - google/gemma-2-2b-it # Fast safety fallback
44
- verify_solution:
45
- - NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO # Primary (reasoning-focused)
46
- - meta-llama/Meta-Llama-3-70B-Instruct # First fallback
47
- - meta-llama/Llama-3.1-8B-Instruct # Second fallback
48
-
49
- task_provider_map:
50
- # All tasks default to hf_inference router.
51
- # Runtime note: when INFERENCE_PROVIDER=local_peft, chat is routed to local_peft first,
52
- # then falls back to hf_inference while non-chat tasks remain on these mappings.
53
- chat: hf_inference
54
- verify_solution: hf_inference
55
- lesson_generation: hf_inference
56
- quiz_generation: hf_inference
57
- learning_path: hf_inference
58
- daily_insight: hf_inference
59
- risk_narrative: hf_inference
60
- risk_classification: hf_inference
 
1
+ models:
2
+ primary:
3
+ id: meta-llama/Llama-3.1-8B-Instruct
4
+ description: Fast default instruction model for interactive Grade 11-12 math tutoring
5
+ max_new_tokens: 640
6
+ temperature: 0.25
7
+ top_p: 0.9
8
+
9
+ backup:
10
+ - id: meta-llama/Meta-Llama-3-70B-Instruct
11
+ description: High-quality model used for harder multi-step prompts
12
+ max_new_tokens: 768
13
+ temperature: 0.3
14
+ top_p: 0.9
15
+ - id: google/gemma-2-2b-it
16
+ description: Secondary backup with broad instruction coverage
17
+ max_new_tokens: 384
18
+ temperature: 0.2
19
+ top_p: 0.9
20
+
21
+ experimental:
22
+ - id: mistralai/Mistral-7B-Instruct-v0.3
23
+ notes: Prompt/procedure experimentation
24
+ - id: meta-llama/Meta-Llama-3-8B-Instruct
25
+ notes: Baseline comparison against legacy deployment
26
+
27
+ routing:
28
+ task_model_map:
29
+ # Chat default: Llama-3.1-8B for low latency.
30
+ # Hard prompts can escalate to 70B via runtime policy in inference_client.
31
+ chat: meta-llama/Llama-3.1-8B-Instruct
32
+ verify_solution: NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO
33
+ lesson_generation: meta-llama/Llama-3.1-8B-Instruct
34
+ quiz_generation: meta-llama/Llama-3.1-8B-Instruct
35
+ learning_path: meta-llama/Llama-3.1-8B-Instruct
36
+ daily_insight: meta-llama/Llama-3.1-8B-Instruct
37
+ risk_classification: meta-llama/Llama-3.1-8B-Instruct
38
+ risk_narrative: meta-llama/Llama-3.1-8B-Instruct
39
+
40
+ task_fallback_model_map:
41
+ chat:
42
+ - meta-llama/Meta-Llama-3-70B-Instruct # Hard/fallback quality tier
43
+ - google/gemma-2-2b-it # Fast safety fallback
44
+ verify_solution:
45
+ - NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO # Primary (reasoning-focused)
46
+ - meta-llama/Meta-Llama-3-70B-Instruct # First fallback
47
+ - meta-llama/Llama-3.1-8B-Instruct # Second fallback
48
+
49
+ task_provider_map:
50
+ # All tasks use hf_inference router (Qwen2.5-7B-Instruct natively supported)
51
+ chat: hf_inference
52
+ verify_solution: hf_inference
53
+ lesson_generation: hf_inference
54
+ quiz_generation: hf_inference
55
+ learning_path: hf_inference
56
+ daily_insight: hf_inference
57
+ risk_narrative: hf_inference
58
+ risk_classification: hf_inference
 
 
main.py CHANGED
The diff for this file is too large to render. See raw diff
 
requirements.txt CHANGED
@@ -1,22 +1,17 @@
1
- fastapi>=0.104.0
2
- uvicorn[standard]>=0.24.0
3
- huggingface-hub>=0.31.0
4
- transformers>=4.45.0
5
- peft>=0.13.0
6
- accelerate>=0.34.0
7
- torch>=2.3.0
8
- bitsandbytes>=0.43.0; platform_system != "Darwin"
9
- requests>=2.31.0
10
- pandas==2.2.3
11
- openpyxl==3.1.5
12
- pdfplumber==0.11.5
13
- python-docx==1.1.2
14
- python-multipart>=0.0.6
15
- sympy==1.13.3
16
- matplotlib==3.10.0
17
- scikit-learn==1.6.1
18
- joblib==1.4.2
19
- scipy==1.15.1
20
- numpy==2.2.1
21
- firebase-admin>=6.2.0
22
- PyYAML>=6.0.0
 
1
+ fastapi>=0.104.0
2
+ uvicorn[standard]>=0.24.0
3
+ huggingface-hub>=0.31.0
4
+ requests>=2.31.0
5
+ pandas==2.2.3
6
+ openpyxl==3.1.5
7
+ pdfplumber==0.11.5
8
+ python-docx==1.1.2
9
+ python-multipart>=0.0.6
10
+ sympy==1.13.3
11
+ matplotlib==3.10.0
12
+ scikit-learn==1.6.1
13
+ joblib==1.4.2
14
+ scipy==1.15.1
15
+ numpy==2.2.1
16
+ firebase-admin>=6.2.0
17
+ PyYAML>=6.0.0
 
 
 
 
 
services/__init__.py CHANGED
@@ -1 +1 @@
1
- """Backend service helpers for inference, logging, and integrations."""
 
1
+ """Backend service helpers for inference, logging, and integrations."""
services/inference_client.py CHANGED
The diff for this file is too large to render. See raw diff
 
services/logging_utils.py CHANGED
@@ -1,86 +1,86 @@
1
- import json
2
- import logging
3
- from datetime import datetime, timezone
4
- from typing import Any, Dict, Optional
5
-
6
-
7
- def configure_structured_logging(name: str) -> logging.Logger:
8
- logger = logging.getLogger(name)
9
- if logger.handlers:
10
- return logger
11
-
12
- logger.setLevel(logging.INFO)
13
- handler = logging.StreamHandler()
14
- formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")
15
- handler.setFormatter(formatter)
16
- logger.addHandler(handler)
17
- logger.propagate = False
18
- return logger
19
-
20
-
21
- def _safe_json(payload: Dict[str, Any]) -> str:
22
- return json.dumps(payload, ensure_ascii=True, default=str)
23
-
24
-
25
- def log_model_call(
26
- logger: logging.Logger,
27
- *,
28
- provider: str,
29
- model: str,
30
- endpoint: str,
31
- latency_ms: float,
32
- input_tokens: Optional[int],
33
- output_tokens: Optional[int],
34
- status: str,
35
- error_class: Optional[str] = None,
36
- error_message: Optional[str] = None,
37
- task_type: Optional[str] = None,
38
- request_tag: Optional[str] = None,
39
- retry_attempt: Optional[int] = None,
40
- fallback_depth: Optional[int] = None,
41
- route: Optional[str] = None,
42
- ) -> None:
43
- payload = {
44
- "ts": datetime.now(timezone.utc).isoformat(),
45
- "event": "model_call",
46
- "provider": provider,
47
- "model": model,
48
- "endpoint": endpoint,
49
- "latency_ms": round(latency_ms, 2),
50
- "input_tokens": input_tokens,
51
- "output_tokens": output_tokens,
52
- "status": status,
53
- "error_class": error_class,
54
- "error_message": error_message,
55
- "task_type": task_type,
56
- "request_tag": request_tag,
57
- "retry_attempt": retry_attempt,
58
- "fallback_depth": fallback_depth,
59
- "route": route,
60
- }
61
- if status == "ok":
62
- logger.info(_safe_json(payload))
63
- else:
64
- logger.error(_safe_json(payload))
65
-
66
-
67
- def log_job_metric(
68
- logger: logging.Logger,
69
- *,
70
- job_name: str,
71
- run_id: str,
72
- metric_name: str,
73
- metric_value: Any,
74
- extras: Optional[Dict[str, Any]] = None,
75
- ) -> None:
76
- payload: Dict[str, Any] = {
77
- "ts": datetime.now(timezone.utc).isoformat(),
78
- "event": "job_metric",
79
- "job_name": job_name,
80
- "run_id": run_id,
81
- "metric_name": metric_name,
82
- "metric_value": metric_value,
83
- }
84
- if extras:
85
- payload.update(extras)
86
- logger.info(_safe_json(payload))
 
1
+ import json
2
+ import logging
3
+ from datetime import datetime, timezone
4
+ from typing import Any, Dict, Optional
5
+
6
+
7
+ def configure_structured_logging(name: str) -> logging.Logger:
8
+ logger = logging.getLogger(name)
9
+ if logger.handlers:
10
+ return logger
11
+
12
+ logger.setLevel(logging.INFO)
13
+ handler = logging.StreamHandler()
14
+ formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")
15
+ handler.setFormatter(formatter)
16
+ logger.addHandler(handler)
17
+ logger.propagate = False
18
+ return logger
19
+
20
+
21
+ def _safe_json(payload: Dict[str, Any]) -> str:
22
+ return json.dumps(payload, ensure_ascii=True, default=str)
23
+
24
+
25
+ def log_model_call(
26
+ logger: logging.Logger,
27
+ *,
28
+ provider: str,
29
+ model: str,
30
+ endpoint: str,
31
+ latency_ms: float,
32
+ input_tokens: Optional[int],
33
+ output_tokens: Optional[int],
34
+ status: str,
35
+ error_class: Optional[str] = None,
36
+ error_message: Optional[str] = None,
37
+ task_type: Optional[str] = None,
38
+ request_tag: Optional[str] = None,
39
+ retry_attempt: Optional[int] = None,
40
+ fallback_depth: Optional[int] = None,
41
+ route: Optional[str] = None,
42
+ ) -> None:
43
+ payload = {
44
+ "ts": datetime.now(timezone.utc).isoformat(),
45
+ "event": "model_call",
46
+ "provider": provider,
47
+ "model": model,
48
+ "endpoint": endpoint,
49
+ "latency_ms": round(latency_ms, 2),
50
+ "input_tokens": input_tokens,
51
+ "output_tokens": output_tokens,
52
+ "status": status,
53
+ "error_class": error_class,
54
+ "error_message": error_message,
55
+ "task_type": task_type,
56
+ "request_tag": request_tag,
57
+ "retry_attempt": retry_attempt,
58
+ "fallback_depth": fallback_depth,
59
+ "route": route,
60
+ }
61
+ if status == "ok":
62
+ logger.info(_safe_json(payload))
63
+ else:
64
+ logger.error(_safe_json(payload))
65
+
66
+
67
+ def log_job_metric(
68
+ logger: logging.Logger,
69
+ *,
70
+ job_name: str,
71
+ run_id: str,
72
+ metric_name: str,
73
+ metric_value: Any,
74
+ extras: Optional[Dict[str, Any]] = None,
75
+ ) -> None:
76
+ payload: Dict[str, Any] = {
77
+ "ts": datetime.now(timezone.utc).isoformat(),
78
+ "event": "job_metric",
79
+ "job_name": job_name,
80
+ "run_id": run_id,
81
+ "metric_name": metric_name,
82
+ "metric_value": metric_value,
83
+ }
84
+ if extras:
85
+ payload.update(extras)
86
+ logger.info(_safe_json(payload))
startup_validation.py CHANGED
@@ -1,292 +1,243 @@
1
- """
2
- Startup validation for MathPulse AI backend.
3
-
4
- This module validates all critical dependencies and configurations BEFORE
5
- the FastAPI app starts, preventing indefinite restart loops.
6
-
7
- If any critical check fails, the process exits with a clear error message
8
- that's visible in HF Space logs.
9
- """
10
-
11
- import os
12
- import sys
13
- import logging
14
- import importlib
15
- from pathlib import Path
16
-
17
- logger = logging.getLogger("mathpulse.startup")
18
-
19
-
20
- class StartupError(Exception):
21
- """Critical error during startup validation."""
22
- pass
23
-
24
-
25
- def validate_imports() -> None:
26
- """Verify all critical imports work. Use absolute imports."""
27
- logger.info("๐Ÿ” Validating Python imports...")
28
- try:
29
- # Core FastAPI stack
30
- import fastapi # noqa
31
- import uvicorn # noqa
32
- import pydantic # noqa
33
- logger.info(" โœ“ FastAPI, Uvicorn, Pydantic OK")
34
-
35
- # Backend services (use ABSOLUTE imports like deployed code)
36
- from services.inference_client import InferenceClient, create_default_client # noqa
37
- logger.info(" โœ“ InferenceClient imports OK")
38
-
39
- from automation_engine import automation_engine # noqa
40
- logger.info(" โœ“ automation_engine imports OK")
41
-
42
- from analytics import compute_competency_analysis # noqa
43
- logger.info(" โœ“ analytics imports OK")
44
-
45
- # Firebase
46
- try:
47
- import firebase_admin # noqa
48
- logger.info(" โœ“ firebase_admin imports OK")
49
- except ImportError:
50
- logger.warning(" โš  firebase_admin not available (OK if Firebase not needed)")
51
-
52
- # ML & inference
53
- from huggingface_hub import InferenceClient as HFInferenceClient # noqa
54
- logger.info(" โœ“ HuggingFace Hub imports OK")
55
-
56
- inference_provider = os.getenv("INFERENCE_PROVIDER", "hf_inference").strip().lower()
57
- if inference_provider == "local_peft":
58
- importlib.import_module("transformers")
59
- importlib.import_module("peft")
60
- importlib.import_module("accelerate")
61
- logger.info(" โœ“ local_peft deps import OK (transformers, peft, accelerate)")
62
-
63
- load_in_4bit = os.getenv("LORA_LOAD_IN_4BIT", "false").strip().lower() in {"1", "true", "yes", "on"}
64
- if load_in_4bit:
65
- try:
66
- importlib.import_module("bitsandbytes")
67
- logger.info(" โœ“ bitsandbytes import OK")
68
- except ImportError as exc:
69
- raise StartupError(
70
- "โŒ local_peft dependency error: bitsandbytes is required when LORA_LOAD_IN_4BIT=true"
71
- ) from exc
72
-
73
- logger.info("โœ… All critical imports validated")
74
- except ImportError as e:
75
- raise StartupError(
76
- f"โŒ IMPORT ERROR - Cannot start backend:\n"
77
- f" {e}\n"
78
- f"\n"
79
- f"This usually means:\n"
80
- f" - A Python package is missing (check requirements.txt)\n"
81
- f" - A relative import was used (must be absolute in container)\n"
82
- f" - A circular import exists\n"
83
- f"\n"
84
- f"Deploy will FAIL and backend will restart indefinitely.\n"
85
- ) from e
86
- except Exception as e:
87
- raise StartupError(f"โŒ Unexpected import error: {e}") from e
88
-
89
-
90
- def validate_environment() -> None:
91
- """Verify required environment variables are set."""
92
- logger.info("๐Ÿ” Validating environment variables...")
93
-
94
- # CRITICAL: HF_TOKEN for inference
95
- hf_token = os.environ.get("HF_TOKEN")
96
- api_key = os.environ.get("HUGGING_FACE_API_TOKEN")
97
- legacy_api_key = os.environ.get("HUGGINGFACE_API_TOKEN")
98
- if not hf_token and not api_key and not legacy_api_key:
99
- logger.warning(
100
- "โš  WARNING: HF_TOKEN is not set as an environment variable.\n"
101
- " On HF Spaces, this should be set as a SPACE SECRET.\n"
102
- " AI inference will fail without this token.\n"
103
- " Use: python set-hf-secrets.py to set the secret."
104
- )
105
- else:
106
- logger.info(" โœ“ HF_TOKEN/HUGGING_FACE_API_TOKEN/HUGGINGFACE_API_TOKEN is set")
107
-
108
- # Check inference provider config
109
- inference_provider = os.getenv("INFERENCE_PROVIDER", "hf_inference")
110
- logger.info(f" โœ“ INFERENCE_PROVIDER: {inference_provider}")
111
-
112
- # Check model IDs
113
- chat_model = os.getenv("INFERENCE_CHAT_MODEL_ID") or os.getenv("INFERENCE_MODEL_ID") or "Qwen/Qwen2.5-7B-Instruct"
114
- logger.info(f" โœ“ Chat model configured: {chat_model}")
115
-
116
- if inference_provider.strip().lower() == "local_peft":
117
- lora_base_model_id = os.getenv("LORA_BASE_MODEL_ID", "").strip()
118
- lora_adapter_model_id = os.getenv("LORA_ADAPTER_MODEL_ID", "").strip()
119
- if not lora_base_model_id:
120
- raise StartupError("โŒ LORA_BASE_MODEL_ID is required when INFERENCE_PROVIDER=local_peft")
121
- if not lora_adapter_model_id:
122
- raise StartupError("โŒ LORA_ADAPTER_MODEL_ID is required when INFERENCE_PROVIDER=local_peft")
123
-
124
- logger.info(f" โœ“ local_peft base model: {lora_base_model_id}")
125
- logger.info(f" โœ“ local_peft adapter model: {lora_adapter_model_id}")
126
- logger.info(f" โœ“ local_peft load_in_4bit: {os.getenv('LORA_LOAD_IN_4BIT', 'false')}")
127
- logger.info(f" โœ“ local_peft device_map: {os.getenv('LORA_DEVICE_MAP', 'auto')}")
128
- logger.info(f" โœ“ local_peft dtype: {os.getenv('LORA_DTYPE', 'float16')}")
129
-
130
- logger.info("โœ… Environment variables OK")
131
-
132
-
133
- def validate_config_files() -> None:
134
- """Verify config files exist and are readable."""
135
- logger.info("๐Ÿ” Validating configuration files...")
136
-
137
- config_paths = [
138
- "config/models.yaml",
139
- "backend/config/models.yaml",
140
- ]
141
-
142
- for config_path in config_paths:
143
- full_path = Path(config_path)
144
- if not full_path.exists():
145
- logger.warning(f" โš  Config file not found: {config_path}")
146
- else:
147
- try:
148
- with open(full_path, 'r') as f:
149
- content = f.read()
150
- if not content.strip():
151
- raise StartupError(
152
- f"โŒ CONFIG ERROR: {config_path} is empty!\n"
153
- f" This will cause model routing to fail.\n"
154
- )
155
- logger.info(f" โœ“ {config_path} is readable and non-empty")
156
- except Exception as e:
157
- raise StartupError(
158
- f"โŒ CONFIG ERROR: Cannot read {config_path}:\n"
159
- f" {e}\n"
160
- ) from e
161
-
162
- logger.info("โœ… Configuration files OK")
163
-
164
-
165
- def validate_file_structure() -> None:
166
- """Verify critical backend files exist."""
167
- logger.info("๐Ÿ” Validating file structure...")
168
- required_path_sets = [
169
- ["main.py", "backend/main.py"],
170
- ["services/inference_client.py", "backend/services/inference_client.py"],
171
- ["analytics.py", "backend/analytics.py"],
172
- ["automation_engine.py", "backend/automation_engine.py"],
173
- ]
174
-
175
- for candidates in required_path_sets:
176
- found = None
177
- for candidate in candidates:
178
- if Path(candidate).exists():
179
- found = candidate
180
- break
181
-
182
- if not found:
183
- joined = " or ".join(candidates)
184
- raise StartupError(
185
- f"โŒ FILE MISSING: {joined}\n"
186
- f" Backend structure is broken for this deployment layout.\n"
187
- )
188
-
189
- logger.info(f" โœ“ Found {found}")
190
-
191
- docker_candidates = ["Dockerfile", "backend/Dockerfile"]
192
- found_dockerfile = next((candidate for candidate in docker_candidates if Path(candidate).exists()), None)
193
- if found_dockerfile:
194
- logger.info(f" โœ“ Found {found_dockerfile}")
195
- else:
196
- logger.info(" โ„น Dockerfile not present in runtime image (expected in deployed containers)")
197
-
198
- logger.info("โœ… File structure OK")
199
-
200
-
201
- def validate_inference_client_config() -> None:
202
- """Validate InferenceClient can load its config."""
203
- logger.info("๐Ÿ” Validating InferenceClient configuration...")
204
-
205
- try:
206
- # Try to create the client (this will load config from YAML)
207
- from services.inference_client import create_default_client
208
- client = create_default_client()
209
-
210
- # Verify critical attributes
211
- if not hasattr(client, 'task_model_map'):
212
- raise StartupError("โŒ InferenceClient missing task_model_map attribute")
213
-
214
- if not hasattr(client, 'task_provider_map'):
215
- raise StartupError("โŒ InferenceClient missing task_provider_map attribute")
216
-
217
- # Check that required tasks are mapped
218
- required_tasks = ['chat', 'verify_solution', 'lesson_generation', 'quiz_generation']
219
- for task in required_tasks:
220
- if task not in client.task_model_map:
221
- raise StartupError(
222
- f"โŒ Task '{task}' not in task_model_map.\n"
223
- f" Check config/models.yaml\n"
224
- )
225
- model = client.task_model_map[task]
226
- provider = client.task_provider_map.get(task, 'unknown')
227
- logger.info(f" โœ“ {task}: {model} ({provider})")
228
-
229
- if getattr(client, "provider", "") == "local_peft":
230
- if not getattr(client, "lora_base_model_id", ""):
231
- raise StartupError("โŒ local_peft missing LORA_BASE_MODEL_ID")
232
- if not getattr(client, "lora_adapter_model_id", ""):
233
- raise StartupError("โŒ local_peft missing LORA_ADAPTER_MODEL_ID")
234
- logger.info(
235
- " โœ“ local_peft runtime config: base=%s adapter=%s",
236
- client.lora_base_model_id,
237
- client.lora_adapter_model_id,
238
- )
239
-
240
- logger.info("โœ… InferenceClient configuration OK")
241
-
242
- except StartupError:
243
- raise
244
- except Exception as e:
245
- raise StartupError(
246
- f"โŒ InferenceClient validation failed:\n"
247
- f" {e}\n"
248
- f" Check config/models.yaml and backend/config/models.yaml\n"
249
- ) from e
250
-
251
-
252
- def run_all_validations() -> None:
253
- """Run comprehensive startup validation.
254
-
255
- If any check fails, exits with clear error message visible in logs.
256
- """
257
- logger.info("=" * 70)
258
- logger.info("๐Ÿš€ STARTUP VALIDATION - Checking all critical dependencies")
259
- logger.info("=" * 70)
260
-
261
- strict_mode = os.getenv("STARTUP_VALIDATION_STRICT", "false").strip().lower() in {"1", "true", "yes", "on"}
262
-
263
- try:
264
- validate_file_structure()
265
- validate_imports()
266
- validate_environment()
267
- validate_config_files()
268
- validate_inference_client_config()
269
-
270
- logger.info("=" * 70)
271
- logger.info("โœ… ALL STARTUP VALIDATIONS PASSED")
272
- logger.info("=" * 70)
273
-
274
- except StartupError as e:
275
- logger.error("=" * 70)
276
- logger.error(str(e))
277
- logger.error("=" * 70)
278
- if strict_mode:
279
- logger.error("\n๐Ÿ›‘ DEPLOYMENT WILL FAIL - Fix errors above and redeploy")
280
- sys.exit(1)
281
- logger.warning(
282
- "\nโš ๏ธ Continuing startup because STARTUP_VALIDATION_STRICT is disabled. "
283
- "Set STARTUP_VALIDATION_STRICT=true to fail fast."
284
- )
285
- except Exception as e:
286
- logger.exception(f"Unexpected validation error: {e}")
287
- if strict_mode:
288
- sys.exit(1)
289
- logger.warning(
290
- "โš ๏ธ Continuing startup after unexpected validation error because "
291
- "STARTUP_VALIDATION_STRICT is disabled."
292
- )
 
1
+ """
2
+ Startup validation for MathPulse AI backend.
3
+
4
+ This module validates all critical dependencies and configurations BEFORE
5
+ the FastAPI app starts, preventing indefinite restart loops.
6
+
7
+ If any critical check fails, the process exits with a clear error message
8
+ that's visible in HF Space logs.
9
+ """
10
+
11
+ import os
12
+ import sys
13
+ import logging
14
+ from pathlib import Path
15
+
16
+ logger = logging.getLogger("mathpulse.startup")
17
+
18
+
19
+ class StartupError(Exception):
20
+ """Critical error during startup validation."""
21
+ pass
22
+
23
+
24
+ def validate_imports() -> None:
25
+ """Verify all critical imports work. Use absolute imports."""
26
+ logger.info("๐Ÿ” Validating Python imports...")
27
+ try:
28
+ # Core FastAPI stack
29
+ import fastapi # noqa
30
+ import uvicorn # noqa
31
+ import pydantic # noqa
32
+ logger.info(" โœ“ FastAPI, Uvicorn, Pydantic OK")
33
+
34
+ # Backend services (use ABSOLUTE imports like deployed code)
35
+ from services.inference_client import InferenceClient, create_default_client # noqa
36
+ logger.info(" โœ“ InferenceClient imports OK")
37
+
38
+ from automation_engine import automation_engine # noqa
39
+ logger.info(" โœ“ automation_engine imports OK")
40
+
41
+ from analytics import compute_competency_analysis # noqa
42
+ logger.info(" โœ“ analytics imports OK")
43
+
44
+ # Firebase
45
+ try:
46
+ import firebase_admin # noqa
47
+ logger.info(" โœ“ firebase_admin imports OK")
48
+ except ImportError:
49
+ logger.warning(" โš  firebase_admin not available (OK if Firebase not needed)")
50
+
51
+ # ML & inference
52
+ from huggingface_hub import InferenceClient as HFInferenceClient # noqa
53
+ logger.info(" โœ“ HuggingFace Hub imports OK")
54
+
55
+ logger.info("โœ… All critical imports validated")
56
+ except ImportError as e:
57
+ raise StartupError(
58
+ f"โŒ IMPORT ERROR - Cannot start backend:\n"
59
+ f" {e}\n"
60
+ f"\n"
61
+ f"This usually means:\n"
62
+ f" - A Python package is missing (check requirements.txt)\n"
63
+ f" - A relative import was used (must be absolute in container)\n"
64
+ f" - A circular import exists\n"
65
+ f"\n"
66
+ f"Deploy will FAIL and backend will restart indefinitely.\n"
67
+ ) from e
68
+ except Exception as e:
69
+ raise StartupError(f"โŒ Unexpected import error: {e}") from e
70
+
71
+
72
+ def validate_environment() -> None:
73
+ """Verify required environment variables are set."""
74
+ logger.info("๐Ÿ” Validating environment variables...")
75
+
76
+ # CRITICAL: HF_TOKEN for inference
77
+ hf_token = os.environ.get("HF_TOKEN")
78
+ api_key = os.environ.get("HUGGING_FACE_API_TOKEN")
79
+ legacy_api_key = os.environ.get("HUGGINGFACE_API_TOKEN")
80
+ if not hf_token and not api_key and not legacy_api_key:
81
+ logger.warning(
82
+ "โš  WARNING: HF_TOKEN is not set as an environment variable.\n"
83
+ " On HF Spaces, this should be set as a SPACE SECRET.\n"
84
+ " AI inference will fail without this token.\n"
85
+ " Use: python set-hf-secrets.py to set the secret."
86
+ )
87
+ else:
88
+ logger.info(" โœ“ HF_TOKEN/HUGGING_FACE_API_TOKEN/HUGGINGFACE_API_TOKEN is set")
89
+
90
+ # Check inference provider config
91
+ inference_provider = os.getenv("INFERENCE_PROVIDER", "hf_inference")
92
+ logger.info(f" โœ“ INFERENCE_PROVIDER: {inference_provider}")
93
+
94
+ # Check model IDs
95
+ chat_model = os.getenv("INFERENCE_CHAT_MODEL_ID") or os.getenv("INFERENCE_MODEL_ID") or "Qwen/Qwen2.5-7B-Instruct"
96
+ logger.info(f" โœ“ Chat model configured: {chat_model}")
97
+
98
+ logger.info("โœ… Environment variables OK")
99
+
100
+
101
+ def validate_config_files() -> None:
102
+ """Verify config files exist and are readable."""
103
+ logger.info("๐Ÿ” Validating configuration files...")
104
+
105
+ config_paths = [
106
+ "config/models.yaml",
107
+ "backend/config/models.yaml",
108
+ ]
109
+
110
+ for config_path in config_paths:
111
+ full_path = Path(config_path)
112
+ if not full_path.exists():
113
+ logger.warning(f" โš  Config file not found: {config_path}")
114
+ else:
115
+ try:
116
+ with open(full_path, 'r') as f:
117
+ content = f.read()
118
+ if not content.strip():
119
+ raise StartupError(
120
+ f"โŒ CONFIG ERROR: {config_path} is empty!\n"
121
+ f" This will cause model routing to fail.\n"
122
+ )
123
+ logger.info(f" โœ“ {config_path} is readable and non-empty")
124
+ except Exception as e:
125
+ raise StartupError(
126
+ f"โŒ CONFIG ERROR: Cannot read {config_path}:\n"
127
+ f" {e}\n"
128
+ ) from e
129
+
130
+ logger.info("โœ… Configuration files OK")
131
+
132
+
133
+ def validate_file_structure() -> None:
134
+ """Verify critical backend files exist."""
135
+ logger.info("๐Ÿ” Validating file structure...")
136
+ required_path_sets = [
137
+ ["main.py", "backend/main.py"],
138
+ ["services/inference_client.py", "backend/services/inference_client.py"],
139
+ ["analytics.py", "backend/analytics.py"],
140
+ ["automation_engine.py", "backend/automation_engine.py"],
141
+ ["Dockerfile", "backend/Dockerfile"],
142
+ ]
143
+
144
+ for candidates in required_path_sets:
145
+ found = None
146
+ for candidate in candidates:
147
+ if Path(candidate).exists():
148
+ found = candidate
149
+ break
150
+
151
+ if not found:
152
+ joined = " or ".join(candidates)
153
+ raise StartupError(
154
+ f"โŒ FILE MISSING: {joined}\n"
155
+ f" Backend structure is broken for this deployment layout.\n"
156
+ )
157
+
158
+ logger.info(f" โœ“ Found {found}")
159
+
160
+ logger.info("โœ… File structure OK")
161
+
162
+
163
+ def validate_inference_client_config() -> None:
164
+ """Validate InferenceClient can load its config."""
165
+ logger.info("๐Ÿ” Validating InferenceClient configuration...")
166
+
167
+ try:
168
+ # Try to create the client (this will load config from YAML)
169
+ from services.inference_client import create_default_client
170
+ client = create_default_client()
171
+
172
+ # Verify critical attributes
173
+ if not hasattr(client, 'task_model_map'):
174
+ raise StartupError("โŒ InferenceClient missing task_model_map attribute")
175
+
176
+ if not hasattr(client, 'task_provider_map'):
177
+ raise StartupError("โŒ InferenceClient missing task_provider_map attribute")
178
+
179
+ # Check that required tasks are mapped
180
+ required_tasks = ['chat', 'verify_solution', 'lesson_generation', 'quiz_generation']
181
+ for task in required_tasks:
182
+ if task not in client.task_model_map:
183
+ raise StartupError(
184
+ f"โŒ Task '{task}' not in task_model_map.\n"
185
+ f" Check config/models.yaml\n"
186
+ )
187
+ model = client.task_model_map[task]
188
+ provider = client.task_provider_map.get(task, 'unknown')
189
+ logger.info(f" โœ“ {task}: {model} ({provider})")
190
+
191
+ logger.info("โœ… InferenceClient configuration OK")
192
+
193
+ except StartupError:
194
+ raise
195
+ except Exception as e:
196
+ raise StartupError(
197
+ f"โŒ InferenceClient validation failed:\n"
198
+ f" {e}\n"
199
+ f" Check config/models.yaml and backend/config/models.yaml\n"
200
+ ) from e
201
+
202
+
203
+ def run_all_validations() -> None:
204
+ """Run comprehensive startup validation.
205
+
206
+ If any check fails, exits with clear error message visible in logs.
207
+ """
208
+ logger.info("=" * 70)
209
+ logger.info("๐Ÿš€ STARTUP VALIDATION - Checking all critical dependencies")
210
+ logger.info("=" * 70)
211
+
212
+ strict_mode = os.getenv("STARTUP_VALIDATION_STRICT", "false").strip().lower() in {"1", "true", "yes", "on"}
213
+
214
+ try:
215
+ validate_file_structure()
216
+ validate_imports()
217
+ validate_environment()
218
+ validate_config_files()
219
+ validate_inference_client_config()
220
+
221
+ logger.info("=" * 70)
222
+ logger.info("โœ… ALL STARTUP VALIDATIONS PASSED")
223
+ logger.info("=" * 70)
224
+
225
+ except StartupError as e:
226
+ logger.error("=" * 70)
227
+ logger.error(str(e))
228
+ logger.error("=" * 70)
229
+ if strict_mode:
230
+ logger.error("\n๐Ÿ›‘ DEPLOYMENT WILL FAIL - Fix errors above and redeploy")
231
+ sys.exit(1)
232
+ logger.warning(
233
+ "\nโš ๏ธ Continuing startup because STARTUP_VALIDATION_STRICT is disabled. "
234
+ "Set STARTUP_VALIDATION_STRICT=true to fail fast."
235
+ )
236
+ except Exception as e:
237
+ logger.exception(f"Unexpected validation error: {e}")
238
+ if strict_mode:
239
+ sys.exit(1)
240
+ logger.warning(
241
+ "โš ๏ธ Continuing startup after unexpected validation error because "
242
+ "STARTUP_VALIDATION_STRICT is disabled."
243
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_api.py CHANGED
@@ -217,7 +217,7 @@ class TestChatEndpoint:
217
  def test_chat_with_history(self, mock_chat):
218
  mock_chat.return_value = "Yes, that's right."
219
  response = client.post("/api/chat", json={
220
- "message": "Is 2 + 2 = 4 correct?",
221
  "history": [
222
  {"role": "user", "content": "What is 2+2?"},
223
  {"role": "assistant", "content": "4"},
@@ -237,21 +237,11 @@ class TestChatEndpoint:
237
  def test_chat_hf_failure_returns_502(self, mock_chat):
238
  mock_chat.side_effect = Exception("HF API down")
239
  response = client.post("/api/chat", json={
240
- "message": "Solve 10 - 3.",
241
  "history": [],
242
  })
243
  assert response.status_code == 502
244
 
245
- @patch("main.call_hf_chat")
246
- def test_chat_rejects_non_math_query(self, mock_chat):
247
- response = client.post("/api/chat", json={
248
- "message": "Who is Elon Musk?",
249
- "history": [],
250
- })
251
- assert response.status_code == 200
252
- assert response.json()["response"] == main_module.MATH_ONLY_REFUSAL_MESSAGE
253
- mock_chat.assert_not_called()
254
-
255
  @patch("main.call_hf_chat")
256
  def test_chat_quadratic_prompt_smoke(self, mock_chat):
257
  mock_chat.return_value = (
@@ -272,7 +262,7 @@ class TestChatEndpoint:
272
  mock_stream.return_value = iter(["Hello", " world"])
273
 
274
  with client.stream("POST", "/api/chat/stream", json={
275
- "message": "What is 3 + 4?",
276
  "history": [],
277
  }) as response:
278
  assert response.status_code == 200
@@ -287,7 +277,7 @@ class TestChatEndpoint:
287
  mock_stream.side_effect = Exception("HF stream down")
288
 
289
  with client.stream("POST", "/api/chat/stream", json={
290
- "message": "Solve 2 + 2.",
291
  "history": [],
292
  }) as response:
293
  assert response.status_code == 200
@@ -296,22 +286,6 @@ class TestChatEndpoint:
296
  assert "event: error" in content
297
  assert "event: end" in content
298
 
299
- @patch("main.call_hf_chat_stream")
300
- def test_chat_stream_rejects_non_math_query(self, mock_stream):
301
- with client.stream("POST", "/api/chat/stream", json={
302
- "message": "Write me a poem.",
303
- "history": [],
304
- }) as response:
305
- assert response.status_code == 200
306
- content = "".join(response.iter_text())
307
-
308
- refusal_payload = json.dumps({"chunk": main_module.MATH_ONLY_REFUSAL_MESSAGE}, ensure_ascii=False)
309
- assert "event: chunk" in content
310
- assert f"data: {refusal_payload}" in content
311
- assert "event: end" in content
312
- assert "event: error" not in content
313
- mock_stream.assert_not_called()
314
-
315
 
316
  class TestHFChatTransport:
317
  @patch("main.http_requests.post")
@@ -343,74 +317,6 @@ class TestHFChatTransport:
343
  assert payload["stream"] is False
344
  assert isinstance(payload["messages"], list)
345
 
346
- def test_call_hf_chat_stream_supports_local_peft_provider(self):
347
- class FakeLocalClient:
348
- interactive_timeout_sec = 60
349
-
350
- def _resolve_primary_model(self, req):
351
- return "Qwen/Qwen2.5-7B-Instruct", False
352
-
353
- def _model_chain_for_task(self, task_type, selected_model):
354
- return [selected_model]
355
-
356
- def _provider_chain_for_task(self, task_type):
357
- return ["local_peft"]
358
-
359
- def _resolve_route_label(self, provider, task_type):
360
- return "standard"
361
-
362
- def _stream_local_peft(self, req, *, provider, route, fallback_depth):
363
- assert provider == "local_peft"
364
- assert req.task_type == "chat"
365
- yield "Adapter"
366
- yield " stream"
367
-
368
- with patch("main.get_inference_client", return_value=FakeLocalClient()):
369
- chunks = list(main_module.call_hf_chat_stream(
370
- [{"role": "user", "content": "Solve 2x + 4 = 10"}],
371
- max_tokens=64,
372
- task_type="chat",
373
- ))
374
-
375
- assert "".join(chunks) == "Adapter stream"
376
-
377
- def test_call_hf_chat_stream_async_supports_local_peft_provider(self, monkeypatch):
378
- monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
379
-
380
- class FakeLocalClient:
381
- def _resolve_primary_model(self, req):
382
- return "Qwen/Qwen2.5-7B-Instruct", False
383
-
384
- def _model_chain_for_task(self, task_type, selected_model):
385
- return [selected_model]
386
-
387
- def _provider_chain_for_task(self, task_type):
388
- return ["local_peft"]
389
-
390
- def _resolve_route_label(self, provider, task_type):
391
- return "standard"
392
-
393
- def _stream_local_peft(self, req, *, provider, route, fallback_depth):
394
- assert provider == "local_peft"
395
- assert req.task_type == "chat"
396
- yield "Adapter"
397
- yield " async"
398
-
399
- async def _collect() -> str:
400
- parts: List[str] = []
401
- async for chunk in main_module.call_hf_chat_stream_async(
402
- [{"role": "user", "content": "Solve 2x + 4 = 10"}],
403
- max_tokens=64,
404
- task_type="chat",
405
- ):
406
- parts.append(chunk)
407
- return "".join(parts)
408
-
409
- with patch("main.get_inference_client", return_value=FakeLocalClient()):
410
- result = asyncio.run(_collect())
411
-
412
- assert result == "Adapter async"
413
-
414
 
415
  # โ”€โ”€โ”€ Risk Prediction โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
416
 
 
217
  def test_chat_with_history(self, mock_chat):
218
  mock_chat.return_value = "Yes, that's right."
219
  response = client.post("/api/chat", json={
220
+ "message": "Is that correct?",
221
  "history": [
222
  {"role": "user", "content": "What is 2+2?"},
223
  {"role": "assistant", "content": "4"},
 
237
  def test_chat_hf_failure_returns_502(self, mock_chat):
238
  mock_chat.side_effect = Exception("HF API down")
239
  response = client.post("/api/chat", json={
240
+ "message": "Hello",
241
  "history": [],
242
  })
243
  assert response.status_code == 502
244
 
 
 
 
 
 
 
 
 
 
 
245
  @patch("main.call_hf_chat")
246
  def test_chat_quadratic_prompt_smoke(self, mock_chat):
247
  mock_chat.return_value = (
 
262
  mock_stream.return_value = iter(["Hello", " world"])
263
 
264
  with client.stream("POST", "/api/chat/stream", json={
265
+ "message": "Say hello",
266
  "history": [],
267
  }) as response:
268
  assert response.status_code == 200
 
277
  mock_stream.side_effect = Exception("HF stream down")
278
 
279
  with client.stream("POST", "/api/chat/stream", json={
280
+ "message": "Say hello",
281
  "history": [],
282
  }) as response:
283
  assert response.status_code == 200
 
286
  assert "event: error" in content
287
  assert "event: end" in content
288
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
 
290
  class TestHFChatTransport:
291
  @patch("main.http_requests.post")
 
317
  assert payload["stream"] is False
318
  assert isinstance(payload["messages"], list)
319
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
 
321
  # โ”€โ”€โ”€ Risk Prediction โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
322
 
tests/test_peft_smoke.py DELETED
@@ -1,66 +0,0 @@
1
- """
2
- backend/tests/test_peft_smoke.py
3
- Lightweight smoke tests for local_peft provider routing.
4
-
5
- Run with:
6
- python -m pytest backend/tests/test_peft_smoke.py -q
7
- """
8
-
9
- import os
10
- import sys
11
-
12
- # Add backend directory to path
13
- sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
14
-
15
- from services.inference_client import InferenceClient, InferenceRequest
16
-
17
-
18
- def _set_local_peft_env(monkeypatch):
19
- monkeypatch.setenv("INFERENCE_PROVIDER", "local_peft")
20
- monkeypatch.setenv("INFERENCE_ENABLE_PROVIDER_FALLBACK", "true")
21
- monkeypatch.setenv("INFERENCE_GPU_PROVIDER", "hf_inference")
22
- monkeypatch.setenv("INFERENCE_CPU_PROVIDER", "hf_inference")
23
- monkeypatch.setenv("LORA_BASE_MODEL_ID", "Qwen/Qwen2.5-7B-Instruct")
24
- monkeypatch.setenv(
25
- "LORA_ADAPTER_MODEL_ID",
26
- "Deign86/deped-math-qwen2.5-7b-checkpoint-700-lora",
27
- )
28
- monkeypatch.setenv("LORA_LOAD_IN_4BIT", "true")
29
- monkeypatch.setenv("LORA_DEVICE_MAP", "auto")
30
- monkeypatch.setenv("LORA_DTYPE", "float16")
31
- monkeypatch.setenv("LORA_MAX_NEW_TOKENS", "576")
32
-
33
-
34
- def test_local_peft_chat_provider_chain_prioritizes_adapter(monkeypatch):
35
- _set_local_peft_env(monkeypatch)
36
- client = InferenceClient()
37
-
38
- chat_chain = client._provider_chain_for_task("chat")
39
- assert chat_chain[0] == "local_peft"
40
- assert "hf_inference" in chat_chain
41
-
42
- # Non-chat tasks keep existing forced task provider mapping from models.yaml.
43
- verify_chain = client._provider_chain_for_task("verify_solution")
44
- assert verify_chain == ["hf_inference"]
45
-
46
-
47
- def test_local_peft_generate_path_returns_text(monkeypatch):
48
- _set_local_peft_env(monkeypatch)
49
-
50
- def fake_call_local_peft(self, req, *, provider, route, fallback_depth):
51
- assert provider == "local_peft"
52
- assert req.task_type == "chat"
53
- return "Adapter-generated text"
54
-
55
- monkeypatch.setattr(InferenceClient, "_call_local_peft", fake_call_local_peft)
56
- client = InferenceClient()
57
-
58
- req = InferenceRequest(
59
- messages=[{"role": "user", "content": "Solve 2x + 4 = 10"}],
60
- task_type="chat",
61
- max_new_tokens=128,
62
- )
63
- text = client.generate_from_messages(req)
64
-
65
- assert isinstance(text, str)
66
- assert text == "Adapter-generated text"