github-actions[bot] commited on
Commit
cca5aec
·
1 Parent(s): 4b42511

deploy: backend from 5bbaa0e

Browse files
.env.example CHANGED
@@ -17,8 +17,6 @@ DEEPSEEK_REASONER_MODEL=deepseek-reasoner
17
  # HF API token — kept only for HF Space deployment and dataset push
18
  HF_API_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
19
 
20
- # HF Model ID for AI monitoring proxy
21
- VITE_HF_MODEL_ID=Qwen/QwQ-32B
22
 
23
  # ── Model Selection ───────────────────────────────────────────────
24
  # LOCAL DEVELOPMENT — deepseek-chat (fast, $0.14/M input)
 
17
  # HF API token — kept only for HF Space deployment and dataset push
18
  HF_API_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
19
 
 
 
20
 
21
  # ── Model Selection ───────────────────────────────────────────────
22
  # LOCAL DEVELOPMENT — deepseek-chat (fast, $0.14/M input)
codemap.md CHANGED
@@ -16,7 +16,7 @@ FastAPI backend entry point: initializes Firebase/Firestore, authentication, mid
16
  4. Data paths read/write Firestore and model/RAG providers; async generation stores task state for `/api/tasks/{task_id}` polling.
17
 
18
  ## Integration
19
- - Router modules: `rag`, `admin_model`, `admin`, `curriculum`, `diagnostic`, `video`, `quiz_battle`, `teacher_materials`, `class_records`, `risk`, `tutor_checkin`, `practice`, `try_it_yourself`, `ai_monitoring`, `class_analytics`, `intervention`, `pipeline`, `deepseek_rag`, `at_risk_resolution`, `fun_modules`, `jev`.
20
  - Core routes include `/api/chat`, `/api/chat/stream`, `/api/verify-solution`, `/api/predict-risk`, `/api/learning-path`, `/api/lesson/generate`, `/api/quiz/generate`, account import, class-record upload, admin users, analytics, and `/api/automation/*`.
21
  - Firebase Auth token claims/Firestore `users` provide identity and role data. Other directly used collections include `diagnosticResults`, `classRecordImports`, `normalizedClassRecords`, `classrooms`, `managedStudents`, `riskRefreshEvents`, `riskRefreshJobs`, `riskRefreshStats`, and `classSectionOwnership`.
22
  - Uses `middleware.rate_limiter`, `config.models.yaml`/`config.ai_pricing.py`, `services.*`, `rag.*`, and top-level `analytics.py`/`automation_engine.py`.
 
16
  4. Data paths read/write Firestore and model/RAG providers; async generation stores task state for `/api/tasks/{task_id}` polling.
17
 
18
  ## Integration
19
+ - Router modules: `rag`, `admin_model`, `admin`, `curriculum`, `diagnostic`, `video`, `quiz_battle`, `teacher_materials`, `class_records`, `risk`, `tutor_checkin`, `practice`, `try_it_yourself`, `class_analytics`, `intervention`, `pipeline`, `deepseek_rag`, `at_risk_resolution`, `fun_modules`, `jev`.
20
  - Core routes include `/api/chat`, `/api/chat/stream`, `/api/verify-solution`, `/api/predict-risk`, `/api/learning-path`, `/api/lesson/generate`, `/api/quiz/generate`, account import, class-record upload, admin users, analytics, and `/api/automation/*`.
21
  - Firebase Auth token claims/Firestore `users` provide identity and role data. Other directly used collections include `diagnosticResults`, `classRecordImports`, `normalizedClassRecords`, `classrooms`, `managedStudents`, `riskRefreshEvents`, `riskRefreshJobs`, `riskRefreshStats`, and `classSectionOwnership`.
22
  - Uses `middleware.rate_limiter`, `config.models.yaml`/`config.ai_pricing.py`, `services.*`, `rag.*`, and top-level `analytics.py`/`automation_engine.py`.
main.py CHANGED
@@ -93,7 +93,7 @@ import httpx
93
  import uvicorn
94
  from services.inference_client import (
95
  InferenceRequest, create_default_client,
96
- get_model_for_task, get_current_runtime_config,
97
  )
98
  from services.deterministic_cache import DeterministicResponseCache
99
  from services.logging_utils import log_model_call
@@ -124,7 +124,6 @@ from routes.risk_router import router as risk_router
124
  from routes.tutor_checkin import router as tutor_checkin_router
125
  from routes.practice import router as practice_router
126
  from routes.try_it_yourself import router as try_it_yourself_router
127
- from routes.ai_monitoring import router as ai_monitoring_router
128
  from routes.class_analytics_routes import router as class_analytics_router
129
  from routes.intervention_routes import router as intervention_router
130
  from routes.pipeline_routes import router as pipeline_router
@@ -469,7 +468,6 @@ ROLE_POLICIES: Dict[str, Set[str]] = {
469
  "/api/analytics/class-insights": TEACHER_OR_ADMIN,
470
  "/api/analytics/refresh-cache": ADMIN_ONLY,
471
  "/api/testing/reset-data": ALL_APP_ROLES,
472
- "/api/hf/monitoring": ADMIN_ONLY,
473
  "/api/dev/generate-mock-data": ADMIN_ONLY,
474
  "/api/analytics/config": TEACHER_OR_ADMIN,
475
  "/api/analytics/imported-class-overview": TEACHER_OR_ADMIN,
@@ -1224,7 +1222,6 @@ app.include_router(risk_router)
1224
  app.include_router(tutor_checkin_router)
1225
  app.include_router(practice_router)
1226
  app.include_router(try_it_yourself_router)
1227
- app.include_router(ai_monitoring_router)
1228
  app.include_router(class_analytics_router)
1229
  app.include_router(intervention_router)
1230
  app.include_router(pipeline_router)
@@ -9722,11 +9719,6 @@ class AsyncTaskCancelResponse(BaseModel):
9722
  message: str
9723
 
9724
 
9725
- class HFMonitoringDataResponse(BaseModel):
9726
- success: bool
9727
- data: Dict[str, Any]
9728
-
9729
-
9730
  class ImportGroundedFeedbackRequest(BaseModel):
9731
  flow: str = Field(..., description="Flow identifier: quiz or lesson")
9732
  status: str = Field(..., description="Event status: success, failed, or skipped")
@@ -12304,83 +12296,6 @@ async def cancel_async_task(http_request: Request, task_id: str):
12304
  )
