Spaces:
Sleeping
Sleeping
github-actions[bot] commited on
Commit ·
cca5aec
1
Parent(s): 4b42511
deploy: backend from 5bbaa0e
Browse files- .env.example +0 -2
- codemap.md +1 -1
- main.py +1 -86
- routes/ai_monitoring.py +0 -243
- services/inference_client.py +2 -33
- tests/role_policy_expectations.py +0 -6
- tests/test_ai_monitoring_contract.py +0 -75
- tests/test_ai_monitoring_telemetry.py +0 -28
- tests/test_api.py +0 -13
- tests/test_hf_monitoring_routes.py +0 -148
.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`, `
|
| 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,
|
| 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 |
-
|
| 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"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|