12305
 
12306
 
12307
- @app.get("/api/hf/monitoring", response_model=HFMonitoringDataResponse)
12308
- async def get_hf_monitoring(http_request: Request):
12309
- """
12310
- Aggregates DeepSeek AI status, model config, and latency probe.
12311
- Returns distilled data safe for frontend consumption.
12312
-
12313
- Requires admin authentication.
12314
- """
12315
- user = get_current_user(http_request)
12316
- if user.role != "admin":
12317
- raise HTTPException(status_code=403, detail="Forbidden for this role")
12318
-
12319
- _ensure_deepseek_available()
12320
-
12321
- try:
12322
- generation_model_id = get_model_for_task("chat")
12323
- except Exception:
12324
- generation_model_id = CHAT_MODEL
12325
-
12326
- embedding_model_id = os.getenv("EMBEDDING_MODEL", "BAAI/bge-small-en-v1.5")
12327
-
12328
- runtime_config = get_current_runtime_config()
12329
-
12330
- task_resolved: dict[str, str] = {}
12331
- for task in [
12332
- "chat", "verify_solution", "lesson_generation", "quiz_generation",
12333
- "learning_path", "daily_insight", "risk_classification", "risk_narrative",
12334
- "rag_lesson", "rag_problem", "rag_analysis_context",
12335
- ]:
12336
- try:
12337
- task_resolved[task] = get_model_for_task(task)
12338
- except Exception:
12339
- task_resolved[task] = generation_model_id
12340
-
12341
- result: Dict[str, Any] = {
12342
- "modelId": generation_model_id,
12343
- "modelStatus": "Operational",
12344
- "avgResponseTimeMs": 0,
12345
- "embeddingModelId": embedding_model_id,
12346
- "embeddingModelStatus": "Operational",
12347
- "inferenceBalance": 0.0,
12348
- "totalPeriodCost": 0.0,
12349
- "hubApiCallsUsed": 0,
12350
- "hubApiCallsLimit": 2500,
12351
- "zeroGpuMinutesUsed": 0,
12352
- "zeroGpuMinutesLimit": 25,
12353
- "publicStorageUsedTB": 0.0,
12354
- "publicStorageLimitTB": 11.2,
12355
- "lastChecked": datetime.now(timezone.utc).isoformat(),
12356
- "periodStart": "",
12357
- "periodEnd": "",
12358
- "activeProfile": runtime_config.get("profile") or os.getenv("MODEL_PROFILE", "dev"),
12359
- "runtimeOverridesActive": len(runtime_config.get("overrides", {})) > 0,
12360
- "resolvedModels": task_resolved,
12361
- "provider": "deepseek",
12362
- "apiBaseUrl": os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
12363
- }
12364
-
12365
- try:
12366
- client = get_deepseek_client()
12367
- latency_start = time.time()
12368
- probe_response = client.chat.completions.create(
12369
- model=str(CHAT_MODEL),
12370
- messages=[{"role": "user", "content": "Hi"}],
12371
- max_tokens=1,
12372
- temperature=0.0,
12373
- )
12374
- latency_ms = int((time.time() - latency_start) * 1000)
12375
- result["avgResponseTimeMs"] = latency_ms
12376
- result["modelStatus"] = "Operational"
12377
- except Exception as e:
12378
- logger.warning(f"DeepSeek latency probe failed: {e}")
12379
- result["modelStatus"] = "Degraded"
12380
-
12381
- return HFMonitoringDataResponse(success=True, data=result)
12382
-
12383
-
12384
  @app.get("/api/quiz/topics")
12385
  async def get_quiz_topics(response: Response, gradeLevel: Optional[str] = None):
12386
  """
 
93
  import uvicorn
94
  from services.inference_client import (
95
  InferenceRequest, create_default_client,
96
+ get_model_for_task,
97
  )
98
  from services.deterministic_cache import DeterministicResponseCache
99
  from services.logging_utils import log_model_call
 
124
  from routes.tutor_checkin import router as tutor_checkin_router
125
  from routes.practice import router as practice_router
126
  from routes.try_it_yourself import router as try_it_yourself_router
 
127
  from routes.class_analytics_routes import router as class_analytics_router
128
  from routes.intervention_routes import router as intervention_router
129
  from routes.pipeline_routes import router as pipeline_router
 
468
  "/api/analytics/class-insights": TEACHER_OR_ADMIN,
469
  "/api/analytics/refresh-cache": ADMIN_ONLY,
470
  "/api/testing/reset-data": ALL_APP_ROLES,
 
471
  "/api/dev/generate-mock-data": ADMIN_ONLY,
472
  "/api/analytics/config": TEACHER_OR_ADMIN,
473
  "/api/analytics/imported-class-overview": TEACHER_OR_ADMIN,
 
1222
  app.include_router(tutor_checkin_router)
1223
  app.include_router(practice_router)
1224
  app.include_router(try_it_yourself_router)
 
1225
  app.include_router(class_analytics_router)
1226
  app.include_router(intervention_router)
1227
  app.include_router(pipeline_router)
 
9719
  message: str
9720
 
9721
 
 
 
 
 
 
9722
  class ImportGroundedFeedbackRequest(BaseModel):
9723
  flow: str = Field(..., description="Flow identifier: quiz or lesson")
9724
  status: str = Field(..., description="Event status: success, failed, or skipped")
 
12296
  )
12297
 
12298
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12299
  @app.get("/api/quiz/topics")
12300
  async def get_quiz_topics(response: Response, gradeLevel: Optional[str] = None):
12301
  """
routes/ai_monitoring.py DELETED
@@ -1,243 +0,0 @@
1
- # backend/routes/ai_monitoring.py
2
- # TODO: Review pricing after 2026-05-31
3
- from datetime import date, datetime, time, timedelta, timezone
4
- from zoneinfo import ZoneInfo
5
- from fastapi import APIRouter, Depends, HTTPException, Request
6
- import logging
7
-
8
- from config.ai_pricing import get_active_pricing, get_full_pricing, DEEPSEEK_PRICING
9
-
10
- logger = logging.getLogger("mathpulse.ai_monitoring")
11
-
12
- router = APIRouter(prefix="/api/admin/ai-monitoring", tags=["admin", "ai-monitoring"])
13
- MANILA = ZoneInfo("Asia/Manila")
14
-
15
-
16
- def _now() -> datetime:
17
- return datetime.now(timezone.utc)
18
-
19
-
20
- def _get_firestore_client():
21
- try:
22
- from firebase_admin import firestore
23
-
24
- return firestore.client()
25
- except Exception as exc:
26
- logger.warning("AI monitoring Firestore unavailable: %s", exc)
27
- return None
28
-
29
-
30
- def _as_datetime(value) -> datetime | None:
31
- if isinstance(value, datetime):
32
- return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
33
- if isinstance(value, str):
34
- try:
35
- return datetime.fromisoformat(value.replace("Z", "+00:00"))
36
- except ValueError:
37
- return None
38
- return None
39
-
40
-
41
- def _aggregate_telemetry() -> dict:
42
- """Aggregate attempt events into Manila days; pending events count as failed attempts."""
43
- today = _now().astimezone(MANILA).date()
44
- first_day = today - timedelta(days=29)
45
- days: dict[date, dict] = {
46
- first_day + timedelta(days=offset): {
47
- "date": (first_day + timedelta(days=offset)).isoformat(),
48
- "totalAttempts": 0,
49
- "successfulAttempts": 0,
50
- "completedRequests": 0,
51
- "latencyTotalMs": 0.0,
52
- }
53
- for offset in range(30)
54
- }
55
- requests_by_task_type: dict[str, int] = {}
56
- start_at = datetime.combine(first_day, time.min, tzinfo=MANILA).astimezone(timezone.utc)
57
- end_at = datetime.combine(today + timedelta(days=1), time.min, tzinfo=MANILA).astimezone(timezone.utc)
58
- db = _get_firestore_client()
59
- if db is not None:
60
- try:
61
- events = (
62
- db.collection("ai_usage_logs")
63
- .where("timestamp", ">=", start_at)
64
- .where("timestamp", "<", end_at)
65
- .stream()
66
- )
67
- for snapshot in events:
68
- event = snapshot.to_dict() or {}
69
- timestamp = _as_datetime(event.get("timestamp"))
70
- if timestamp is None:
71
- timestamp = _as_datetime(event.get("createdAtIso"))
72
- if timestamp is None:
73
- continue
74
- bucket = timestamp.astimezone(MANILA).date()
75
- if bucket not in days:
76
- continue
77
- daily = days[bucket]
78
- daily["totalAttempts"] += 1
79
- task_type = event.get("taskType")
80
- if isinstance(task_type, str) and task_type:
81
- requests_by_task_type[task_type] = requests_by_task_type.get(task_type, 0) + 1
82
- if event.get("status") == "success":
83
- daily["successfulAttempts"] += 1
84
- latency_ms = event.get("latencyMs")
85
- if isinstance(latency_ms, (int, float)) and latency_ms >= 0:
86
- daily["completedRequests"] += 1
87
- daily["latencyTotalMs"] += latency_ms
88
- except Exception as exc:
89
- logger.warning("AI monitoring telemetry read failed: %s", exc)
90
-
91
- daily_metrics = []
92
- total_attempts = successful_attempts = completed_requests = 0
93
- latency_total_ms = 0.0
94
- for daily in days.values():
95
- total_attempts += daily["totalAttempts"]
96
- successful_attempts += daily["successfulAttempts"]
97
- completed_requests += daily["completedRequests"]
98
- latency_total_ms += daily["latencyTotalMs"]
99
- daily_metrics.append({
100
- "date": daily["date"],
101
- "totalAttempts": daily["totalAttempts"],
102
- "successfulAttempts": daily["successfulAttempts"],
103
- "completedRequests": daily["completedRequests"],
104
- "averageLatencyMs": round(daily["latencyTotalMs"] / daily["completedRequests"], 2) if daily["completedRequests"] else None,
105
- "successRate": round(daily["successfulAttempts"] / daily["totalAttempts"] * 100, 1) if daily["totalAttempts"] else None,
106
- })
107
-
108
- return {
109
- "dailyMetrics": daily_metrics,
110
- "requestsByTaskType": requests_by_task_type,
111
- "totalAttempts": total_attempts,
112
- "successfulAttempts": successful_attempts,
113
- "completedRequests": completed_requests,
114
- "averageLatencyMs": round(latency_total_ms / completed_requests, 2) if completed_requests else None,
115
- "successRate": round(successful_attempts / total_attempts * 100, 1) if total_attempts else None,
116
- "latencyDefinition": "Mean generation time in milliseconds across completed requests only.",
117
- "successRateDefinition": "Successful attempts divided by all attempts, as a percentage.",
118
- "dayTimezone": "Asia/Manila",
119
- }
120
-
121
-
122
- def require_admin(request: Request):
123
- user = getattr(request.state, "user", None)
124
- if user is None:
125
- raise HTTPException(status_code=401, detail="Authentication required")
126
- if user.role not in ("admin", "superadmin"):
127
- raise HTTPException(status_code=403, detail="Admin access required")
128
- return user
129
-
130
-
131
- def _build_pricing_meta(model_id: str = "deepseek-v4-pro") -> dict:
132
- """Build pricingMeta block for response."""
133
- pricing = get_active_pricing(model_id)
134
- full = get_full_pricing(model_id)
135
- now = datetime.now(timezone.utc)
136
- promo_config = DEEPSEEK_PRICING.get(model_id, {}).get("promotional", {})
137
- expires = promo_config.get("expires_utc", now)
138
- days_remaining = max(0, (expires - now).days) if pricing.get("is_promotional") else 0
139
-
140
- return {
141
- "activeModel": model_id,
142
- "isPromotional": pricing.get("is_promotional", False),
143
- "promoExpiresUtc": expires.isoformat() if pricing.get("is_promotional") else None,
144
- "daysUntilPromoEnds": days_remaining,
145
- "currentInputCacheMissRate": pricing["input_cache_miss_per_1m"],
146
- "currentOutputRate": pricing["output_per_1m"],
147
- "fullPriceInputRate": full["input_cache_miss_per_1m"],
148
- "fullPriceOutputRate": full["output_per_1m"],
149
- }
150
-
151
-
152
- FEATURE_NAMES = {
153
- "chat": "AI Chat Tutor",
154
- "rag_lesson": "RAG Lessons",
155
- "lesson_generation": "Lesson Generation",
156
- "quiz_generation": "Quiz Generation",
157
- "verify_solution": "Solution Verification",
158
- "intervention_plan": "Intervention Plans",
159
- "class_report": "Class Reports",
160
- "daily_insight": "Daily Insights",
161
- "learning_path": "Learning Paths",
162
- "risk_narrative": "Risk Narratives",
163
- }
164
- COST_TRACKING_NOTE = (
165
- "Token usage, cost and cache hits are not logged; only attempt counts, outcomes and latency are measured."
166
- )
167
-
168
-
169
- def _aggregate_summary(telemetry: dict | None = None) -> dict:
170
- """Summarise measured attempts per task type. Cost and cache figures are not measured and stay null."""
171
- if telemetry is None:
172
- telemetry = _aggregate_telemetry()
173
- model_id = "deepseek-v4-pro"
174
- pricing = get_active_pricing(model_id)
175
- total_attempts = telemetry["totalAttempts"]
176
- by_task_type = sorted(telemetry["requestsByTaskType"].items(), key=lambda entry: (-entry[1], entry[0]))
177
-
178
- features = [
179
- {
180
- "featureId": task_type,
181
- "featureName": FEATURE_NAMES.get(task_type, task_type.replace("_", " ").title()),
182
- "modelId": model_id,
183
- "monthlyCost": None,
184
- "requestShare": round(count / total_attempts * 100, 1) if total_attempts else 0.0,
185
- "totalRequests": count,
186
- "totalInputTokens": None,
187
- "totalOutputTokens": None,
188
- "cacheHitRate": None,
189
- "isMostActive": position == 0,
190
- "isTopSpending": False,
191
- "icon": "Zap",
192
- }
193
- for position, (task_type, count) in enumerate(by_task_type)
194
- ]
195
-
196
- summary = {
197
- "systemStatus": "healthy",
198
- "actionRequired": False,
199
- "hasPerformanceIssues": False,
200
- "monthlyCost": None,
201
- "projectedMonthlyCost": None,
202
- "billingCycleLabel": "Last 30 days",
203
- "costBreakdown": None,
204
- "costTrackingNote": COST_TRACKING_NOTE,
205
- "totalUsage": total_attempts,
206
- "totalInputTokens": None,
207
- "totalOutputTokens": None,
208
- "cacheHitRate": None,
209
- "activeEngine": "DeepSeek-V4 Pro",
210
- "activeEngineModelId": model_id,
211
- "engineTier": "High-Performance LLM",
212
- "promotionalPricingActive": pricing.get("is_promotional", False),
213
- "promotionalPriceExpiresUtc": pricing.get("promo_expires_utc", ""),
214
- "estimatedCostAfterPromo": None,
215
- "lastUpdated": datetime.now(timezone.utc).isoformat(),
216
- }
217
-
218
- return {"summary": summary, "features": features}
219
-
220
-
221
- @router.get("/summary")
222
- def get_monitoring_summary(_admin=Depends(require_admin)):
223
- """Returns AI monitoring summary + feature metrics + pricing metadata."""
224
- telemetry = _aggregate_telemetry()
225
- aggregated = _aggregate_summary(telemetry)
226
- return {
227
- **aggregated["summary"],
228
- "features": aggregated["features"],
229
- "pricingMeta": _build_pricing_meta(),
230
- "telemetry": telemetry,
231
- }
232
-
233
-
234
- @router.post("/refresh")
235
- def refresh_monitoring(_admin=Depends(require_admin)):
236
- """Acknowledge a refresh; the summary endpoint re-aggregates on every read."""
237
- # TODO: Write to Firestore ai_monitoring/summary when Firestore admin SDK is available
238
- pricing = get_active_pricing("deepseek-v4-pro")
239
- return {
240
- "success": True,
241
- "updatedAt": datetime.now(timezone.utc).isoformat(),
242
- "pricingUsed": pricing,
243
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
services/inference_client.py CHANGED
@@ -540,39 +540,13 @@ class InferenceClient:
540
  except Exception as e:
541
  LOGGER.warning(f"?????? Failed to persist metrics: {e}")
542
 
543
- def _record_attempt(self, *, task_type: str, provider: str, route: str, fallback_depth: int):
544
  self._bump_metric("requests_total", 1)
545
  self._bump_bucket("task_counts", (task_type or "default").strip().lower(), 1)
546
  self._bump_bucket("provider_counts", provider, 1)
547
  self._bump_bucket("route_counts", route, 1)
548
  if fallback_depth > 0:
549
  self._bump_metric("fallback_attempts", 1)
550
- try:
551
- from datetime import datetime, timezone
552
- from firebase_admin import firestore as firebase_firestore
553
-
554
- firestore_client = self.firestore or firebase_firestore.client()
555
- return firestore_client.collection("ai_usage_logs").add({
556
- "timestamp": datetime.now(timezone.utc),
557
- "taskType": task_type,
558
- "provider": provider,
559
- "route": route,
560
- "fallbackDepth": fallback_depth,
561
- "status": "pending",
562
- "latencyMs": None,
563
- })[1]
564
- except Exception as exc:
565
- LOGGER.warning("AI monitoring attempt logging skipped: %s", exc)
566
- return None
567
-
568
- @staticmethod
569
- def _finish_telemetry_attempt(document, *, status: str, latency_ms: float) -> None:
570
- if document is None:
571
- return
572
- try:
573
- document.update({"status": status, "latencyMs": round(latency_ms, 2) if status == "success" else None})
574
- except Exception as exc:
575
- LOGGER.warning("AI monitoring attempt update skipped: %s", exc)
576
 
577
  def snapshot_metrics(self) -> Dict[str, Any]:
578
  with self._metrics_lock:
@@ -768,7 +742,7 @@ class InferenceClient:
768
  params["response_format"] = {"type": "json_object"}
769
 
770
  for attempt in range(max_retries):
771
- telemetry_document = self._record_attempt(
772
  task_type=task_type,
773
  provider="deepseek",
774
  route=route,
@@ -803,12 +777,10 @@ class InferenceClient:
803
  )
804
  self._record_completion(latency_ms=latency_ms)
805
  self._bump_metric("requests_ok", 1)
806
- self._finish_telemetry_attempt(telemetry_document, status="success", latency_ms=latency_ms)
807
  return text
808
 
809
  except RateLimitError:
810
  latency_ms = (time.perf_counter() - start) * 1000
811
- self._finish_telemetry_attempt(telemetry_document, status="error", latency_ms=latency_ms)
812
  if attempt < max_retries - 1:
813
  log_model_call(
814
  LOGGER,
@@ -835,7 +807,6 @@ class InferenceClient:
835
 
836
  except APITimeoutError:
837
  latency_ms = (time.perf_counter() - start) * 1000
838
- self._finish_telemetry_attempt(telemetry_document, status="error", latency_ms=latency_ms)
839
  if attempt < max_retries - 1:
840
  log_model_call(
841
  LOGGER,
@@ -862,7 +833,6 @@ class InferenceClient:
862
 
863
  except APIError as e:
864
  latency_ms = (time.perf_counter() - start) * 1000
865
- self._finish_telemetry_attempt(telemetry_document, status="error", latency_ms=latency_ms)
866
  status = getattr(e, "status_code", None)
867
  if status in (401, 403):
868
  self._bump_metric("requests_error", 1)
@@ -934,7 +904,6 @@ class InferenceClient:
934
 
935
  except Exception as exc:
936
  latency_ms = (time.perf_counter() - start) * 1000
937
- self._finish_telemetry_attempt(telemetry_document, status="error", latency_ms=latency_ms)
938
  self._bump_metric("requests_error", 1)
939
  log_model_call(
940
  LOGGER,
 
540
  except Exception as e:
541
  LOGGER.warning(f"?????? Failed to persist metrics: {e}")
542
 
543
+ def _record_attempt(self, *, task_type: str, provider: str, route: str, fallback_depth: int) -> None:
544
  self._bump_metric("requests_total", 1)
545
  self._bump_bucket("task_counts", (task_type or "default").strip().lower(), 1)
546
  self._bump_bucket("provider_counts", provider, 1)
547
  self._bump_bucket("route_counts", route, 1)
548
  if fallback_depth > 0:
549
  self._bump_metric("fallback_attempts", 1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
550
 
551
  def snapshot_metrics(self) -> Dict[str, Any]:
552
  with self._metrics_lock:
 
742
  params["response_format"] = {"type": "json_object"}
743
 
744
  for attempt in range(max_retries):
745
+ self._record_attempt(
746
  task_type=task_type,
747
  provider="deepseek",
748
  route=route,
 
777
  )
778
  self._record_completion(latency_ms=latency_ms)
779
  self._bump_metric("requests_ok", 1)
 
780
  return text
781
 
782
  except RateLimitError:
783
  latency_ms = (time.perf_counter() - start) * 1000
 
784
  if attempt < max_retries - 1:
785
  log_model_call(
786
  LOGGER,
 
807
 
808
  except APITimeoutError:
809
  latency_ms = (time.perf_counter() - start) * 1000
 
810
  if attempt < max_retries - 1:
811
  log_model_call(
812
  LOGGER,
 
833
 
834
  except APIError as e:
835
  latency_ms = (time.perf_counter() - start) * 1000
 
836
  status = getattr(e, "status_code", None)
837
  if status in (401, 403):
838
  self._bump_metric("requests_error", 1)
 
904
 
905
  except Exception as exc:
906
  latency_ms = (time.perf_counter() - start) * 1000
 
907
  self._bump_metric("requests_error", 1)
908
  log_model_call(
909
  LOGGER,
tests/role_policy_expectations.py CHANGED
@@ -68,7 +68,6 @@ EXPECTED_ROLE_POLICIES = {
68
  "/api/analytics/class-insights": TEACHER_ADMIN,
69
  "/api/analytics/refresh-cache": ADMIN_ONLY,
70
  "/api/testing/reset-data": ALL_ROLES,
71
- "/api/hf/monitoring": ADMIN_ONLY,
72
  "/api/dev/generate-mock-data": ADMIN_ONLY,
73
  "/api/analytics/config": TEACHER_ADMIN,
74
  "/api/analytics/imported-class-overview": TEACHER_ADMIN,
@@ -153,8 +152,6 @@ EXPECTED_REGISTERED_ROUTE_METHODS = {
153
  "/api/try-it-yourself/complete-session": frozenset({"POST"}),
154
  "/api/try-it-yourself/generate-round": frozenset({"POST"}),
155
  "/api/try-it-yourself/shadow-retry": frozenset({"POST"}),
156
- "/api/admin/ai-monitoring/summary": frozenset({"GET"}),
157
- "/api/admin/ai-monitoring/refresh": frozenset({"POST"}),
158
  "/api/analytics/class/{class_id}": frozenset({"GET"}),
159
  "/api/analytics/class/{class_id}/students": frozenset({"GET"}),
160
  "/api/analytics/class/{class_id}/topics": frozenset({"GET"}),
@@ -212,7 +209,6 @@ EXPECTED_REGISTERED_ROUTE_METHODS = {
212
  "/api/tasks/{task_id}": frozenset({"GET"}),
213
  "/api/tasks": frozenset({"GET"}),
214
  "/api/tasks/{task_id}/cancel": frozenset({"POST"}),
215
- "/api/hf/monitoring": frozenset({"GET"}),
216
  "/api/quiz/topics": frozenset({"GET"}),
217
  "/api/quiz/student-competency": frozenset({"POST"}),
218
  "/api/calculator/evaluate": frozenset({"POST"}),
@@ -293,8 +289,6 @@ HANDLER_GUARDED_ROUTE_EXCEPTIONS = {
293
  ("POST", "/api/admin/audit-log"): "admin/teacher role check",
294
  ("GET", "/api/admin/reingest-status"): "admin role check",
295
  ("GET", "/api/admin/dashboard-analytics"): "admin role check",
296
- ("GET", "/api/admin/ai-monitoring/summary"): "admin role check",
297
- ("POST", "/api/admin/ai-monitoring/refresh"): "admin role check",
298
  ("GET", "/api/quiz-battle/results"): "teacher/admin role check",
299
  ("POST", "/api/teacher-materials/{moduleId}/generate-quiz"): "teacher/admin role check",
300
  ("POST", "/api/class-records/intervention-plan"): "teacher/admin role check",
 
68
  "/api/analytics/class-insights": TEACHER_ADMIN,
69
  "/api/analytics/refresh-cache": ADMIN_ONLY,
70
  "/api/testing/reset-data": ALL_ROLES,
 
71
  "/api/dev/generate-mock-data": ADMIN_ONLY,
72
  "/api/analytics/config": TEACHER_ADMIN,
73
  "/api/analytics/imported-class-overview": TEACHER_ADMIN,
 
152
  "/api/try-it-yourself/complete-session": frozenset({"POST"}),
153
  "/api/try-it-yourself/generate-round": frozenset({"POST"}),
154
  "/api/try-it-yourself/shadow-retry": frozenset({"POST"}),
 
 
155
  "/api/analytics/class/{class_id}": frozenset({"GET"}),
156
  "/api/analytics/class/{class_id}/students": frozenset({"GET"}),
157
  "/api/analytics/class/{class_id}/topics": frozenset({"GET"}),
 
209
  "/api/tasks/{task_id}": frozenset({"GET"}),
210
  "/api/tasks": frozenset({"GET"}),
211
  "/api/tasks/{task_id}/cancel": frozenset({"POST"}),
 
212
  "/api/quiz/topics": frozenset({"GET"}),
213
  "/api/quiz/student-competency": frozenset({"POST"}),
214
  "/api/calculator/evaluate": frozenset({"POST"}),
 
289
  ("POST", "/api/admin/audit-log"): "admin/teacher role check",
290
  ("GET", "/api/admin/reingest-status"): "admin role check",
291
  ("GET", "/api/admin/dashboard-analytics"): "admin role check",
 
 
292
  ("GET", "/api/quiz-battle/results"): "teacher/admin role check",
293
  ("POST", "/api/teacher-materials/{moduleId}/generate-quiz"): "teacher/admin role check",
294
  ("POST", "/api/class-records/intervention-plan"): "teacher/admin role check",
tests/test_ai_monitoring_contract.py DELETED
@@ -1,75 +0,0 @@
1
- from datetime import datetime, timezone
2
- from unittest.mock import Mock
3
- from zoneinfo import ZoneInfo
4
-
5
- from routes import ai_monitoring
6
- from services.inference_client import InferenceClient
7
-
8
-
9
- class FakeSnapshot:
10
- def __init__(self, payload):
11
- self.payload = payload
12
-
13
- def to_dict(self):
14
- return self.payload
15
-
16
-
17
- def test_daily_telemetry_uses_manila_days_and_documented_denominators(monkeypatch):
18
- manila = ZoneInfo("Asia/Manila")
19
- now = datetime(2025, 2, 1, 0, 0, tzinfo=manila)
20
- docs = [
21
- FakeSnapshot({"timestamp": datetime(2025, 1, 31, 15, 59, tzinfo=timezone.utc), "status": "success", "latencyMs": 100}),
22
- FakeSnapshot({"timestamp": datetime(2025, 1, 31, 16, 0, tzinfo=timezone.utc), "status": "error", "latencyMs": 900}),
23
- FakeSnapshot({"timestamp": datetime(2025, 1, 31, 17, 0, tzinfo=timezone.utc), "status": "success", "latencyMs": 300}),
24
- ]
25
- collection = Mock()
26
- collection.where.return_value.where.return_value.stream.return_value = docs
27
- firestore = Mock()
28
- firestore.collection.return_value = collection
29
- monkeypatch.setattr(ai_monitoring, "_get_firestore_client", lambda: firestore)
30
- monkeypatch.setattr(ai_monitoring, "_now", lambda: now)
31
-
32
- telemetry = ai_monitoring._aggregate_telemetry()
33
-
34
- recent_days = [day for day in telemetry["dailyMetrics"] if day["date"] in {"2025-01-31", "2025-02-01"}]
35
- assert recent_days == [
36
- {"date": "2025-01-31", "totalAttempts": 1, "successfulAttempts": 1, "completedRequests": 1, "averageLatencyMs": 100.0, "successRate": 100.0},
37
- {"date": "2025-02-01", "totalAttempts": 2, "successfulAttempts": 1, "completedRequests": 1, "averageLatencyMs": 300.0, "successRate": 50.0},
38
- ]
39
- assert telemetry["totalAttempts"] == 3
40
- assert telemetry["successfulAttempts"] == 2
41
- assert telemetry["completedRequests"] == 2
42
- assert telemetry["averageLatencyMs"] == 200.0
43
- assert telemetry["successRate"] == 66.7
44
-
45
-
46
- def test_daily_telemetry_returns_empty_metrics_without_firestore(monkeypatch):
47
- monkeypatch.setattr(ai_monitoring, "_get_firestore_client", lambda: None)
48
- telemetry = ai_monitoring._aggregate_telemetry()
49
-
50
- assert telemetry["totalAttempts"] == 0
51
- assert telemetry["successfulAttempts"] == 0
52
- assert telemetry["completedRequests"] == 0
53
- assert telemetry["averageLatencyMs"] is None
54
- assert telemetry["successRate"] is None
55
- assert telemetry["dailyMetrics"]
56
- assert all(day["totalAttempts"] == 0 for day in telemetry["dailyMetrics"])
57
-
58
-
59
- def test_generation_attempt_collector_persists_pending_then_completed_status():
60
- document = Mock()
61
- firestore = Mock()
62
- firestore.collection.return_value.add.return_value = (None, document)
63
- client = InferenceClient(firestore_client=firestore)
64
-
65
- telemetry_document = client._record_attempt(
66
- task_type="chat", provider="deepseek", route="deepseek", fallback_depth=0
67
- )
68
- client._finish_telemetry_attempt(telemetry_document, status="success", latency_ms=250.456)
69
-
70
- firestore.collection.assert_any_call("ai_usage_logs")
71
- firestore.collection.return_value.add.assert_called_once()
72
- event = firestore.collection.return_value.add.call_args.args[0]
73
- assert event["status"] == "pending"
74
- assert event["latencyMs"] is None
75
- document.update.assert_called_once_with({"status": "success", "latencyMs": 250.46})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_ai_monitoring_telemetry.py DELETED
@@ -1,28 +0,0 @@
1
- from routes.ai_monitoring import COST_TRACKING_NOTE, _aggregate_summary
2
-
3
-
4
- def test_monitoring_summary_does_not_expose_rejected_top_level_utc_telemetry():
5
- summary = _aggregate_summary()["summary"]
6
-
7
- rejected_fields = {"dailyQuestionCount", "averageLatencyMs", "successRate"}
8
- assert rejected_fields.isdisjoint(summary)
9
-
10
-
11
- def test_monitoring_summary_reports_measured_requests_and_null_cost_figures():
12
- telemetry = {"totalAttempts": 10, "requestsByTaskType": {"quiz_generation": 3, "chat": 7}}
13
-
14
- aggregated = _aggregate_summary(telemetry)
15
- summary = aggregated["summary"]
16
-
17
- assert [f["featureId"] for f in aggregated["features"]] == ["chat", "quiz_generation"]
18
- chat = aggregated["features"][0]
19
- assert chat["featureName"] == "AI Chat Tutor"
20
- assert chat["totalRequests"] == 7
21
- assert chat["requestShare"] == 70.0
22
- assert chat["isMostActive"] is True
23
- assert chat["monthlyCost"] is None and chat["cacheHitRate"] is None
24
- assert summary["totalUsage"] == 10
25
- assert summary["monthlyCost"] is None
26
- assert summary["costBreakdown"] is None
27
- assert summary["cacheHitRate"] is None
28
- assert summary["costTrackingNote"] == COST_TRACKING_NOTE
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_api.py CHANGED
@@ -897,19 +897,6 @@ class TestQuizGeneration:
897
  assert "You are asking too fast, please wait a moment" in limited.json()["detail"]
898
 
899
 
900
- def test_ai_monitoring_summary_exposes_nested_telemetry_without_top_level_counters():
901
- from routes import ai_monitoring
902
-
903
- summary = ai_monitoring.get_monitoring_summary(_admin=object())
904
-
905
- assert "telemetry" in summary
906
- assert "dailyQuestions" not in summary
907
- assert "averageLatencyMs" not in summary
908
- assert "successRate" not in summary
909
- assert "features" in summary
910
- assert "pricingMeta" in summary
911
-
912
-
913
  class TestClassRecordImportMapping:
914
  def test_sanitize_column_mapping_drops_none_and_unknown_fields(self):
915
  raw_mapping = {
 
897
  assert "You are asking too fast, please wait a moment" in limited.json()["detail"]
898
 
899
 
 
 
 
 
 
 
 
 
 
 
 
 
 
900
  class TestClassRecordImportMapping:
901
  def test_sanitize_column_mapping_drops_none_and_unknown_fields(self):
902
  raw_mapping = {
tests/test_hf_monitoring_routes.py DELETED
@@ -1,148 +0,0 @@
1
- """
2
- Route-level tests for /api/hf/monitoring endpoint.
3
- Updated for DeepSeek AI monitoring.
4
- """
5
-
6
- import os
7
- from unittest.mock import MagicMock, Mock, patch
8
-
9
- import pytest
10
- from fastapi.testclient import TestClient
11
-
12
- import main as main_module
13
- from main import app
14
-
15
- main_module._firebase_ready = True
16
- main_module._init_firebase_admin = lambda: None
17
- main_module.firebase_firestore = None
18
- if getattr(main_module, "firebase_auth", None) is None:
19
- main_module.firebase_auth = MagicMock()
20
- main_module.firebase_auth.verify_id_token = MagicMock(return_value={
21
- "uid": "test-teacher-uid",
22
- "email": "teacher@example.com",
23
- "role": "teacher",
24
- })
25
-
26
- admin_client = TestClient(app, headers={"Authorization": "Bearer admin-token"})
27
-
28
- EXPECTED_MONITORING_FIELDS = {
29
- "modelId", "modelStatus", "avgResponseTimeMs",
30
- "embeddingModelId", "embeddingModelStatus",
31
- "inferenceBalance", "totalPeriodCost",
32
- "hubApiCallsUsed", "hubApiCallsLimit",
33
- "zeroGpuMinutesUsed", "zeroGpuMinutesLimit",
34
- "publicStorageUsedTB", "publicStorageLimitTB",
35
- "lastChecked", "periodStart", "periodEnd",
36
- "activeProfile", "runtimeOverridesActive", "resolvedModels",
37
- "provider", "apiBaseUrl",
38
- }
39
-
40
- EXPECTED_FIELDS_AFTER_DS_REPLACEMENT = EXPECTED_MONITORING_FIELDS
41
-
42
-
43
- @pytest.fixture(autouse=True)
44
- def _mock_env():
45
- with patch.dict(os.environ, {"DEEPSEEK_API_KEY": "test-ds-monitoring-key"}):
46
- yield
47
-
48
-
49
- # ─── Auth Enforcement ────────────────────────────────────────
50
-
51
-
52
- class TestMonitoringAuth:
53
- def test_rejects_bad_token(self):
54
- main_module.firebase_auth.verify_id_token = MagicMock(side_effect=Exception("bad"))
55
- c = TestClient(app, headers={"Authorization": "Bearer bad-token"})
56
- response = c.get("/api/hf/monitoring")
57
- main_module.firebase_auth.verify_id_token = MagicMock(return_value={
58
- "uid": "admin-uid", "email": "admin@example.com", "role": "admin",
59
- })
60
- assert response.status_code in {401, 403}
61
-
62
-
63
- # ─── Response Shape ───────────────────────────────────────────
64
-
65
-
66
- class TestMonitoringResponseShape:
67
- @patch("main.time.time")
68
- def test_success_response_contains_all_expected_fields(self, mock_time):
69
- mock_time.return_value = 1000.0
70
-
71
- response = admin_client.get("/api/hf/monitoring")
72
- assert response.status_code == 200
73
- data = response.json()
74
- assert data["success"] is True
75
- payload = data["data"]
76
- for field in EXPECTED_FIELDS_AFTER_DS_REPLACEMENT:
77
- assert field in payload, f"Missing field: {field}"
78
-
79
- @patch("main.time.time")
80
- @patch("services.ai_client.get_deepseek_client")
81
- def test_all_probes_fail_gracefully(self, mock_ds_client_fn, mock_time):
82
- mock_time.return_value = 1000.0
83
- mock_client = MagicMock()
84
- mock_client.chat.completions.create.side_effect = Exception("network down")
85
- mock_ds_client_fn.return_value = mock_client
86
-
87
- response = admin_client.get("/api/hf/monitoring")
88
- assert response.status_code == 200
89
- data = response.json()
90
- assert data["success"] is True
91
-
92
-
93
- # ─── Response Values ──────────────────────────────────────────
94
-
95
-
96
- class TestMonitoringResponseValues:
97
- @patch("services.ai_client.get_deepseek_client")
98
- @patch("main.time.time")
99
- def test_model_status_is_degraded_when_probe_fails(self, mock_time, mock_ds_client_fn):
100
- mock_time.return_value = 1000.0
101
- mock_client = MagicMock()
102
- mock_client.chat.completions.create.side_effect = Exception("probe down")
103
- mock_ds_client_fn.return_value = mock_client
104
-
105
- response = admin_client.get("/api/hf/monitoring")
106
- data = response.json()
107
- assert data["success"] is True
108
- assert data["data"]["modelStatus"] == "Degraded"
109
-
110
- @patch("main.time.time")
111
- def test_embedding_model_id_is_returned(self, mock_time):
112
- mock_time.return_value = 1000.0
113
-
114
- response = admin_client.get("/api/hf/monitoring")
115
- data = response.json()
116
- assert data["success"] is True
117
- assert "bge-small" in data["data"]["embeddingModelId"].lower()
118
-
119
- @patch("main.time.time")
120
- def test_resolved_models_contains_task_keys(self, mock_time):
121
- mock_time.return_value = 1000.0
122
-
123
- response = admin_client.get("/api/hf/monitoring")
124
- data = response.json()
125
- resolved = data["data"].get("resolvedModels", {})
126
- expected_tasks = {"chat", "rag_lesson", "rag_problem", "quiz_generation"}
127
- for task in expected_tasks:
128
- assert task in resolved, f"Missing task: {task}"
129
- assert isinstance(resolved[task], str) and len(resolved[task]) > 0
130
-
131
- @patch("main.time.time")
132
- def test_active_profile_returned(self, mock_time):
133
- mock_time.return_value = 1000.0
134
-
135
- response = admin_client.get("/api/hf/monitoring")
136
- data = response.json()
137
- assert data["success"] is True
138
- assert data["data"]["activeProfile"] in {"dev", "budget", "prod", ""}
139
-
140
- @patch("main.time.time")
141
- def test_provider_and_api_base_url_present(self, mock_time):
142
- mock_time.return_value = 1000.0
143
-
144
- response = admin_client.get("/api/hf/monitoring")
145
- data = response.json()
146
- assert data["success"] is True
147
- assert data["data"]["provider"] == "deepseek"
148
- assert "api.deepseek.com" in data["data"]["apiBaseUrl"]