Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
b222bcc
1
Parent(s): 57fbb45
🚀 Auto-deploy backend from GitHub (1393543)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .deploy-trigger +0 -1
- .env.example +0 -33
- .gitattributes +35 -0
- analytics.py +23 -18
- config/env.sample +11 -16
- config/models.yaml +42 -72
- datasets/sample_curriculum.json +0 -137
- main.py +0 -0
- middleware/__init__.py +0 -4
- middleware/rate_limiter.py +0 -184
- pre_deploy_check.py +2 -10
- rag/__init__.py +1 -9
- rag/curriculum_rag.py +48 -199
- rag/firebase_storage_loader.py +0 -175
- rag/pdf_ingestion.py +0 -368
- rag/vectorstore_loader.py +2 -11
- requirements.txt +0 -8
- routes/admin_model_routes.py +0 -67
- routes/admin_routes.py +0 -87
- routes/curriculum_routes.py +0 -66
- routes/diagnostic.py +0 -797
- routes/quiz_battle.py +0 -205
- routes/quiz_generation_routes.py +0 -356
- routes/rag_routes.py +54 -298
- routes/video_routes.py +0 -102
- scripts/download_vectorstore_from_firebase.py +9 -74
- scripts/ingest_curriculum.py +221 -136
- scripts/ingest_from_storage.py +0 -285
- scripts/migrate_grade12_to_grade11.py +0 -107
- scripts/register_firestore_metadata.py +0 -183
- scripts/seed_curriculum.py +0 -64
- scripts/upload_curriculum_pdfs.py +0 -264
- scripts/upload_lesson_modules.py +0 -142
- scripts/upload_vectorstore_to_firebase.py +0 -71
- services/__init__.py +0 -43
- services/ai_client.py +0 -28
- services/curriculum_service.py +0 -232
- services/inference_client.py +551 -528
- services/question_bank_service.py +0 -123
- services/user_provisioning_service.py +1 -0
- services/variance_engine.py +0 -115
- services/youtube_service.py +0 -1017
- startup.sh +5 -41
- startup_validation.py +21 -94
- test_full_rag.py +0 -75
- test_retrieval.py +0 -39
- tests/README.md +0 -46
- tests/test_admin_model_routes.py +0 -213
- tests/test_api.py +200 -118
- tests/test_hf_monitoring_routes.py +0 -148
.deploy-trigger
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
2026-04-29 21:37:27
|
|
|
|
|
|
.env.example
DELETED
|
@@ -1,33 +0,0 @@
|
|
| 1 |
-
# ── Vector Store ──────────────────────────────────────────────────
|
| 2 |
-
# Path to ChromaDB vectorstore directory
|
| 3 |
-
CURRICULUM_VECTORSTORE_DIR=datasets/vectorstore
|
| 4 |
-
|
| 5 |
-
# Sentence transformer for embeddings
|
| 6 |
-
# WARNING: changing this requires full re-ingestion of all curriculum data
|
| 7 |
-
EMBEDDING_MODEL=BAAI/bge-small-en-v1.5
|
| 8 |
-
|
| 9 |
-
# ── DeepSeek AI Inference ─────────────────────────────────────────
|
| 10 |
-
# DeepSeek API key (OpenAI-compatible), required for all AI features
|
| 11 |
-
DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
| 12 |
-
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
| 13 |
-
DEEPSEEK_MODEL=deepseek-chat
|
| 14 |
-
DEEPSEEK_REASONER_MODEL=deepseek-reasoner
|
| 15 |
-
|
| 16 |
-
# ── HuggingFace (dataset push / HF Space deployment only) ─────────
|
| 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)
|
| 25 |
-
HF_MODEL_ID=deepseek-chat
|
| 26 |
-
|
| 27 |
-
# PRODUCTION — deepseek-reasoner for step-by-step solutions
|
| 28 |
-
# HF_MODEL_ID=deepseek-reasoner
|
| 29 |
-
|
| 30 |
-
# ── Quiz Battle Internal Auth ─────────────────────────────────────
|
| 31 |
-
# Shared secret between Firebase Cloud Functions and FastAPI backend
|
| 32 |
-
# Used to authenticate server-to-server requests for correct answers
|
| 33 |
-
QUIZ_BATTLE_INTERNAL_SECRET=change_this_to_a_random_string
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
analytics.py
CHANGED
|
@@ -232,6 +232,7 @@ class EnhancedRiskRequest(BaseModel):
|
|
| 232 |
avgQuizScore: float = Field(..., ge=0, le=100)
|
| 233 |
attendance: float = Field(..., ge=0, le=100)
|
| 234 |
assignmentCompletion: float = Field(..., ge=0, le=100)
|
|
|
|
| 235 |
xpGrowthRate: Optional[float] = 0.0
|
| 236 |
timeOnPlatform: Optional[float] = 0.0 # hours
|
| 237 |
# Optional trend data
|
|
@@ -809,7 +810,7 @@ def _build_risk_features(data: EnhancedRiskRequest) -> np.ndarray:
|
|
| 809 |
data.avgQuizScore,
|
| 810 |
data.attendance,
|
| 811 |
data.assignmentCompletion,
|
| 812 |
-
|
| 813 |
data.xpGrowthRate or 0.0,
|
| 814 |
data.timeOnPlatform or 0.0,
|
| 815 |
data.engagementTrend7d or 0.0,
|
|
@@ -870,8 +871,12 @@ def _rule_based_risk(data: EnhancedRiskRequest) -> EnhancedRiskPrediction:
|
|
| 870 |
score -= 10
|
| 871 |
if (data.daysSinceLastActivity or 0) >= 7:
|
| 872 |
score -= 10
|
|
|
|
|
|
|
| 873 |
|
| 874 |
# Bonuses
|
|
|
|
|
|
|
| 875 |
if (data.engagementTrend7d or 0) > 0:
|
| 876 |
score += 5
|
| 877 |
|
|
@@ -1146,19 +1151,19 @@ async def train_risk_model(force_retrain: bool = False) -> RiskTrainResponse:
|
|
| 1146 |
if not data:
|
| 1147 |
continue
|
| 1148 |
|
| 1149 |
-
|
| 1150 |
-
|
| 1151 |
-
|
| 1152 |
-
|
| 1153 |
-
|
| 1154 |
-
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
X_data.append(features)
|
| 1163 |
|
| 1164 |
# Determine label from existing riskLevel or compute it
|
|
@@ -1255,7 +1260,7 @@ def _generate_synthetic_risk_data(n: int) -> Tuple[np.ndarray, np.ndarray]:
|
|
| 1255 |
quiz = np.random.normal(35, 12)
|
| 1256 |
attendance = np.random.normal(50, 15)
|
| 1257 |
completion = np.random.normal(35, 15)
|
| 1258 |
-
|
| 1259 |
xp_growth = np.random.normal(-0.5, 0.3)
|
| 1260 |
time_platform = np.random.normal(2, 1)
|
| 1261 |
trend = np.random.normal(-10, 5)
|
|
@@ -1267,7 +1272,7 @@ def _generate_synthetic_risk_data(n: int) -> Tuple[np.ndarray, np.ndarray]:
|
|
| 1267 |
quiz = np.random.normal(60, 10)
|
| 1268 |
attendance = np.random.normal(72, 10)
|
| 1269 |
completion = np.random.normal(60, 12)
|
| 1270 |
-
|
| 1271 |
xp_growth = np.random.normal(0.2, 0.3)
|
| 1272 |
time_platform = np.random.normal(5, 2)
|
| 1273 |
trend = np.random.normal(0, 8)
|
|
@@ -1279,7 +1284,7 @@ def _generate_synthetic_risk_data(n: int) -> Tuple[np.ndarray, np.ndarray]:
|
|
| 1279 |
quiz = np.random.normal(85, 8)
|
| 1280 |
attendance = np.random.normal(93, 5)
|
| 1281 |
completion = np.random.normal(88, 8)
|
| 1282 |
-
|
| 1283 |
xp_growth = np.random.normal(1.0, 0.4)
|
| 1284 |
time_platform = np.random.normal(10, 3)
|
| 1285 |
trend = np.random.normal(5, 5)
|
|
@@ -1292,7 +1297,7 @@ def _generate_synthetic_risk_data(n: int) -> Tuple[np.ndarray, np.ndarray]:
|
|
| 1292 |
max(0, min(100, quiz)),
|
| 1293 |
max(0, min(100, attendance)),
|
| 1294 |
max(0, min(100, completion)),
|
| 1295 |
-
|
| 1296 |
xp_growth,
|
| 1297 |
max(0, time_platform),
|
| 1298 |
trend,
|
|
|
|
| 232 |
avgQuizScore: float = Field(..., ge=0, le=100)
|
| 233 |
attendance: float = Field(..., ge=0, le=100)
|
| 234 |
assignmentCompletion: float = Field(..., ge=0, le=100)
|
| 235 |
+
streak: Optional[int] = 0
|
| 236 |
xpGrowthRate: Optional[float] = 0.0
|
| 237 |
timeOnPlatform: Optional[float] = 0.0 # hours
|
| 238 |
# Optional trend data
|
|
|
|
| 810 |
data.avgQuizScore,
|
| 811 |
data.attendance,
|
| 812 |
data.assignmentCompletion,
|
| 813 |
+
data.streak or 0,
|
| 814 |
data.xpGrowthRate or 0.0,
|
| 815 |
data.timeOnPlatform or 0.0,
|
| 816 |
data.engagementTrend7d or 0.0,
|
|
|
|
| 871 |
score -= 10
|
| 872 |
if (data.daysSinceLastActivity or 0) >= 7:
|
| 873 |
score -= 10
|
| 874 |
+
if (data.streak or 0) == 0:
|
| 875 |
+
score -= 5
|
| 876 |
|
| 877 |
# Bonuses
|
| 878 |
+
if (data.streak or 0) >= 7:
|
| 879 |
+
score += 5
|
| 880 |
if (data.engagementTrend7d or 0) > 0:
|
| 881 |
score += 5
|
| 882 |
|
|
|
|
| 1151 |
if not data:
|
| 1152 |
continue
|
| 1153 |
|
| 1154 |
+
features = [
|
| 1155 |
+
data.get("engagementScore", 50),
|
| 1156 |
+
data.get("avgQuizScore", 50),
|
| 1157 |
+
data.get("attendance", 80),
|
| 1158 |
+
data.get("assignmentCompletion", 60),
|
| 1159 |
+
data.get("streak", 0),
|
| 1160 |
+
data.get("xpGrowthRate", 0),
|
| 1161 |
+
data.get("timeOnPlatform", 0),
|
| 1162 |
+
0.0, # engagementTrend7d
|
| 1163 |
+
0.0, # quizScoreVariance
|
| 1164 |
+
data.get("consecutiveAbsences", 0),
|
| 1165 |
+
data.get("daysSinceLastActivity", 0),
|
| 1166 |
+
]
|
| 1167 |
X_data.append(features)
|
| 1168 |
|
| 1169 |
# Determine label from existing riskLevel or compute it
|
|
|
|
| 1260 |
quiz = np.random.normal(35, 12)
|
| 1261 |
attendance = np.random.normal(50, 15)
|
| 1262 |
completion = np.random.normal(35, 15)
|
| 1263 |
+
streak = max(0, int(np.random.normal(1, 2)))
|
| 1264 |
xp_growth = np.random.normal(-0.5, 0.3)
|
| 1265 |
time_platform = np.random.normal(2, 1)
|
| 1266 |
trend = np.random.normal(-10, 5)
|
|
|
|
| 1272 |
quiz = np.random.normal(60, 10)
|
| 1273 |
attendance = np.random.normal(72, 10)
|
| 1274 |
completion = np.random.normal(60, 12)
|
| 1275 |
+
streak = max(0, int(np.random.normal(3, 3)))
|
| 1276 |
xp_growth = np.random.normal(0.2, 0.3)
|
| 1277 |
time_platform = np.random.normal(5, 2)
|
| 1278 |
trend = np.random.normal(0, 8)
|
|
|
|
| 1284 |
quiz = np.random.normal(85, 8)
|
| 1285 |
attendance = np.random.normal(93, 5)
|
| 1286 |
completion = np.random.normal(88, 8)
|
| 1287 |
+
streak = max(0, int(np.random.normal(10, 5)))
|
| 1288 |
xp_growth = np.random.normal(1.0, 0.4)
|
| 1289 |
time_platform = np.random.normal(10, 3)
|
| 1290 |
trend = np.random.normal(5, 5)
|
|
|
|
| 1297 |
max(0, min(100, quiz)),
|
| 1298 |
max(0, min(100, attendance)),
|
| 1299 |
max(0, min(100, completion)),
|
| 1300 |
+
streak,
|
| 1301 |
xp_growth,
|
| 1302 |
max(0, time_platform),
|
| 1303 |
trend,
|
config/env.sample
CHANGED
|
@@ -1,16 +1,10 @@
|
|
| 1 |
-
# DeepSeek AI API (OpenAI-compatible)
|
| 2 |
-
DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
| 3 |
-
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
| 4 |
-
DEEPSEEK_MODEL=deepseek-chat
|
| 5 |
-
DEEPSEEK_REASONER_MODEL=deepseek-reasoner
|
| 6 |
-
|
| 7 |
# Inference provider selection
|
| 8 |
# CI trigger marker: keep this file touchable to force backend deploy workflow runs when needed.
|
| 9 |
-
INFERENCE_PROVIDER=
|
| 10 |
INFERENCE_PRO_ENABLED=true
|
| 11 |
-
INFERENCE_PRO_PROVIDER=
|
| 12 |
-
INFERENCE_GPU_PROVIDER=
|
| 13 |
-
INFERENCE_CPU_PROVIDER=
|
| 14 |
INFERENCE_ENABLE_PROVIDER_FALLBACK=true
|
| 15 |
INFERENCE_PRO_PRIORITY_TASKS=chat,verify_solution
|
| 16 |
INFERENCE_PRO_ROUTE_HEADER_NAME=
|
|
@@ -30,14 +24,15 @@ INFERENCE_LOCAL_SPACE_URL=http://127.0.0.1:7860
|
|
| 30 |
INFERENCE_LOCAL_SPACE_GENERATE_PATH=/gradio_api/call/generate
|
| 31 |
INFERENCE_LOCAL_SPACE_TIMEOUT_SEC=180
|
| 32 |
|
| 33 |
-
#
|
| 34 |
# Alternative env names accepted by runtime/startup checks: HUGGING_FACE_API_TOKEN, HUGGINGFACE_API_TOKEN
|
| 35 |
HF_TOKEN=your_hf_token
|
| 36 |
FIREBASE_AUTH_PROJECT_ID=mathpulse-ai-2026
|
| 37 |
# Prefer one of the options below for backend Firestore/Admin access in deployment:
|
| 38 |
# FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account",...}
|
| 39 |
# FIREBASE_SERVICE_ACCOUNT_FILE=/path/to/service-account.json
|
| 40 |
-
|
|
|
|
| 41 |
INFERENCE_HF_TIMEOUT_SEC=90
|
| 42 |
INFERENCE_INTERACTIVE_TIMEOUT_SEC=55
|
| 43 |
INFERENCE_BACKGROUND_TIMEOUT_SEC=120
|
|
@@ -69,13 +64,13 @@ APP_BRAND_AVATAR_URL=
|
|
| 69 |
|
| 70 |
# model defaults
|
| 71 |
# Global default model for all tasks.
|
| 72 |
-
INFERENCE_MODEL_ID=
|
| 73 |
INFERENCE_ENFORCE_QWEN_ONLY=true
|
| 74 |
-
INFERENCE_QWEN_LOCK_MODEL=
|
| 75 |
INFERENCE_MAX_NEW_TOKENS=8192
|
| 76 |
INFERENCE_TEMPERATURE=0.2
|
| 77 |
INFERENCE_TOP_P=0.9
|
| 78 |
-
INFERENCE_CHAT_MODEL_ID=
|
| 79 |
# Temporary chat-only override for experiments (clear to roll back instantly).
|
| 80 |
# Example: Qwen/Qwen3-32B
|
| 81 |
INFERENCE_CHAT_MODEL_TEMP_OVERRIDE=
|
|
@@ -95,7 +90,7 @@ CHAT_STREAM_CONTINUATION_TAIL_CHARS=900
|
|
| 95 |
CHAT_STREAM_COMPLETION_MODE_DEFAULT=auto
|
| 96 |
# Optional: force quiz-generation model. Leave empty to use routing.task_model_map.quiz_generation.
|
| 97 |
HF_QUIZ_MODEL_ID=
|
| 98 |
-
HF_QUIZ_JSON_REPAIR_MODEL_ID=
|
| 99 |
|
| 100 |
# retry behavior
|
| 101 |
INFERENCE_MAX_RETRIES=3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
|
|
|
| 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
|
|
|
|
| 64 |
|
| 65 |
# model defaults
|
| 66 |
# Global default model for all tasks.
|
| 67 |
+
INFERENCE_MODEL_ID=Qwen/Qwen3-32B
|
| 68 |
INFERENCE_ENFORCE_QWEN_ONLY=true
|
| 69 |
+
INFERENCE_QWEN_LOCK_MODEL=Qwen/Qwen3-32B
|
| 70 |
INFERENCE_MAX_NEW_TOKENS=8192
|
| 71 |
INFERENCE_TEMPERATURE=0.2
|
| 72 |
INFERENCE_TOP_P=0.9
|
| 73 |
+
INFERENCE_CHAT_MODEL_ID=Qwen/Qwen3-32B
|
| 74 |
# Temporary chat-only override for experiments (clear to roll back instantly).
|
| 75 |
# Example: Qwen/Qwen3-32B
|
| 76 |
INFERENCE_CHAT_MODEL_TEMP_OVERRIDE=
|
|
|
|
| 90 |
CHAT_STREAM_COMPLETION_MODE_DEFAULT=auto
|
| 91 |
# Optional: force quiz-generation model. Leave empty to use routing.task_model_map.quiz_generation.
|
| 92 |
HF_QUIZ_MODEL_ID=
|
| 93 |
+
HF_QUIZ_JSON_REPAIR_MODEL_ID=Qwen/Qwen3-32B
|
| 94 |
|
| 95 |
# retry behavior
|
| 96 |
INFERENCE_MAX_RETRIES=3
|
config/models.yaml
CHANGED
|
@@ -1,85 +1,55 @@
|
|
| 1 |
models:
|
| 2 |
primary:
|
| 3 |
-
id:
|
| 4 |
-
description:
|
| 5 |
-
max_new_tokens:
|
| 6 |
-
temperature: 0.
|
| 7 |
top_p: 0.9
|
| 8 |
|
| 9 |
-
|
| 10 |
-
id:
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
- chat
|
| 21 |
-
- quiz_generation
|
| 22 |
-
- learning_path
|
| 23 |
-
- daily_insight
|
| 24 |
-
|
| 25 |
-
embedding:
|
| 26 |
-
id: BAAI/bge-small-en-v1.5
|
| 27 |
-
description: Embedding model for RAG retrieval — curriculum vectorstore ingestion and semantic search
|
| 28 |
-
note: Not part of the generation pipeline. Read from EMBEDDING_MODEL env var only. Not swappable via admin panel.
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
|
| 36 |
routing:
|
| 37 |
task_model_map:
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
rag_analysis_context: deepseek-chat
|
| 49 |
|
| 50 |
task_fallback_model_map:
|
| 51 |
-
chat:
|
| 52 |
-
- deepseek-chat
|
| 53 |
verify_solution:
|
| 54 |
-
-
|
| 55 |
-
|
| 56 |
-
- deepseek-chat
|
| 57 |
-
quiz_generation:
|
| 58 |
-
- deepseek-chat
|
| 59 |
-
learning_path:
|
| 60 |
-
- deepseek-chat
|
| 61 |
-
daily_insight:
|
| 62 |
-
- deepseek-chat
|
| 63 |
-
risk_classification:
|
| 64 |
-
- deepseek-chat
|
| 65 |
-
risk_narrative:
|
| 66 |
-
- deepseek-chat
|
| 67 |
-
rag_lesson:
|
| 68 |
-
- deepseek-chat
|
| 69 |
-
rag_problem:
|
| 70 |
-
- deepseek-chat
|
| 71 |
-
rag_analysis_context:
|
| 72 |
-
- deepseek-chat
|
| 73 |
|
| 74 |
task_provider_map:
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
risk_narrative:
|
| 83 |
-
|
| 84 |
-
rag_problem: deepseek
|
| 85 |
-
rag_analysis_context: deepseek
|
|
|
|
| 1 |
models:
|
| 2 |
primary:
|
| 3 |
+
id: Qwen/Qwen3-32B
|
| 4 |
+
description: Global 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 |
+
# Keep all task defaults aligned to Qwen3-32B.
|
| 30 |
+
# Hard prompts can still escalate via runtime policy in inference_client.
|
| 31 |
+
chat: Qwen/Qwen3-32B
|
| 32 |
+
verify_solution: Qwen/Qwen3-32B
|
| 33 |
+
lesson_generation: Qwen/Qwen3-32B
|
| 34 |
+
quiz_generation: Qwen/Qwen3-32B
|
| 35 |
+
learning_path: Qwen/Qwen3-32B
|
| 36 |
+
daily_insight: Qwen/Qwen3-32B
|
| 37 |
+
risk_classification: Qwen/Qwen3-32B
|
| 38 |
+
risk_narrative: Qwen/Qwen3-32B
|
|
|
|
| 39 |
|
| 40 |
task_fallback_model_map:
|
| 41 |
+
chat: [] # Chat is strict-primary only (no fallback chain)
|
|
|
|
| 42 |
verify_solution:
|
| 43 |
+
- meta-llama/Meta-Llama-3-70B-Instruct # Higher-capacity fallback
|
| 44 |
+
- meta-llama/Llama-3.1-8B-Instruct # Second fallback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
task_provider_map:
|
| 47 |
+
# All tasks use hf_inference router (Qwen/Qwen3-32B natively supported)
|
| 48 |
+
chat: hf_inference
|
| 49 |
+
verify_solution: hf_inference
|
| 50 |
+
lesson_generation: hf_inference
|
| 51 |
+
quiz_generation: hf_inference
|
| 52 |
+
learning_path: hf_inference
|
| 53 |
+
daily_insight: hf_inference
|
| 54 |
+
risk_narrative: hf_inference
|
| 55 |
+
risk_classification: hf_inference
|
|
|
|
|
|
datasets/sample_curriculum.json
DELETED
|
@@ -1,137 +0,0 @@
|
|
| 1 |
-
[
|
| 2 |
-
{
|
| 3 |
-
"content": "The learner demonstrates understanding of key concepts of functions. Functions can be represented as ordered pairs, tables of values, graphs, and equations. A function is a relation where each element in the domain corresponds to exactly one element in the range. Key types include linear functions (f(x)=mx+b), quadratic functions (f(x)=ax^2+bx+c), and polynomial functions of higher degrees.",
|
| 4 |
-
"subject": "General Mathematics",
|
| 5 |
-
"quarter": 1,
|
| 6 |
-
"content_domain": "Functions and Their Graphs",
|
| 7 |
-
"chunk_type": "content_explanation",
|
| 8 |
-
"source_file": "sample_curriculum.json",
|
| 9 |
-
"page": 1
|
| 10 |
-
},
|
| 11 |
-
{
|
| 12 |
-
"content": "Learning Competency (M11GM-Ia-1): Represents real-life situations using functions, including piece-wise functions. Example: A taxi fare is computed as P40 for the first 500 meters plus P3.50 for every additional 300 meters or fraction thereof. This is a piecewise function where f(d)=40 for d<=500 and f(d)=40+3.5*ceil((d-500)/300) for d>500.",
|
| 13 |
-
"subject": "General Mathematics",
|
| 14 |
-
"quarter": 1,
|
| 15 |
-
"content_domain": "Functions and Their Graphs",
|
| 16 |
-
"chunk_type": "learning_competency",
|
| 17 |
-
"source_file": "sample_curriculum.json",
|
| 18 |
-
"page": 1
|
| 19 |
-
},
|
| 20 |
-
{
|
| 21 |
-
"content": "Learning Competency (M11GM-Ia-2): Evaluates a function. To evaluate f(x) at x=a, substitute a for every occurrence of x in the expression and simplify. Example: Given f(x)=2x^2-3x+5, evaluate f(2): f(2)=2(4)-3(2)+5=8-6+5=7.",
|
| 22 |
-
"subject": "General Mathematics",
|
| 23 |
-
"quarter": 1,
|
| 24 |
-
"content_domain": "Functions and Their Graphs",
|
| 25 |
-
"chunk_type": "content_explanation",
|
| 26 |
-
"source_file": "sample_curriculum.json",
|
| 27 |
-
"page": 2
|
| 28 |
-
},
|
| 29 |
-
{
|
| 30 |
-
"content": "Rational Functions have the form f(x)=P(x)/Q(x) where P(x) and Q(x) are polynomials and Q(x)!=0. Key features: vertical asymptotes occur where Q(x)=0 but P(x)!=0; horizontal asymptotes depend on the degrees of P and Q. The domain of f(x) excludes all x-values that make the denominator zero. Solving rational equations and inequalities requires careful handling of the denominator signs.",
|
| 31 |
-
"subject": "General Mathematics",
|
| 32 |
-
"quarter": 1,
|
| 33 |
-
"content_domain": "Rational Functions",
|
| 34 |
-
"chunk_type": "content_explanation",
|
| 35 |
-
"source_file": "sample_curriculum.json",
|
| 36 |
-
"page": 3
|
| 37 |
-
},
|
| 38 |
-
{
|
| 39 |
-
"content": "Learning Competency (M11GM-Ib-3): Solves problems involving rational functions, rational equations, and rational inequalities. Example: A jeepney operator's average revenue per trip is modeled by R(n)=(5000+300n)/n where n is the number of trips per day. Find how many trips are needed for average revenue to reach P450.",
|
| 40 |
-
"subject": "General Mathematics",
|
| 41 |
-
"quarter": 1,
|
| 42 |
-
"content_domain": "Rational Functions",
|
| 43 |
-
"chunk_type": "learning_competency",
|
| 44 |
-
"source_file": "sample_curriculum.json",
|
| 45 |
-
"page": 3
|
| 46 |
-
},
|
| 47 |
-
{
|
| 48 |
-
"content": "Exponential Functions f(x)=a*b^x (a!=0, b>0, b!=1) model growth and decay. Key properties: domain is all real numbers; range is (0,infinity) for a>0; horizontal asymptote at y=0; y-intercept at (0,a). Solving exponential equations involves expressing both sides with the same base and equating exponents. Philippine applications include bacterial growth and radioactive decay in medical contexts.",
|
| 49 |
-
"subject": "General Mathematics",
|
| 50 |
-
"quarter": 2,
|
| 51 |
-
"content_domain": "Exponential Functions",
|
| 52 |
-
"chunk_type": "content_explanation",
|
| 53 |
-
"source_file": "sample_curriculum.json",
|
| 54 |
-
"page": 4
|
| 55 |
-
},
|
| 56 |
-
{
|
| 57 |
-
"content": "Compound Interest is calculated using A=P(1+r/n)^(nt) where A is the final amount, P is the principal, r is the annual interest rate (decimal), n is the number of compounding periods per year, and t is the time in years. Philippine banks offer savings and loan products with various compounding frequencies: annually (n=1), semi-annually (n=2), quarterly (n=4), monthly (n=12).",
|
| 58 |
-
"subject": "General Mathematics",
|
| 59 |
-
"quarter": 3,
|
| 60 |
-
"content_domain": "Business Mathematics",
|
| 61 |
-
"chunk_type": "content_explanation",
|
| 62 |
-
"source_file": "sample_curriculum.json",
|
| 63 |
-
"page": 5
|
| 64 |
-
},
|
| 65 |
-
{
|
| 66 |
-
"content": "Learning Competency (M11GM-IIc-1): Illustrates simple and compound interests. Simple interest I=Prt where P is principal, r is rate, t is time. Compound interest uses compounding formula. Example: Juana deposits P50,000 in a bank offering 3.5% interest compounded quarterly. After 3 years, her balance will be A=50000(1+0.035/4)^(4*3)=55543.19 using the compound interest formula.",
|
| 67 |
-
"subject": "General Mathematics",
|
| 68 |
-
"quarter": 3,
|
| 69 |
-
"content_domain": "Business Mathematics",
|
| 70 |
-
"chunk_type": "learning_competency",
|
| 71 |
-
"source_file": "sample_curriculum.json",
|
| 72 |
-
"page": 5
|
| 73 |
-
},
|
| 74 |
-
{
|
| 75 |
-
"content": "Annuities are sequences of equal payments made at equal time intervals. The future value of an ordinary annuity (payment at end of period) is FV=PMT*[(1+r)^n-1]/r and present value is PV=PMT*[1-(1+r)^(-n)]/r. Applications include Pag-IBIG housing loans, SSS contributions, and insurance premiums. Philippine context problems often involve 15-year and 25-year housing loans.",
|
| 76 |
-
"subject": "General Mathematics",
|
| 77 |
-
"quarter": 3,
|
| 78 |
-
"content_domain": "Business Mathematics",
|
| 79 |
-
"chunk_type": "content_explanation",
|
| 80 |
-
"source_file": "sample_curriculum.json",
|
| 81 |
-
"page": 6
|
| 82 |
-
},
|
| 83 |
-
{
|
| 84 |
-
"content": "Stocks and Bonds represent two types of investments. Stocks represent ownership shares in a corporation with dividends as earnings — prices are quoted per share in the Philippine Stock Exchange (PSE). Bonds are debt instruments where the issuing entity borrows money and pays periodic interest then repays principal at maturity. Key computations: stock yield = annual dividend per share / market price; bond yield = annual interest payment / market price.",
|
| 85 |
-
"subject": "General Mathematics",
|
| 86 |
-
"quarter": 3,
|
| 87 |
-
"content_domain": "Business Mathematics",
|
| 88 |
-
"chunk_type": "content_explanation",
|
| 89 |
-
"source_file": "sample_curriculum.json",
|
| 90 |
-
"page": 6
|
| 91 |
-
},
|
| 92 |
-
{
|
| 93 |
-
"content": "A Random Variable is a function that assigns a real number to each outcome in the sample space of a random experiment. A Discrete Random Variable has a countable number of possible values. The probability mass function (PMF) gives the probability P(X=x) for each value x. Key properties: sum of all P(X=x)=1 and P(X=x)>=0 for all x. Common discrete distributions include Binomial for success/failure and Poisson for rare events.",
|
| 94 |
-
"subject": "Statistics and Probability",
|
| 95 |
-
"quarter": 1,
|
| 96 |
-
"content_domain": "Random Variables and Probability Distributions",
|
| 97 |
-
"chunk_type": "content_explanation",
|
| 98 |
-
"source_file": "sample_curriculum.json",
|
| 99 |
-
"page": 7
|
| 100 |
-
},
|
| 101 |
-
{
|
| 102 |
-
"content": "Learning Competency (M11/12SP-IIIa-1): Illustrates a random variable (discrete and continuous). A discrete random variable takes countable values like the number of defective items in a batch of 50 bulbs. A continuous random variable takes infinite uncountable values in an interval, such as the height of Grade 11 students in centimeters. The learner distinguishes between discrete and continuous random variables for real Philippine data.",
|
| 103 |
-
"subject": "Statistics and Probability",
|
| 104 |
-
"quarter": 1,
|
| 105 |
-
"content_domain": "Random Variables and Probability Distributions",
|
| 106 |
-
"chunk_type": "learning_competency",
|
| 107 |
-
"source_file": "sample_curriculum.json",
|
| 108 |
-
"page": 7
|
| 109 |
-
},
|
| 110 |
-
{
|
| 111 |
-
"content": "The Normal Distribution (Gaussian) is a continuous probability distribution with a bell-shaped curve symmetric about the mean mu. Standard normal distribution has mu=0 and sigma=1; converting to standard normal z=(x-mu)/sigma allows probability calculation using z-tables. Properties: 68% of data within 1 sigma of mu, 95% within 2 sigma, 99.7% within 3 sigma. Philippine applications include standardized test scores (NAT, college entrance exams) and quality control in manufacturing.",
|
| 112 |
-
"subject": "Statistics and Probability",
|
| 113 |
-
"quarter": 1,
|
| 114 |
-
"content_domain": "Random Variables and Probability Distributions",
|
| 115 |
-
"chunk_type": "content_explanation",
|
| 116 |
-
"source_file": "sample_curriculum.json",
|
| 117 |
-
"page": 8
|
| 118 |
-
},
|
| 119 |
-
{
|
| 120 |
-
"content": "Conic Sections are curves formed by the intersection of a plane and a double-napped cone. The four types are: Circle (all points equidistant from a center), Parabola (all points equidistant from a focus and directrix), Ellipse (sum of distances to two foci is constant), and Hyperbola (absolute difference of distances to two foci is constant). Standard forms: Circle (x-h)^2+(y-k)^2=r^2; Parabola (x-h)^2=4p(y-k) or (y-k)^2=4p(x-h).",
|
| 121 |
-
"subject": "Pre-Calculus",
|
| 122 |
-
"quarter": 1,
|
| 123 |
-
"content_domain": "Analytic Geometry",
|
| 124 |
-
"chunk_type": "content_explanation",
|
| 125 |
-
"source_file": "sample_curriculum.json",
|
| 126 |
-
"page": 9
|
| 127 |
-
},
|
| 128 |
-
{
|
| 129 |
-
"content": "Learning Competency (STEM_PC11AG-Ia-1): Illustrates the different types of conic sections: circle, parabola, ellipse, and hyperbola. The learner identifies conic sections from their standard equations and determines their key properties including center, radius (for circles), vertex, focus, directrix (for parabolas), and asymptotes (for hyperbolas). Real-world applications include satellite dishes, telescope mirrors, and bridge arch designs.",
|
| 130 |
-
"subject": "Pre-Calculus",
|
| 131 |
-
"quarter": 1,
|
| 132 |
-
"content_domain": "Analytic Geometry",
|
| 133 |
-
"chunk_type": "learning_competency",
|
| 134 |
-
"source_file": "sample_curriculum.json",
|
| 135 |
-
"page": 9
|
| 136 |
-
}
|
| 137 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
main.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
middleware/__init__.py
DELETED
|
@@ -1,4 +0,0 @@
|
|
| 1 |
-
# Middleware package
|
| 2 |
-
from .rate_limiter import rate_limiter, setup_rate_limiting, RateLimitExceeded
|
| 3 |
-
|
| 4 |
-
__all__ = ["rate_limiter", "setup_rate_limiting", "RateLimitExceeded"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
middleware/rate_limiter.py
DELETED
|
@@ -1,184 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Rate limiting middleware using slowapi.
|
| 3 |
-
"""
|
| 4 |
-
import os
|
| 5 |
-
import logging
|
| 6 |
-
|
| 7 |
-
from fastapi import Request
|
| 8 |
-
from slowapi import Limiter
|
| 9 |
-
from slowapi.errors import RateLimitExceeded as SlowAPIRateLimitExceeded
|
| 10 |
-
|
| 11 |
-
logger = logging.getLogger("mathpulse.ratelimit")
|
| 12 |
-
|
| 13 |
-
# Environment-based configuration with defaults
|
| 14 |
-
RATE_LIMIT_AI_RPM = int(os.getenv("RATE_LIMIT_AI_RPM", "20"))
|
| 15 |
-
RATE_LIMIT_QUIZ_GENERATE_RPM = int(os.getenv("RATE_LIMIT_QUIZ_GENERATE_RPM", "10"))
|
| 16 |
-
RATE_LIMIT_QUIZ_SUBMIT_RPM = int(os.getenv("RATE_LIMIT_QUIZ_SUBMIT_RPM", "30"))
|
| 17 |
-
RATE_LIMIT_AUTH_RPM = int(os.getenv("RATE_LIMIT_AUTH_RPM", "5"))
|
| 18 |
-
RATE_LIMIT_LEADERBOARD_RPM = int(os.getenv("RATE_LIMIT_LEADERBOARD_RPM", "60"))
|
| 19 |
-
RATE_LIMIT_DEFAULT_RPM = int(os.getenv("RATE_LIMIT_DEFAULT_RPM", "100"))
|
| 20 |
-
RATE_LIMIT_ADMIN_MULTIPLIER = int(os.getenv("RATE_LIMIT_ADMIN_MULTIPLIER", "10"))
|
| 21 |
-
RATE_LIMIT_TEACHER_MULTIPLIER = int(os.getenv("RATE_LIMIT_TEACHER_MULTIPLIER", "3"))
|
| 22 |
-
|
| 23 |
-
# Role multipliers for rate limit adjustment
|
| 24 |
-
ROLE_MULTIPLIERS = {
|
| 25 |
-
"admin": RATE_LIMIT_ADMIN_MULTIPLIER,
|
| 26 |
-
"teacher": RATE_LIMIT_TEACHER_MULTIPLIER,
|
| 27 |
-
"student": 1,
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def _get_user_identifier(request: Request) -> str:
|
| 32 |
-
"""
|
| 33 |
-
Extract user identifier for rate limiting.
|
| 34 |
-
Uses Firebase UID from request.state.user if authenticated, otherwise falls back to IP.
|
| 35 |
-
"""
|
| 36 |
-
user = getattr(request.state, "user", None)
|
| 37 |
-
if user and hasattr(user, "uid") and user.uid:
|
| 38 |
-
return f"uid:{user.uid}"
|
| 39 |
-
|
| 40 |
-
if request.client:
|
| 41 |
-
return f"ip:{request.client.host}"
|
| 42 |
-
return "ip:unknown"
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def _get_user_role(request: Request) -> str:
|
| 46 |
-
"""Get user role from request state for multiplier calculation."""
|
| 47 |
-
user = getattr(request.state, "user", None)
|
| 48 |
-
if user and hasattr(user, "role") and user.role:
|
| 49 |
-
return user.role
|
| 50 |
-
return "student"
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
def _get_role_multiplier(request: Request) -> int:
|
| 54 |
-
"""Get rate limit multiplier based on user role."""
|
| 55 |
-
role = _get_user_role(request)
|
| 56 |
-
return ROLE_MULTIPLIERS.get(role, 1)
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
class MathPulseLimiter:
|
| 60 |
-
"""
|
| 61 |
-
Rate limiter with role-aware multipliers for MathPulse AI.
|
| 62 |
-
"""
|
| 63 |
-
|
| 64 |
-
def __init__(self) -> None:
|
| 65 |
-
self._limiter = Limiter(
|
| 66 |
-
key_func=_get_user_identifier,
|
| 67 |
-
storage_uri="memory://",
|
| 68 |
-
default_limits=[f"{RATE_LIMIT_DEFAULT_RPM}/minute"],
|
| 69 |
-
)
|
| 70 |
-
|
| 71 |
-
@property
|
| 72 |
-
def limiter(self) -> Limiter:
|
| 73 |
-
return self._limiter
|
| 74 |
-
|
| 75 |
-
def _get_adjusted_limit(self, base_rpm: int, request: Request) -> int:
|
| 76 |
-
"""Apply role multiplier to base rate limit."""
|
| 77 |
-
multiplier = _get_role_multiplier(request)
|
| 78 |
-
return base_rpm * multiplier
|
| 79 |
-
|
| 80 |
-
def ai_limit(self, request: Request) -> str:
|
| 81 |
-
"""Rate limit for AI endpoints with role adjustment."""
|
| 82 |
-
limit = self._get_adjusted_limit(RATE_LIMIT_AI_RPM, request)
|
| 83 |
-
return f"{limit}/minute"
|
| 84 |
-
|
| 85 |
-
def quiz_generate_limit(self, request: Request) -> str:
|
| 86 |
-
"""Rate limit for quiz generation with role adjustment."""
|
| 87 |
-
limit = self._get_adjusted_limit(RATE_LIMIT_QUIZ_GENERATE_RPM, request)
|
| 88 |
-
return f"{limit}/minute"
|
| 89 |
-
|
| 90 |
-
def quiz_submit_limit(self, request: Request) -> str:
|
| 91 |
-
"""Rate limit for quiz submission with role adjustment."""
|
| 92 |
-
limit = self._get_adjusted_limit(RATE_LIMIT_QUIZ_SUBMIT_RPM, request)
|
| 93 |
-
return f"{limit}/minute"
|
| 94 |
-
|
| 95 |
-
def auth_limit(self, request: Request) -> str:
|
| 96 |
-
"""Rate limit for auth endpoints with role adjustment."""
|
| 97 |
-
limit = self._get_adjusted_limit(RATE_LIMIT_AUTH_RPM, request)
|
| 98 |
-
return f"{limit}/minute"
|
| 99 |
-
|
| 100 |
-
def leaderboard_limit(self, request: Request) -> str:
|
| 101 |
-
"""Rate limit for leaderboard with role adjustment."""
|
| 102 |
-
limit = self._get_adjusted_limit(RATE_LIMIT_LEADERBOARD_RPM, request)
|
| 103 |
-
return f"{limit}/minute"
|
| 104 |
-
|
| 105 |
-
def default_limit(self, request: Request) -> str:
|
| 106 |
-
"""Default rate limit with role adjustment."""
|
| 107 |
-
limit = self._get_adjusted_limit(RATE_LIMIT_DEFAULT_RPM, request)
|
| 108 |
-
return f"{limit}/minute"
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
# Global rate limiter instance
|
| 112 |
-
rate_limiter = MathPulseLimiter()
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
def setup_rate_limiting(app) -> None:
|
| 116 |
-
"""
|
| 117 |
-
Set up rate limiting for the FastAPI application.
|
| 118 |
-
"""
|
| 119 |
-
|
| 120 |
-
# Add limiter to app state
|
| 121 |
-
app.state.limiter = rate_limiter.limiter
|
| 122 |
-
|
| 123 |
-
# Add slowapi exception handler
|
| 124 |
-
app.add_exception_handler(
|
| 125 |
-
SlowAPIRateLimitExceeded,
|
| 126 |
-
lambda request, exc: _rate_limit_exceeded_handler(request, exc)
|
| 127 |
-
)
|
| 128 |
-
|
| 129 |
-
logger.info(
|
| 130 |
-
f"Rate limiting configured: AI={RATE_LIMIT_AI_RPM}/min, "
|
| 131 |
-
f"QuizGen={RATE_LIMIT_QUIZ_GENERATE_RPM}/min, "
|
| 132 |
-
f"Auth={RATE_LIMIT_AUTH_RPM}/min, "
|
| 133 |
-
f"Admin={RATE_LIMIT_ADMIN_MULTIPLIER}x, Teacher={RATE_LIMIT_TEACHER_MULTIPLIER}x"
|
| 134 |
-
)
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
def _rate_limit_exceeded_handler(request: Request, exc: SlowAPIRateLimitExceeded):
|
| 138 |
-
"""Handle rate limit exceeded errors with proper JSON response."""
|
| 139 |
-
from fastapi.responses import JSONResponse
|
| 140 |
-
|
| 141 |
-
retry_after = getattr(exc, "retry_after", 60)
|
| 142 |
-
return JSONResponse(
|
| 143 |
-
status_code=429,
|
| 144 |
-
content={
|
| 145 |
-
"error": "rate_limit_exceeded",
|
| 146 |
-
"message": "Too many requests. Please try again later.",
|
| 147 |
-
"retry_after": retry_after,
|
| 148 |
-
},
|
| 149 |
-
headers={
|
| 150 |
-
"Retry-After": str(retry_after),
|
| 151 |
-
"Content-Type": "application/json",
|
| 152 |
-
}
|
| 153 |
-
)
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
# Decorator helpers
|
| 157 |
-
def ai_rate_limit():
|
| 158 |
-
"""Decorator for AI endpoint rate limiting."""
|
| 159 |
-
return rate_limiter.limiter.limit(rate_limiter.ai_limit)
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
def quiz_generate_rate_limit():
|
| 163 |
-
"""Decorator for quiz generation rate limiting."""
|
| 164 |
-
return rate_limiter.limiter.limit(rate_limiter.quiz_generate_limit)
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
def quiz_submit_rate_limit():
|
| 168 |
-
"""Decorator for quiz submit rate limiting."""
|
| 169 |
-
return rate_limiter.limiter.limit(rate_limiter.quiz_submit_limit)
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
def auth_rate_limit():
|
| 173 |
-
"""Decorator for auth endpoint rate limiting."""
|
| 174 |
-
return rate_limiter.limiter.limit(rate_limiter.auth_limit)
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
def leaderboard_rate_limit():
|
| 178 |
-
"""Decorator for leaderboard rate limiting."""
|
| 179 |
-
return rate_limiter.limiter.limit(rate_limiter.leaderboard_limit)
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
def default_rate_limit():
|
| 183 |
-
"""Decorator for default rate limiting."""
|
| 184 |
-
return rate_limiter.limiter.limit(rate_limiter.default_limit)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pre_deploy_check.py
CHANGED
|
@@ -16,16 +16,8 @@ Exit codes:
|
|
| 16 |
import sys
|
| 17 |
import os
|
| 18 |
|
| 19 |
-
# Add
|
| 20 |
-
|
| 21 |
-
_parent = os.path.dirname(_repo_root)
|
| 22 |
-
_backend = _repo_root
|
| 23 |
-
|
| 24 |
-
# Add in order: parent first (so services/ can delegate), then backend (for when services/__init__.py tries to import)
|
| 25 |
-
if _parent not in sys.path:
|
| 26 |
-
sys.path.insert(0, _parent)
|
| 27 |
-
if _backend not in sys.path:
|
| 28 |
-
sys.path.insert(0, _backend)
|
| 29 |
|
| 30 |
def main() -> int:
|
| 31 |
"""Run pre-deployment checks."""
|
|
|
|
| 16 |
import sys
|
| 17 |
import os
|
| 18 |
|
| 19 |
+
# Add backend to path
|
| 20 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
def main() -> int:
|
| 23 |
"""Run pre-deployment checks."""
|
rag/__init__.py
CHANGED
|
@@ -5,19 +5,11 @@ from .curriculum_rag import (
|
|
| 5 |
build_lesson_prompt,
|
| 6 |
build_problem_generation_prompt,
|
| 7 |
build_analysis_curriculum_context,
|
| 8 |
-
build_lesson_query,
|
| 9 |
-
format_retrieved_chunks,
|
| 10 |
-
summarize_retrieval_confidence,
|
| 11 |
)
|
| 12 |
-
from .vectorstore_loader import reset_vectorstore_singleton
|
| 13 |
|
| 14 |
__all__ = [
|
| 15 |
"retrieve_curriculum_context",
|
| 16 |
"build_lesson_prompt",
|
| 17 |
"build_problem_generation_prompt",
|
| 18 |
"build_analysis_curriculum_context",
|
| 19 |
-
|
| 20 |
-
"format_retrieved_chunks",
|
| 21 |
-
"summarize_retrieval_confidence",
|
| 22 |
-
"reset_vectorstore_singleton",
|
| 23 |
-
]
|
|
|
|
| 5 |
build_lesson_prompt,
|
| 6 |
build_problem_generation_prompt,
|
| 7 |
build_analysis_curriculum_context,
|
|
|
|
|
|
|
|
|
|
| 8 |
)
|
|
|
|
| 9 |
|
| 10 |
__all__ = [
|
| 11 |
"retrieve_curriculum_context",
|
| 12 |
"build_lesson_prompt",
|
| 13 |
"build_problem_generation_prompt",
|
| 14 |
"build_analysis_curriculum_context",
|
| 15 |
+
]
|
|
|
|
|
|
|
|
|
|
|
|
rag/curriculum_rag.py
CHANGED
|
@@ -1,10 +1,8 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Updated curriculum RAG with exact match retrieval and 7-section notebook output.
|
| 3 |
-
"""
|
| 4 |
-
|
| 5 |
from __future__ import annotations
|
| 6 |
|
| 7 |
-
from typing import Dict, List, Optional
|
|
|
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
def _to_where(
|
|
@@ -12,10 +10,6 @@ def _to_where(
|
|
| 12 |
quarter: Optional[int] = None,
|
| 13 |
content_domain: Optional[str] = None,
|
| 14 |
chunk_type: Optional[str] = None,
|
| 15 |
-
module_id: Optional[str] = None,
|
| 16 |
-
lesson_id: Optional[str] = None,
|
| 17 |
-
competency_code: Optional[str] = None,
|
| 18 |
-
storage_path: Optional[str] = None,
|
| 19 |
) -> Optional[Dict[str, object]]:
|
| 20 |
clauses = []
|
| 21 |
if subject:
|
|
@@ -26,14 +20,6 @@ def _to_where(
|
|
| 26 |
clauses.append({"content_domain": {"$eq": content_domain}})
|
| 27 |
if chunk_type:
|
| 28 |
clauses.append({"chunk_type": {"$eq": chunk_type}})
|
| 29 |
-
if module_id:
|
| 30 |
-
clauses.append({"module_id": {"$eq": module_id}})
|
| 31 |
-
if lesson_id:
|
| 32 |
-
clauses.append({"lesson_id": {"$eq": lesson_id}})
|
| 33 |
-
if competency_code:
|
| 34 |
-
clauses.append({"competency_code": {"$eq": competency_code}})
|
| 35 |
-
if storage_path:
|
| 36 |
-
clauses.append({"storage_path": {"$eq": storage_path}})
|
| 37 |
if not clauses:
|
| 38 |
return None
|
| 39 |
if len(clauses) == 1:
|
|
@@ -42,6 +28,7 @@ def _to_where(
|
|
| 42 |
|
| 43 |
|
| 44 |
def _distance_to_score(distance: float) -> float:
|
|
|
|
| 45 |
return round(1.0 / (1.0 + max(distance, 0.0)), 4)
|
| 46 |
|
| 47 |
|
|
@@ -51,23 +38,12 @@ def retrieve_curriculum_context(
|
|
| 51 |
quarter: int | None = None,
|
| 52 |
content_domain: str | None = None,
|
| 53 |
chunk_type: str | None = None,
|
| 54 |
-
|
| 55 |
-
lesson_id: str | None = None,
|
| 56 |
-
competency_code: str | None = None,
|
| 57 |
-
storage_path: str | None = None,
|
| 58 |
-
top_k: int = 8,
|
| 59 |
) -> list[dict]:
|
| 60 |
-
from rag.vectorstore_loader import get_vectorstore_components
|
| 61 |
-
|
| 62 |
_, collection, embedder = get_vectorstore_components()
|
| 63 |
-
where = _to_where(subject, quarter, content_domain, chunk_type
|
| 64 |
-
|
| 65 |
-
prefixed_query = f"Represent this sentence for searching relevant passages: {query}"
|
| 66 |
-
query_embedding = embedder.encode(
|
| 67 |
-
prefixed_query,
|
| 68 |
-
normalize_embeddings=True,
|
| 69 |
-
).tolist()
|
| 70 |
|
|
|
|
| 71 |
result = collection.query(
|
| 72 |
query_embeddings=[query_embedding],
|
| 73 |
n_results=max(1, top_k),
|
|
@@ -83,39 +59,20 @@ def retrieve_curriculum_context(
|
|
| 83 |
for idx, content in enumerate(documents):
|
| 84 |
md = metadatas[idx] if idx < len(metadatas) and isinstance(metadatas[idx], dict) else {}
|
| 85 |
distance = float(distances[idx]) if idx < len(distances) else 1.0
|
| 86 |
-
rows.append(
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
"score": _distance_to_score(distance),
|
| 99 |
-
})
|
| 100 |
-
return rows
|
| 101 |
-
|
| 102 |
|
| 103 |
-
|
| 104 |
-
topic: str,
|
| 105 |
-
subject: str,
|
| 106 |
-
quarter: int,
|
| 107 |
-
lesson_title: str | None = None,
|
| 108 |
-
competency: str | None = None,
|
| 109 |
-
module_unit: str | None = None,
|
| 110 |
-
learner_level: str | None = None,
|
| 111 |
-
competency_code: str | None = None,
|
| 112 |
-
) -> str:
|
| 113 |
-
parts = [topic, subject, f"Quarter {quarter}"]
|
| 114 |
-
for value in (lesson_title, competency, module_unit, learner_level, competency_code):
|
| 115 |
-
clean = str(value or "").strip()
|
| 116 |
-
if clean:
|
| 117 |
-
parts.append(clean)
|
| 118 |
-
return " | ".join(parts)
|
| 119 |
|
| 120 |
|
| 121 |
def build_lesson_query(
|
|
@@ -136,120 +93,30 @@ def build_lesson_query(
|
|
| 136 |
return " | ".join(parts)
|
| 137 |
|
| 138 |
|
| 139 |
-
def retrieve_lesson_pdf_context(
|
| 140 |
-
topic: str,
|
| 141 |
-
subject: str,
|
| 142 |
-
quarter: int,
|
| 143 |
-
lesson_title: str | None = None,
|
| 144 |
-
competency: str | None = None,
|
| 145 |
-
module_id: str | None = None,
|
| 146 |
-
lesson_id: str | None = None,
|
| 147 |
-
competency_code: str | None = None,
|
| 148 |
-
storage_path: str | None = None,
|
| 149 |
-
top_k: int = 8,
|
| 150 |
-
) -> Tuple[list[dict], str]:
|
| 151 |
-
"""Retrieve chunks by storage_path exact match + semantic ranking; fallback to general query.
|
| 152 |
-
|
| 153 |
-
NOTE: Curriculum PDF chunks are often tagged with quarter=1 even when they cover all quarters.
|
| 154 |
-
We first try the exact quarter, then fallback to quarter=1, then no quarter filter.
|
| 155 |
-
"""
|
| 156 |
-
# Try 1: Exact match with storage_path + quarter
|
| 157 |
-
if storage_path:
|
| 158 |
-
exact_chunks = retrieve_curriculum_context(
|
| 159 |
-
query=topic,
|
| 160 |
-
subject=subject,
|
| 161 |
-
quarter=quarter,
|
| 162 |
-
storage_path=storage_path,
|
| 163 |
-
top_k=top_k,
|
| 164 |
-
)
|
| 165 |
-
if exact_chunks and any(c["score"] >= 0.65 for c in exact_chunks):
|
| 166 |
-
return exact_chunks, "exact"
|
| 167 |
-
|
| 168 |
-
# Try 2: General query with exact quarter
|
| 169 |
-
general_chunks = retrieve_curriculum_context(
|
| 170 |
-
query=topic,
|
| 171 |
-
subject=subject,
|
| 172 |
-
quarter=quarter,
|
| 173 |
-
top_k=top_k,
|
| 174 |
-
)
|
| 175 |
-
|
| 176 |
-
# Try 3: Fallback to quarter=1 (most curriculum PDFs are tagged Q1)
|
| 177 |
-
if not general_chunks and quarter != 1:
|
| 178 |
-
general_chunks = retrieve_curriculum_context(
|
| 179 |
-
query=topic,
|
| 180 |
-
subject=subject,
|
| 181 |
-
quarter=1,
|
| 182 |
-
top_k=top_k,
|
| 183 |
-
)
|
| 184 |
-
|
| 185 |
-
# Try 4: Final fallback - no quarter filter at all
|
| 186 |
-
if not general_chunks:
|
| 187 |
-
general_chunks = retrieve_curriculum_context(
|
| 188 |
-
query=topic,
|
| 189 |
-
subject=subject,
|
| 190 |
-
top_k=top_k,
|
| 191 |
-
)
|
| 192 |
-
|
| 193 |
-
if storage_path and exact_chunks:
|
| 194 |
-
all_chunks = exact_chunks + general_chunks
|
| 195 |
-
seen = set()
|
| 196 |
-
deduped = []
|
| 197 |
-
for c in all_chunks:
|
| 198 |
-
key = f"{c.get('source_file')}:{c.get('page')}:{c.get('content', '')[:60]}"
|
| 199 |
-
if key not in seen:
|
| 200 |
-
seen.add(key)
|
| 201 |
-
deduped.append(c)
|
| 202 |
-
deduped.sort(key=lambda x: x.get("score", 0), reverse=True)
|
| 203 |
-
return deduped[:top_k], "hybrid"
|
| 204 |
-
|
| 205 |
-
return general_chunks, "general"
|
| 206 |
-
|
| 207 |
-
|
| 208 |
def format_retrieved_chunks(curriculum_chunks: list[dict]) -> str:
|
| 209 |
-
|
| 210 |
for i, chunk in enumerate(curriculum_chunks, start=1):
|
| 211 |
-
|
| 212 |
f"{i}. [{chunk.get('source_file')} p.{chunk.get('page')}] "
|
| 213 |
f"({chunk.get('content_domain')}/{chunk.get('chunk_type')}) score={chunk.get('score')}\n"
|
| 214 |
f" Excerpt: {chunk.get('content', '')}"
|
| 215 |
)
|
| 216 |
-
return "\n".join(
|
| 217 |
|
| 218 |
|
| 219 |
-
def summarize_retrieval_confidence(curriculum_chunks: list[dict]) -> Dict[str,
|
| 220 |
if not curriculum_chunks:
|
| 221 |
-
return {"confidence": 0.0, "band": "low"
|
| 222 |
|
| 223 |
-
top_scores = [float(
|
| 224 |
score = sum(top_scores) / max(1, len(top_scores))
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
"introduction": [],
|
| 233 |
-
"key_concepts": [],
|
| 234 |
-
"worked_examples": [],
|
| 235 |
-
"important_notes": [],
|
| 236 |
-
"practice": [],
|
| 237 |
-
"summary": [],
|
| 238 |
-
"assessment": [],
|
| 239 |
-
"general": [],
|
| 240 |
-
}
|
| 241 |
-
domain_priority = {
|
| 242 |
-
"introduction": 1, "key_concepts": 2, "worked_examples": 3,
|
| 243 |
-
"important_notes": 4, "practice": 5, "summary": 6,
|
| 244 |
-
"assessment": 7, "general": 8,
|
| 245 |
-
}
|
| 246 |
-
for chunk in chunks:
|
| 247 |
-
domain = chunk.get("content_domain", "general")
|
| 248 |
-
if domain in sections:
|
| 249 |
-
sections[domain].append(chunk)
|
| 250 |
-
else:
|
| 251 |
-
sections["general"].append(chunk)
|
| 252 |
-
return sections
|
| 253 |
|
| 254 |
|
| 255 |
def build_lesson_prompt(
|
|
@@ -262,57 +129,39 @@ def build_lesson_prompt(
|
|
| 262 |
learner_level: Optional[str],
|
| 263 |
module_unit: Optional[str],
|
| 264 |
curriculum_chunks: list[dict],
|
| 265 |
-
competency_code: Optional[str] = None,
|
| 266 |
) -> str:
|
| 267 |
refs_text = format_retrieved_chunks(curriculum_chunks)
|
| 268 |
-
organized = organize_chunks_by_section(curriculum_chunks)
|
| 269 |
-
|
| 270 |
return (
|
| 271 |
-
"You are a
|
| 272 |
-
"Generate
|
| 273 |
-
"Do NOT invent content. Do NOT add generic motivational text. All content must be grounded in the retrieved excerpts.\n\n"
|
| 274 |
f"Lesson title: {lesson_title}\n"
|
| 275 |
-
f"Competency code: {competency_code or 'n/a'}\n"
|
| 276 |
f"Curriculum competency: {competency}\n"
|
| 277 |
f"Grade level: {grade_level}\n"
|
| 278 |
f"Subject: {subject}\n"
|
| 279 |
f"Quarter: Q{quarter}\n"
|
| 280 |
-
f"Learner level: {learner_level or '
|
| 281 |
f"Module/unit: {module_unit or 'n/a'}\n\n"
|
| 282 |
"[CURRICULUM CONTEXT]\n"
|
| 283 |
f"{refs_text}\n\n"
|
| 284 |
-
"Return
|
| 285 |
-
"
|
| 286 |
-
' "sections": [\n'
|
| 287 |
-
' {"type": "introduction", "title": "Introduction", "content": "..."},\n'
|
| 288 |
-
' {"type": "key_concepts", "title": "Key Concepts", "content": "...", "callouts": [{"type":"important|ti..."}]\n},'
|
| 289 |
-
' {"type": "video", "title": "Video Lesson", "content": "...", "videoId": "", "videoTitle": "", "videoChannel": "", "embedUrl": "", "thumbnailUrl": ""},\n'
|
| 290 |
-
' {"type": "worked_examples", "title": "Worked Examples", "examples": [{"problem":"...","steps":["Step 1: ...","Step 2: ..."],"answer":"..."}]},\n'
|
| 291 |
-
' {"type": "important_notes", "title": "Important Notes", "bulletPoints": ["...","..."]},\n'
|
| 292 |
-
' {"type": "try_it_yourself", "title": "Try It Yourself", "practiceProblems": [{"question":"...","solution":"..."}]},\n'
|
| 293 |
-
' {"type": "summary", "title": "Summary", "content": "..."}\n'
|
| 294 |
-
" ],\n"
|
| 295 |
-
' "needsReview": false\n'
|
| 296 |
-
"}\n\n"
|
| 297 |
"Rules:\n"
|
| 298 |
-
"-
|
| 299 |
-
"-
|
| 300 |
-
"-
|
| 301 |
-
"-
|
| 302 |
-
"-
|
| 303 |
-
"- Do not use placeholder text like 'placeholder' or 'example text'\n"
|
| 304 |
-
"- Do not fabricate worked examples - use actual curriculum content\n"
|
| 305 |
)
|
| 306 |
|
| 307 |
|
| 308 |
def build_problem_generation_prompt(topic: str, difficulty: str, curriculum_chunks: list[dict]) -> str:
|
| 309 |
-
|
| 310 |
for i, chunk in enumerate(curriculum_chunks, start=1):
|
| 311 |
-
|
| 312 |
f"{i}. [{chunk.get('source_file')} p.{chunk.get('page')}] "
|
| 313 |
f"({chunk.get('content_domain')}/{chunk.get('chunk_type')}) {chunk.get('content', '')}"
|
| 314 |
)
|
| 315 |
-
refs_text = "\n".join(
|
| 316 |
|
| 317 |
return (
|
| 318 |
"Generate one practice problem strictly aligned to the retrieved DepEd competency scope.\n"
|
|
@@ -335,7 +184,7 @@ def build_analysis_curriculum_context(weak_topics: list[str], subject: str) -> l
|
|
| 335 |
top_k=2,
|
| 336 |
)
|
| 337 |
for row in rows:
|
| 338 |
-
key = f"{row.get('source_file')}::{row.get('page')}::{row.get('content'
|
| 339 |
if key not in dedup:
|
| 340 |
dedup[key] = row
|
| 341 |
-
return list(dedup.values())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from typing import Dict, List, Optional
|
| 4 |
+
|
| 5 |
+
from .vectorstore_loader import get_vectorstore_components
|
| 6 |
|
| 7 |
|
| 8 |
def _to_where(
|
|
|
|
| 10 |
quarter: Optional[int] = None,
|
| 11 |
content_domain: Optional[str] = None,
|
| 12 |
chunk_type: Optional[str] = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
) -> Optional[Dict[str, object]]:
|
| 14 |
clauses = []
|
| 15 |
if subject:
|
|
|
|
| 20 |
clauses.append({"content_domain": {"$eq": content_domain}})
|
| 21 |
if chunk_type:
|
| 22 |
clauses.append({"chunk_type": {"$eq": chunk_type}})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
if not clauses:
|
| 24 |
return None
|
| 25 |
if len(clauses) == 1:
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
def _distance_to_score(distance: float) -> float:
|
| 31 |
+
# Chroma returns smaller distance for better matches. Map to (0,1].
|
| 32 |
return round(1.0 / (1.0 + max(distance, 0.0)), 4)
|
| 33 |
|
| 34 |
|
|
|
|
| 38 |
quarter: int | None = None,
|
| 39 |
content_domain: str | None = None,
|
| 40 |
chunk_type: str | None = None,
|
| 41 |
+
top_k: int = 5,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
) -> list[dict]:
|
|
|
|
|
|
|
| 43 |
_, collection, embedder = get_vectorstore_components()
|
| 44 |
+
where = _to_where(subject, quarter, content_domain, chunk_type)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
+
query_embedding = embedder.encode(query).tolist()
|
| 47 |
result = collection.query(
|
| 48 |
query_embeddings=[query_embedding],
|
| 49 |
n_results=max(1, top_k),
|
|
|
|
| 59 |
for idx, content in enumerate(documents):
|
| 60 |
md = metadatas[idx] if idx < len(metadatas) and isinstance(metadatas[idx], dict) else {}
|
| 61 |
distance = float(distances[idx]) if idx < len(distances) else 1.0
|
| 62 |
+
rows.append(
|
| 63 |
+
{
|
| 64 |
+
"content": str(content or ""),
|
| 65 |
+
"subject": str(md.get("subject") or "unknown"),
|
| 66 |
+
"quarter": int(md.get("quarter") or 0),
|
| 67 |
+
"content_domain": str(md.get("content_domain") or "unknown"),
|
| 68 |
+
"chunk_type": str(md.get("chunk_type") or "unknown"),
|
| 69 |
+
"source_file": str(md.get("source_file") or ""),
|
| 70 |
+
"page": int(md.get("page") or 0),
|
| 71 |
+
"score": _distance_to_score(distance),
|
| 72 |
+
}
|
| 73 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
+
return rows
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
|
| 78 |
def build_lesson_query(
|
|
|
|
| 93 |
return " | ".join(parts)
|
| 94 |
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
def format_retrieved_chunks(curriculum_chunks: list[dict]) -> str:
|
| 97 |
+
references = []
|
| 98 |
for i, chunk in enumerate(curriculum_chunks, start=1):
|
| 99 |
+
references.append(
|
| 100 |
f"{i}. [{chunk.get('source_file')} p.{chunk.get('page')}] "
|
| 101 |
f"({chunk.get('content_domain')}/{chunk.get('chunk_type')}) score={chunk.get('score')}\n"
|
| 102 |
f" Excerpt: {chunk.get('content', '')}"
|
| 103 |
)
|
| 104 |
+
return "\n".join(references) if references else "No curriculum context retrieved."
|
| 105 |
|
| 106 |
|
| 107 |
+
def summarize_retrieval_confidence(curriculum_chunks: list[dict]) -> Dict[str, float | str]:
|
| 108 |
if not curriculum_chunks:
|
| 109 |
+
return {"confidence": 0.0, "band": "low"}
|
| 110 |
|
| 111 |
+
top_scores = [float(chunk.get("score") or 0.0) for chunk in curriculum_chunks[:5]]
|
| 112 |
score = sum(top_scores) / max(1, len(top_scores))
|
| 113 |
+
if score >= 0.72:
|
| 114 |
+
band = "high"
|
| 115 |
+
elif score >= 0.5:
|
| 116 |
+
band = "medium"
|
| 117 |
+
else:
|
| 118 |
+
band = "low"
|
| 119 |
+
return {"confidence": round(score, 3), "band": band}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
|
| 122 |
def build_lesson_prompt(
|
|
|
|
| 129 |
learner_level: Optional[str],
|
| 130 |
module_unit: Optional[str],
|
| 131 |
curriculum_chunks: list[dict],
|
|
|
|
| 132 |
) -> str:
|
| 133 |
refs_text = format_retrieved_chunks(curriculum_chunks)
|
|
|
|
|
|
|
| 134 |
return (
|
| 135 |
+
"You are a Grade 11-12 DepEd SHS math instructional designer.\n"
|
| 136 |
+
"Generate JSON only. Use ONLY the retrieved curriculum evidence below. Do not invent competencies or content beyond the retrieved scope.\n\n"
|
|
|
|
| 137 |
f"Lesson title: {lesson_title}\n"
|
|
|
|
| 138 |
f"Curriculum competency: {competency}\n"
|
| 139 |
f"Grade level: {grade_level}\n"
|
| 140 |
f"Subject: {subject}\n"
|
| 141 |
f"Quarter: Q{quarter}\n"
|
| 142 |
+
f"Learner level: {learner_level or 'mixed'}\n"
|
| 143 |
f"Module/unit: {module_unit or 'n/a'}\n\n"
|
| 144 |
"[CURRICULUM CONTEXT]\n"
|
| 145 |
f"{refs_text}\n\n"
|
| 146 |
+
"Return JSON with these keys only:\n"
|
| 147 |
+
"lessonTitle, curriculumCompetency, lessonObjective, realWorldHook, explanation, workedExample, guidedPractice, independentPractice, quickAssessment, reflectionPrompt, sourceCitations, needsReview, reviewReason\n\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
"Rules:\n"
|
| 149 |
+
"- Keep the lesson age-appropriate for SHS learners.\n"
|
| 150 |
+
"- Use real Philippine contexts where possible, such as payroll, VAT, discounts, loans, logistics, travel, or school data.\n"
|
| 151 |
+
"- If evidence is thin, set needsReview=true and explain why in reviewReason.\n"
|
| 152 |
+
"- Do not mention unsupported curriculum facts.\n"
|
| 153 |
+
"- sourceCitations should be an array of short citations referencing the retrieved excerpts."
|
|
|
|
|
|
|
| 154 |
)
|
| 155 |
|
| 156 |
|
| 157 |
def build_problem_generation_prompt(topic: str, difficulty: str, curriculum_chunks: list[dict]) -> str:
|
| 158 |
+
references = []
|
| 159 |
for i, chunk in enumerate(curriculum_chunks, start=1):
|
| 160 |
+
references.append(
|
| 161 |
f"{i}. [{chunk.get('source_file')} p.{chunk.get('page')}] "
|
| 162 |
f"({chunk.get('content_domain')}/{chunk.get('chunk_type')}) {chunk.get('content', '')}"
|
| 163 |
)
|
| 164 |
+
refs_text = "\n".join(references) if references else "No curriculum context retrieved."
|
| 165 |
|
| 166 |
return (
|
| 167 |
"Generate one practice problem strictly aligned to the retrieved DepEd competency scope.\n"
|
|
|
|
| 184 |
top_k=2,
|
| 185 |
)
|
| 186 |
for row in rows:
|
| 187 |
+
key = f"{row.get('source_file')}::{row.get('page')}::{row.get('content')[:80]}"
|
| 188 |
if key not in dedup:
|
| 189 |
dedup[key] = row
|
| 190 |
+
return list(dedup.values())
|
rag/firebase_storage_loader.py
DELETED
|
@@ -1,175 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Firebase Storage PDF loader for curriculum ingestion.
|
| 3 |
-
Downloads PDFs from Firebase Storage and extracts text for ChromaDB indexing.
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
from __future__ import annotations
|
| 7 |
-
|
| 8 |
-
import logging
|
| 9 |
-
import os
|
| 10 |
-
from pathlib import Path
|
| 11 |
-
from typing import Dict, List, Optional, Tuple
|
| 12 |
-
|
| 13 |
-
logger = logging.getLogger("mathpulse.fb_storage_loader")
|
| 14 |
-
|
| 15 |
-
_FIREBASE_INITIALIZED = False
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def _init_firebase_storage() -> Tuple[any, any]:
|
| 19 |
-
global _FIREBASE_INITIALIZED
|
| 20 |
-
|
| 21 |
-
if _FIREBASE_INITIALIZED:
|
| 22 |
-
try:
|
| 23 |
-
from firebase_admin import storage as fb_storage
|
| 24 |
-
bucket = fb_storage.bucket()
|
| 25 |
-
return fb_storage, bucket
|
| 26 |
-
except Exception as e:
|
| 27 |
-
logger.warning("Firebase storage unavailable: %s", e)
|
| 28 |
-
_FIREBASE_INITIALIZED = False
|
| 29 |
-
return None, None
|
| 30 |
-
|
| 31 |
-
try:
|
| 32 |
-
import firebase_admin
|
| 33 |
-
from firebase_admin import credentials, storage
|
| 34 |
-
except ImportError:
|
| 35 |
-
logger.warning("firebase_admin not installed")
|
| 36 |
-
return None, None
|
| 37 |
-
|
| 38 |
-
if firebase_admin._apps:
|
| 39 |
-
_FIREBASE_INITIALIZED = True
|
| 40 |
-
try:
|
| 41 |
-
bucket = storage.bucket()
|
| 42 |
-
return storage, bucket
|
| 43 |
-
except Exception as e:
|
| 44 |
-
logger.warning("Firebase storage bucket unavailable: %s", e)
|
| 45 |
-
return None, None
|
| 46 |
-
|
| 47 |
-
sa_json = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON")
|
| 48 |
-
sa_file = os.getenv("FIREBASE_SERVICE_ACCOUNT_FILE")
|
| 49 |
-
bucket_name = os.getenv("FIREBASE_STORAGE_BUCKET", "mathpulse-ai-2026.firebasestorage.app")
|
| 50 |
-
|
| 51 |
-
try:
|
| 52 |
-
if sa_json:
|
| 53 |
-
import json as _json
|
| 54 |
-
creds = credentials.Certificate(_json.loads(sa_json))
|
| 55 |
-
elif sa_file and Path(sa_file).exists():
|
| 56 |
-
creds = credentials.Certificate(sa_file)
|
| 57 |
-
else:
|
| 58 |
-
creds = credentials.ApplicationDefault()
|
| 59 |
-
|
| 60 |
-
firebase_admin.initialize_app(creds, {"storageBucket": bucket_name})
|
| 61 |
-
_FIREBASE_INITIALIZED = True
|
| 62 |
-
bucket = storage.bucket()
|
| 63 |
-
return storage, bucket
|
| 64 |
-
except Exception as e:
|
| 65 |
-
logger.warning("Firebase init failed: %s", e)
|
| 66 |
-
return None, None
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
def download_pdf_from_storage(storage_path: str, dest_path: Optional[str] = None) -> Optional[bytes]:
|
| 70 |
-
"""Download a PDF from Firebase Storage and return its bytes."""
|
| 71 |
-
_, bucket = _init_firebase_storage()
|
| 72 |
-
if bucket is None:
|
| 73 |
-
logger.warning("Firebase Storage not available, skipping download")
|
| 74 |
-
return None
|
| 75 |
-
|
| 76 |
-
try:
|
| 77 |
-
blob = bucket.blob(storage_path)
|
| 78 |
-
if not blob.exists():
|
| 79 |
-
logger.warning("Blob does not exist: %s", storage_path)
|
| 80 |
-
return None
|
| 81 |
-
bytes_data = blob.download_as_bytes()
|
| 82 |
-
logger.info("Downloaded %s (%d bytes)", storage_path, len(bytes_data))
|
| 83 |
-
|
| 84 |
-
if dest_path:
|
| 85 |
-
Path(dest_path).parent.mkdir(parents=True, exist_ok=True)
|
| 86 |
-
with open(dest_path, "wb") as f:
|
| 87 |
-
f.write(bytes_data)
|
| 88 |
-
logger.info("Saved to %s", dest_path)
|
| 89 |
-
|
| 90 |
-
return bytes_data
|
| 91 |
-
except Exception as e:
|
| 92 |
-
logger.error("Failed to download %s: %s", storage_path, e)
|
| 93 |
-
return None
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
def list_curriculum_blobs(prefix: str = "curriculum/") -> List[Dict[str, str]]:
|
| 97 |
-
"""List all blobs under a prefix in Firebase Storage."""
|
| 98 |
-
_, bucket = _init_firebase_storage()
|
| 99 |
-
if bucket is None:
|
| 100 |
-
return []
|
| 101 |
-
|
| 102 |
-
blobs = bucket.list_blobs(prefix=prefix)
|
| 103 |
-
result = []
|
| 104 |
-
for blob in blobs:
|
| 105 |
-
if blob.name.endswith(".pdf"):
|
| 106 |
-
result.append({
|
| 107 |
-
"name": blob.name,
|
| 108 |
-
"size": blob.size,
|
| 109 |
-
"updated": str(blob.updated) if blob.updated else None,
|
| 110 |
-
"download_url": f"https://storage.googleapis.com/{bucket.name}/{blob.name}",
|
| 111 |
-
})
|
| 112 |
-
return result
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
# NOTE: Curriculum guide PDFs (shaping papers) are stored in Firebase Storage
|
| 116 |
-
# for system reference but are NOT included in RAG ingestion because they
|
| 117 |
-
# contain only learning objectives and course descriptions — insufficient
|
| 118 |
-
# content for lesson generation (typically <10 chunks each).
|
| 119 |
-
#
|
| 120 |
-
# Only SDO teaching modules (full lesson content with examples and problems)
|
| 121 |
-
# are included in the RAG pipeline.
|
| 122 |
-
|
| 123 |
-
PDF_METADATA: Dict[str, dict] = {
|
| 124 |
-
# General Mathematics Q1 — SDO Navotas teaching module (100 pages, ~117k chars)
|
| 125 |
-
"curriculum/gen_math_sdo/SDO_Navotas_Gen.Math_SHS_1stSem.FV.pdf": {
|
| 126 |
-
"subject": "General Mathematics",
|
| 127 |
-
"subjectId": "gen-math",
|
| 128 |
-
"type": "sdo_module",
|
| 129 |
-
"content_domain": "general",
|
| 130 |
-
"quarter": 1,
|
| 131 |
-
"storage_path": "curriculum/gen_math_sdo/SDO_Navotas_Gen.Math_SHS_1stSem.FV.pdf",
|
| 132 |
-
},
|
| 133 |
-
# General Mathematics Q2 — Interest & Annuities modules (~27-35 pages each)
|
| 134 |
-
"curriculum/general_math/genmath_q2_mod1_simpleandcompoundinterests_v2.pdf": {
|
| 135 |
-
"subject": "General Mathematics",
|
| 136 |
-
"subjectId": "gen-math",
|
| 137 |
-
"type": "sdo_module",
|
| 138 |
-
"content_domain": "general",
|
| 139 |
-
"quarter": 2,
|
| 140 |
-
"storage_path": "curriculum/general_math/genmath_q2_mod1_simpleandcompoundinterests_v2.pdf",
|
| 141 |
-
},
|
| 142 |
-
"curriculum/general_math/genmath_q2_mod2_interestmaturityfutureandpresentvaluesinsimpleandcompoundinterests_v2.pdf": {
|
| 143 |
-
"subject": "General Mathematics",
|
| 144 |
-
"subjectId": "gen-math",
|
| 145 |
-
"type": "sdo_module",
|
| 146 |
-
"content_domain": "general",
|
| 147 |
-
"quarter": 2,
|
| 148 |
-
"storage_path": "curriculum/general_math/genmath_q2_mod2_interestmaturityfutureandpresentvaluesinsimpleandcompoundinterests_v2.pdf",
|
| 149 |
-
},
|
| 150 |
-
"curriculum/general_math/genmath_q2_mod3_SolvingProblemsInvolvingSimpleandCompoundInterest_v2.pdf": {
|
| 151 |
-
"subject": "General Mathematics",
|
| 152 |
-
"subjectId": "gen-math",
|
| 153 |
-
"type": "sdo_module",
|
| 154 |
-
"content_domain": "general",
|
| 155 |
-
"quarter": 2,
|
| 156 |
-
"storage_path": "curriculum/general_math/genmath_q2_mod3_SolvingProblemsInvolvingSimpleandCompoundInterest_v2.pdf",
|
| 157 |
-
},
|
| 158 |
-
"curriculum/general_math/genmath_q2_mod4_simpleandgeneralannuities_v2.pdf": {
|
| 159 |
-
"subject": "General Mathematics",
|
| 160 |
-
"subjectId": "gen-math",
|
| 161 |
-
"type": "sdo_module",
|
| 162 |
-
"content_domain": "general",
|
| 163 |
-
"quarter": 2,
|
| 164 |
-
"storage_path": "curriculum/general_math/genmath_q2_mod4_simpleandgeneralannuities_v2.pdf",
|
| 165 |
-
},
|
| 166 |
-
# Statistics and Probability — Full textbook (331 pages, ~607k chars)
|
| 167 |
-
"curriculum/stat_prob/Full.pdf": {
|
| 168 |
-
"subject": "Statistics and Probability",
|
| 169 |
-
"subjectId": "stats-prob",
|
| 170 |
-
"type": "sdo_module",
|
| 171 |
-
"content_domain": "statistics",
|
| 172 |
-
"quarter": 1,
|
| 173 |
-
"storage_path": "curriculum/stat_prob/Full.pdf",
|
| 174 |
-
},
|
| 175 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
rag/pdf_ingestion.py
DELETED
|
@@ -1,368 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
PDF Ingestion Module for Quiz Battle RAG Question Bank.
|
| 3 |
-
|
| 4 |
-
Ingests PDFs from Firebase Storage, extracts text, chunks content,
|
| 5 |
-
generates embeddings, calls DeepSeek to produce base questions,
|
| 6 |
-
and stores results in Firestore.
|
| 7 |
-
"""
|
| 8 |
-
|
| 9 |
-
import asyncio
|
| 10 |
-
import hashlib
|
| 11 |
-
import io
|
| 12 |
-
import json
|
| 13 |
-
import logging
|
| 14 |
-
import os
|
| 15 |
-
import random
|
| 16 |
-
from dataclasses import dataclass
|
| 17 |
-
from datetime import datetime, timezone
|
| 18 |
-
from typing import Optional
|
| 19 |
-
|
| 20 |
-
from google.cloud.firestore import Client
|
| 21 |
-
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 22 |
-
from sentence_transformers import SentenceTransformer
|
| 23 |
-
import pypdf
|
| 24 |
-
|
| 25 |
-
from rag.firebase_storage_loader import _init_firebase_storage
|
| 26 |
-
from services.ai_client import get_deepseek_client, CHAT_MODEL
|
| 27 |
-
|
| 28 |
-
logger = logging.getLogger(__name__)
|
| 29 |
-
|
| 30 |
-
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "all-MiniLM-L6-v2")
|
| 31 |
-
DEFAULT_FIREBASE_PROJECT = os.getenv("FIREBASE_AUTH_PROJECT_ID", "mathpulse-ai-2026")
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
@dataclass
|
| 35 |
-
class IngestionResult:
|
| 36 |
-
"""Result of a PDF ingestion operation."""
|
| 37 |
-
|
| 38 |
-
filename: str
|
| 39 |
-
processed: bool
|
| 40 |
-
question_count: int
|
| 41 |
-
grade_level: int
|
| 42 |
-
topic: str
|
| 43 |
-
storage_path: str
|
| 44 |
-
timestamp: datetime
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def _extract_filename(storage_path: str) -> str:
|
| 48 |
-
"""Extract filename from a Firebase Storage path."""
|
| 49 |
-
return storage_path.split("/")[-1]
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
def _generate_chunk_id(source_chunk_id: str, question_text: str) -> str:
|
| 53 |
-
"""Generate a unique document ID for a question."""
|
| 54 |
-
return hashlib.md5(f"{source_chunk_id}:{question_text}".encode()).hexdigest()
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
def _strip_json_fences(text: str) -> str:
|
| 58 |
-
"""Strip markdown JSON fences from text."""
|
| 59 |
-
text = text.strip()
|
| 60 |
-
if text.startswith("```json"):
|
| 61 |
-
text = text[7:]
|
| 62 |
-
if text.startswith("```"):
|
| 63 |
-
text = text[3:]
|
| 64 |
-
if text.endswith("```"):
|
| 65 |
-
text = text[:-3]
|
| 66 |
-
return text.strip()
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
async def _generate_questions_for_chunk(
|
| 70 |
-
chunk_text: str,
|
| 71 |
-
chunk_id: str,
|
| 72 |
-
topic: str,
|
| 73 |
-
grade_level: int,
|
| 74 |
-
deepseek_client,
|
| 75 |
-
) -> list[dict]:
|
| 76 |
-
"""Call DeepSeek to generate MCQs for a text chunk."""
|
| 77 |
-
system_prompt = (
|
| 78 |
-
"You are a DepEd-aligned math question generator for Filipino students. "
|
| 79 |
-
"Given a curriculum excerpt, generate 5 multiple-choice questions. "
|
| 80 |
-
"Return ONLY a JSON array. No markdown, no explanation."
|
| 81 |
-
)
|
| 82 |
-
|
| 83 |
-
user_prompt = f"""Given this curriculum excerpt:
|
| 84 |
-
<chunk>
|
| 85 |
-
{chunk_text}
|
| 86 |
-
</chunk>
|
| 87 |
-
|
| 88 |
-
Generate 5 multiple-choice questions. For each question output JSON:
|
| 89 |
-
{{
|
| 90 |
-
"question": "...",
|
| 91 |
-
"choices": ["A) ...", "B) ...", "C) ...", "D) ..."],
|
| 92 |
-
"correct_answer": "A",
|
| 93 |
-
"explanation": "...",
|
| 94 |
-
"topic": "{topic}",
|
| 95 |
-
"difficulty": "easy|medium|hard",
|
| 96 |
-
"grade_level": {grade_level},
|
| 97 |
-
"source_chunk_id": "{chunk_id}"
|
| 98 |
-
}}
|
| 99 |
-
Return a JSON array only, no extra text."""
|
| 100 |
-
|
| 101 |
-
try:
|
| 102 |
-
response = deepseek_client.chat.completions.create(
|
| 103 |
-
model=CHAT_MODEL,
|
| 104 |
-
messages=[
|
| 105 |
-
{"role": "system", "content": system_prompt},
|
| 106 |
-
{"role": "user", "content": user_prompt},
|
| 107 |
-
],
|
| 108 |
-
temperature=0.7,
|
| 109 |
-
)
|
| 110 |
-
raw_response = response.choices[0].message.content
|
| 111 |
-
clean_response = _strip_json_fences(raw_response)
|
| 112 |
-
questions = json.loads(clean_response)
|
| 113 |
-
return questions if isinstance(questions, list) else []
|
| 114 |
-
except json.JSONDecodeError as e:
|
| 115 |
-
logger.error(f"Failed to parse DeepSeek response as JSON for chunk {chunk_id}: {e}")
|
| 116 |
-
return []
|
| 117 |
-
except Exception as e:
|
| 118 |
-
logger.error(f"Error calling DeepSeek for chunk {chunk_id}: {e}")
|
| 119 |
-
return []
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
def _chunk_text(text: str) -> list[str]:
|
| 123 |
-
"""Split text into chunks using RecursiveCharacterTextSplitter."""
|
| 124 |
-
splitter = RecursiveCharacterTextSplitter(
|
| 125 |
-
chunk_size=500,
|
| 126 |
-
chunk_overlap=50,
|
| 127 |
-
length_function=len,
|
| 128 |
-
separators=["\n\n", "\n", " ", ""],
|
| 129 |
-
)
|
| 130 |
-
return splitter.split_text(text)
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
def _extract_pdf_text(pdf_bytes: bytes) -> str:
|
| 134 |
-
"""Extract text from PDF bytes using pypdf."""
|
| 135 |
-
reader = pypdf.PdfReader(io.BytesIO(pdf_bytes))
|
| 136 |
-
text_parts = []
|
| 137 |
-
for page in reader.pages:
|
| 138 |
-
text_parts.append(page.extract_text())
|
| 139 |
-
return "\n".join(text_parts)
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
async def _save_questions_batch(
|
| 143 |
-
firestore_client: Client,
|
| 144 |
-
questions: list[dict],
|
| 145 |
-
grade_level: int,
|
| 146 |
-
topic: str,
|
| 147 |
-
) -> int:
|
| 148 |
-
"""Save questions to Firestore using batch writes. Returns count saved."""
|
| 149 |
-
batch = firestore_client.batch()
|
| 150 |
-
question_count = 0
|
| 151 |
-
|
| 152 |
-
for question in questions:
|
| 153 |
-
doc_id = question.get("id") or _generate_chunk_id(
|
| 154 |
-
question.get("source_chunk_id", ""),
|
| 155 |
-
question.get("question", ""),
|
| 156 |
-
)
|
| 157 |
-
doc_ref = firestore_client.collection("question_bank").document(
|
| 158 |
-
str(grade_level)
|
| 159 |
-
).collection(topic).document("questions").collection("questions").document(doc_id)
|
| 160 |
-
|
| 161 |
-
doc_data = {
|
| 162 |
-
"question": question.get("question", ""),
|
| 163 |
-
"choices": question.get("choices", []),
|
| 164 |
-
"correct_answer": question.get("correct_answer", ""),
|
| 165 |
-
"explanation": question.get("explanation", ""),
|
| 166 |
-
"topic": question.get("topic", topic),
|
| 167 |
-
"difficulty": question.get("difficulty", "medium"),
|
| 168 |
-
"grade_level": question.get("grade_level", grade_level),
|
| 169 |
-
"source_chunk_id": question.get("source_chunk_id", ""),
|
| 170 |
-
"random_seed": random.random(),
|
| 171 |
-
"created_at": datetime.now(timezone.utc),
|
| 172 |
-
}
|
| 173 |
-
batch.set(doc_ref, doc_data)
|
| 174 |
-
question_count += 1
|
| 175 |
-
|
| 176 |
-
if question_count % 500 == 0:
|
| 177 |
-
await batch.commit()
|
| 178 |
-
batch = firestore_client.batch()
|
| 179 |
-
|
| 180 |
-
await batch.commit()
|
| 181 |
-
return question_count
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
async def _save_embeddings_batch(
|
| 185 |
-
firestore_client: Client,
|
| 186 |
-
chunks: list[dict],
|
| 187 |
-
filename: str,
|
| 188 |
-
) -> int:
|
| 189 |
-
"""Save chunk embeddings to Firestore. Returns count saved."""
|
| 190 |
-
batch = firestore_client.batch()
|
| 191 |
-
count = 0
|
| 192 |
-
|
| 193 |
-
for chunk in chunks:
|
| 194 |
-
chunk_id = chunk["id"]
|
| 195 |
-
doc_ref = firestore_client.collection("question_bank_embeddings").document(chunk_id)
|
| 196 |
-
doc_data = {
|
| 197 |
-
"chunk_id": chunk_id,
|
| 198 |
-
"text": chunk["text"],
|
| 199 |
-
"embedding": chunk["embedding"],
|
| 200 |
-
"filename": filename,
|
| 201 |
-
"created_at": datetime.now(timezone.utc),
|
| 202 |
-
}
|
| 203 |
-
batch.set(doc_ref, doc_data)
|
| 204 |
-
count += 1
|
| 205 |
-
|
| 206 |
-
if count % 500 == 0:
|
| 207 |
-
await batch.commit()
|
| 208 |
-
batch = firestore_client.batch()
|
| 209 |
-
|
| 210 |
-
await batch.commit()
|
| 211 |
-
return count
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
async def _save_processing_manifest(
|
| 215 |
-
firestore_client: Client,
|
| 216 |
-
filename: str,
|
| 217 |
-
question_count: int,
|
| 218 |
-
chunk_count: int,
|
| 219 |
-
grade_level: int,
|
| 220 |
-
topic: str,
|
| 221 |
-
storage_path: str,
|
| 222 |
-
) -> None:
|
| 223 |
-
"""Save processing manifest to Firestore."""
|
| 224 |
-
doc_ref = firestore_client.collection("pdf_processing_status").document(filename)
|
| 225 |
-
doc_data = {
|
| 226 |
-
"filename": filename,
|
| 227 |
-
"question_count": question_count,
|
| 228 |
-
"chunk_count": chunk_count,
|
| 229 |
-
"grade_level": grade_level,
|
| 230 |
-
"topic": topic,
|
| 231 |
-
"storage_path": storage_path,
|
| 232 |
-
"processed_at": datetime.now(timezone.utc),
|
| 233 |
-
"status": "completed",
|
| 234 |
-
}
|
| 235 |
-
await doc_ref.set(doc_data)
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
async def ingest_pdf(
|
| 239 |
-
storage_path: str,
|
| 240 |
-
grade_level: int,
|
| 241 |
-
topic: str,
|
| 242 |
-
force_reingest: bool = False,
|
| 243 |
-
) -> IngestionResult:
|
| 244 |
-
"""
|
| 245 |
-
Ingest a PDF from Firebase Storage, generate questions, and store in Firestore.
|
| 246 |
-
|
| 247 |
-
Args:
|
| 248 |
-
storage_path: Path to PDF in Firebase Storage (e.g., "rag-pdfs/filename.pdf")
|
| 249 |
-
grade_level: Grade level (11 or 12)
|
| 250 |
-
topic: Topic identifier for the questions
|
| 251 |
-
force_reingest: If True, reprocess even if already processed
|
| 252 |
-
|
| 253 |
-
Returns:
|
| 254 |
-
IngestionResult with processing summary
|
| 255 |
-
"""
|
| 256 |
-
filename = _extract_filename(storage_path)
|
| 257 |
-
project_id = os.getenv("FIREBASE_AUTH_PROJECT_ID", DEFAULT_FIREBASE_PROJECT)
|
| 258 |
-
firestore_client = Client(project=project_id)
|
| 259 |
-
|
| 260 |
-
# Step 1: Check if already processed
|
| 261 |
-
if not force_reingest:
|
| 262 |
-
status_ref = firestore_client.collection("pdf_processing_status").document(filename)
|
| 263 |
-
status_doc = await status_ref.get()
|
| 264 |
-
if status_doc.exists:
|
| 265 |
-
logger.info(f"PDF {filename} already processed, skipping (use force_reingest=True to override)")
|
| 266 |
-
data = status_doc.to_dict() or {}
|
| 267 |
-
return IngestionResult(
|
| 268 |
-
filename=filename,
|
| 269 |
-
processed=True,
|
| 270 |
-
question_count=data.get("question_count", 0),
|
| 271 |
-
grade_level=data.get("grade_level", grade_level),
|
| 272 |
-
topic=data.get("topic", topic),
|
| 273 |
-
storage_path=data.get("storage_path", storage_path),
|
| 274 |
-
timestamp=data.get("timestamp", datetime.now(timezone.utc)),
|
| 275 |
-
)
|
| 276 |
-
|
| 277 |
-
# Step 2: Download PDF from Firebase Storage
|
| 278 |
-
try:
|
| 279 |
-
_, bucket = _init_firebase_storage()
|
| 280 |
-
blob = bucket.blob(storage_path)
|
| 281 |
-
pdf_bytes = blob.download_as_bytes()
|
| 282 |
-
except Exception as e:
|
| 283 |
-
logger.error(f"Failed to download PDF from Firebase Storage: {e}")
|
| 284 |
-
return IngestionResult(
|
| 285 |
-
filename=filename,
|
| 286 |
-
processed=False,
|
| 287 |
-
question_count=0,
|
| 288 |
-
grade_level=grade_level,
|
| 289 |
-
topic=topic,
|
| 290 |
-
storage_path=storage_path,
|
| 291 |
-
timestamp=datetime.now(timezone.utc),
|
| 292 |
-
)
|
| 293 |
-
|
| 294 |
-
# Step 3: Extract text from PDF
|
| 295 |
-
try:
|
| 296 |
-
text = _extract_pdf_text(pdf_bytes)
|
| 297 |
-
except Exception as e:
|
| 298 |
-
logger.error(f"Failed to extract text from PDF: {e}")
|
| 299 |
-
return IngestionResult(
|
| 300 |
-
filename=filename,
|
| 301 |
-
processed=False,
|
| 302 |
-
question_count=0,
|
| 303 |
-
grade_level=grade_level,
|
| 304 |
-
topic=topic,
|
| 305 |
-
storage_path=storage_path,
|
| 306 |
-
timestamp=datetime.now(timezone.utc),
|
| 307 |
-
)
|
| 308 |
-
|
| 309 |
-
# Step 4: Chunk text
|
| 310 |
-
chunks = _chunk_text(text)
|
| 311 |
-
|
| 312 |
-
# Step 5: Generate embeddings
|
| 313 |
-
embedding_model = SentenceTransformer(EMBEDDING_MODEL)
|
| 314 |
-
chunk_ids = []
|
| 315 |
-
chunk_data = []
|
| 316 |
-
|
| 317 |
-
for i, chunk_text in enumerate(chunks):
|
| 318 |
-
chunk_id = hashlib.md5(f"{filename}:{i}:{chunk_text[:100]}".encode()).hexdigest()
|
| 319 |
-
embedding = embedding_model.encode(chunk_text).tolist()
|
| 320 |
-
chunk_ids.append(chunk_id)
|
| 321 |
-
chunk_data.append({
|
| 322 |
-
"id": chunk_id,
|
| 323 |
-
"text": chunk_text,
|
| 324 |
-
"embedding": embedding,
|
| 325 |
-
})
|
| 326 |
-
|
| 327 |
-
# Step 6: Initialize DeepSeek client
|
| 328 |
-
deepseek_client = get_deepseek_client()
|
| 329 |
-
|
| 330 |
-
# Step 7: Generate questions for each chunk
|
| 331 |
-
all_questions = []
|
| 332 |
-
for i, chunk_text in enumerate(chunks):
|
| 333 |
-
chunk_id = chunk_ids[i]
|
| 334 |
-
questions = await _generate_questions_for_chunk(
|
| 335 |
-
chunk_text, chunk_id, topic, grade_level, deepseek_client
|
| 336 |
-
)
|
| 337 |
-
for q in questions:
|
| 338 |
-
q["id"] = _generate_chunk_id(chunk_id, q.get("question", ""))
|
| 339 |
-
all_questions.extend(questions)
|
| 340 |
-
|
| 341 |
-
# Step 8: Save questions to Firestore
|
| 342 |
-
question_count = await _save_questions_batch(
|
| 343 |
-
firestore_client, all_questions, grade_level, topic
|
| 344 |
-
)
|
| 345 |
-
|
| 346 |
-
# Step 9: Save embeddings to Firestore
|
| 347 |
-
await _save_embeddings_batch(firestore_client, chunk_data, filename)
|
| 348 |
-
|
| 349 |
-
# Step 10: Save manifest to Firestore
|
| 350 |
-
await _save_processing_manifest(
|
| 351 |
-
firestore_client, filename, question_count, len(chunks),
|
| 352 |
-
grade_level, topic, storage_path
|
| 353 |
-
)
|
| 354 |
-
|
| 355 |
-
logger.info(
|
| 356 |
-
f"Completed ingestion for {filename}: {question_count} questions, "
|
| 357 |
-
f"{len(chunks)} chunks"
|
| 358 |
-
)
|
| 359 |
-
|
| 360 |
-
return IngestionResult(
|
| 361 |
-
filename=filename,
|
| 362 |
-
processed=True,
|
| 363 |
-
question_count=question_count,
|
| 364 |
-
grade_level=grade_level,
|
| 365 |
-
topic=topic,
|
| 366 |
-
storage_path=storage_path,
|
| 367 |
-
timestamp=datetime.now(timezone.utc),
|
| 368 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
rag/vectorstore_loader.py
CHANGED
|
@@ -12,12 +12,6 @@ _VECTORSTORE_LOCK = Lock()
|
|
| 12 |
_VECTORSTORE_SINGLETON: Tuple[Any, Any, SentenceTransformer] | None = None
|
| 13 |
|
| 14 |
|
| 15 |
-
def reset_vectorstore_singleton() -> None:
|
| 16 |
-
global _VECTORSTORE_SINGLETON
|
| 17 |
-
with _VECTORSTORE_LOCK:
|
| 18 |
-
_VECTORSTORE_SINGLETON = None
|
| 19 |
-
|
| 20 |
-
|
| 21 |
def _resolve_vectorstore_dir() -> Path:
|
| 22 |
raw = os.getenv("CURRICULUM_VECTORSTORE_DIR", "datasets/vectorstore")
|
| 23 |
path = Path(raw)
|
|
@@ -34,7 +28,7 @@ def _resolve_vectorstore_dir() -> Path:
|
|
| 34 |
|
| 35 |
def get_vectorstore_components(
|
| 36 |
collection_name: str = "curriculum_chunks",
|
| 37 |
-
model_name: str = "BAAI/bge-
|
| 38 |
):
|
| 39 |
global _VECTORSTORE_SINGLETON
|
| 40 |
if _VECTORSTORE_SINGLETON is None:
|
|
@@ -43,10 +37,7 @@ def get_vectorstore_components(
|
|
| 43 |
vectorstore_dir = _resolve_vectorstore_dir()
|
| 44 |
vectorstore_dir.mkdir(parents=True, exist_ok=True)
|
| 45 |
client = chromadb.PersistentClient(path=str(vectorstore_dir))
|
| 46 |
-
collection = client.get_or_create_collection(
|
| 47 |
-
name=collection_name,
|
| 48 |
-
metadata={"hnsw:space": "cosine"},
|
| 49 |
-
)
|
| 50 |
embedder = SentenceTransformer(model_name)
|
| 51 |
_VECTORSTORE_SINGLETON = (client, collection, embedder)
|
| 52 |
return _VECTORSTORE_SINGLETON
|
|
|
|
| 12 |
_VECTORSTORE_SINGLETON: Tuple[Any, Any, SentenceTransformer] | None = None
|
| 13 |
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
def _resolve_vectorstore_dir() -> Path:
|
| 16 |
raw = os.getenv("CURRICULUM_VECTORSTORE_DIR", "datasets/vectorstore")
|
| 17 |
path = Path(raw)
|
|
|
|
| 28 |
|
| 29 |
def get_vectorstore_components(
|
| 30 |
collection_name: str = "curriculum_chunks",
|
| 31 |
+
model_name: str = "BAAI/bge-small-en-v1.5",
|
| 32 |
):
|
| 33 |
global _VECTORSTORE_SINGLETON
|
| 34 |
if _VECTORSTORE_SINGLETON is None:
|
|
|
|
| 37 |
vectorstore_dir = _resolve_vectorstore_dir()
|
| 38 |
vectorstore_dir.mkdir(parents=True, exist_ok=True)
|
| 39 |
client = chromadb.PersistentClient(path=str(vectorstore_dir))
|
| 40 |
+
collection = client.get_or_create_collection(name=collection_name)
|
|
|
|
|
|
|
|
|
|
| 41 |
embedder = SentenceTransformer(model_name)
|
| 42 |
_VECTORSTORE_SINGLETON = (client, collection, embedder)
|
| 43 |
return _VECTORSTORE_SINGLETON
|
requirements.txt
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
fastapi>=0.104.0
|
| 2 |
uvicorn[standard]>=0.24.0
|
| 3 |
-
openai>=1.0.0
|
| 4 |
huggingface-hub>=0.31.0
|
| 5 |
requests>=2.31.0
|
| 6 |
pandas==2.2.3
|
|
@@ -20,10 +19,3 @@ numpy==2.2.1
|
|
| 20 |
firebase-admin>=6.2.0
|
| 21 |
redis[hiredis]>=5.0.0
|
| 22 |
PyYAML>=6.0.0
|
| 23 |
-
mypy>=1.20.0
|
| 24 |
-
pytest>=9.0.0
|
| 25 |
-
pytest-asyncio>=0.23.0
|
| 26 |
-
google-api-python-client>=2.0.0
|
| 27 |
-
pypdf>=4.0.0
|
| 28 |
-
slowapi>=0.1.0
|
| 29 |
-
limits>=3.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
|
|
|
|
| 19 |
firebase-admin>=6.2.0
|
| 20 |
redis[hiredis]>=5.0.0
|
| 21 |
PyYAML>=6.0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
routes/admin_model_routes.py
DELETED
|
@@ -1,67 +0,0 @@
|
|
| 1 |
-
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 2 |
-
from pydantic import BaseModel
|
| 3 |
-
from services.inference_client import (
|
| 4 |
-
set_runtime_model_profile, set_runtime_model_override,
|
| 5 |
-
reset_runtime_overrides, get_current_runtime_config, _MODEL_PROFILES,
|
| 6 |
-
)
|
| 7 |
-
|
| 8 |
-
router = APIRouter(prefix="/api/admin/model-config", tags=["admin"])
|
| 9 |
-
|
| 10 |
-
ALLOWED_OVERRIDE_KEYS = {
|
| 11 |
-
"INFERENCE_MODEL_ID", "INFERENCE_CHAT_MODEL_ID",
|
| 12 |
-
"HF_QUIZ_MODEL_ID", "HF_RAG_MODEL_ID", "INFERENCE_LOCK_MODEL_ID",
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
def require_admin(request: Request):
|
| 17 |
-
user = getattr(request.state, "user", None)
|
| 18 |
-
if user is None:
|
| 19 |
-
raise HTTPException(status_code=401, detail="Authentication required")
|
| 20 |
-
if user.role != "admin":
|
| 21 |
-
raise HTTPException(status_code=403, detail="Admin access required")
|
| 22 |
-
return user
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
class ProfileSwitchRequest(BaseModel):
|
| 26 |
-
profile: str
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
class OverrideRequest(BaseModel):
|
| 30 |
-
key: str
|
| 31 |
-
value: str
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
@router.get("")
|
| 35 |
-
def get_model_config(_admin=Depends(require_admin)):
|
| 36 |
-
return {
|
| 37 |
-
**get_current_runtime_config(),
|
| 38 |
-
"availableProfiles": list(_MODEL_PROFILES.keys()),
|
| 39 |
-
"profileDescriptions": {
|
| 40 |
-
"dev": "deepseek-chat everywhere - fast, $0.14/M input",
|
| 41 |
-
"budget": "deepseek-chat for all tasks - minimal cost",
|
| 42 |
-
"prod": "deepseek-reasoner for RAG, deepseek-chat for chat - best quality",
|
| 43 |
-
},
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
@router.post("/profile")
|
| 48 |
-
def switch_profile(req: ProfileSwitchRequest, _admin=Depends(require_admin)):
|
| 49 |
-
try:
|
| 50 |
-
set_runtime_model_profile(req.profile)
|
| 51 |
-
return {"success": True, "applied": get_current_runtime_config()}
|
| 52 |
-
except ValueError as e:
|
| 53 |
-
raise HTTPException(status_code=400, detail=str(e))
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
@router.post("/override")
|
| 57 |
-
def set_override(req: OverrideRequest, _admin=Depends(require_admin)):
|
| 58 |
-
if req.key not in ALLOWED_OVERRIDE_KEYS:
|
| 59 |
-
raise HTTPException(status_code=400, detail=f"Key '{req.key}' is not overridable.")
|
| 60 |
-
set_runtime_model_override(req.key, req.value)
|
| 61 |
-
return {"success": True, "applied": get_current_runtime_config()}
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
@router.delete("/reset")
|
| 65 |
-
def reset_to_env(_admin=Depends(require_admin)):
|
| 66 |
-
reset_runtime_overrides()
|
| 67 |
-
return {"success": True, "current": get_current_runtime_config()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
routes/admin_routes.py
DELETED
|
@@ -1,87 +0,0 @@
|
|
| 1 |
-
from typing import Optional
|
| 2 |
-
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form, BackgroundTasks
|
| 3 |
-
from pydantic import BaseModel
|
| 4 |
-
import logging
|
| 5 |
-
|
| 6 |
-
from rag.firebase_storage_loader import _init_firebase_storage, PDF_METADATA
|
| 7 |
-
from scripts.ingest_from_storage import ingest_from_firebase_storage
|
| 8 |
-
|
| 9 |
-
logger = logging.getLogger("mathpulse.admin")
|
| 10 |
-
|
| 11 |
-
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
| 12 |
-
|
| 13 |
-
def require_admin(request: Request):
|
| 14 |
-
user = getattr(request.state, "user", None)
|
| 15 |
-
if user is None:
|
| 16 |
-
raise HTTPException(status_code=401, detail="Authentication required")
|
| 17 |
-
if user.role != "admin":
|
| 18 |
-
raise HTTPException(status_code=403, detail="Admin access required")
|
| 19 |
-
return user
|
| 20 |
-
|
| 21 |
-
class ReingestRequest(BaseModel):
|
| 22 |
-
subjectId: Optional[str] = None
|
| 23 |
-
storagePath: Optional[str] = None
|
| 24 |
-
|
| 25 |
-
@router.post("/upload-pdf")
|
| 26 |
-
async def upload_pdf(
|
| 27 |
-
subjectId: str = Form(...),
|
| 28 |
-
subjectName: str = Form(...),
|
| 29 |
-
semester: int = Form(...),
|
| 30 |
-
quarter: int = Form(...),
|
| 31 |
-
file: UploadFile = File(...),
|
| 32 |
-
_admin=Depends(require_admin)
|
| 33 |
-
):
|
| 34 |
-
if not file.filename.endswith('.pdf'):
|
| 35 |
-
raise HTTPException(status_code=400, detail="Only PDF files are allowed.")
|
| 36 |
-
|
| 37 |
-
file_content = await file.read()
|
| 38 |
-
if len(file_content) > 50 * 1024 * 1024:
|
| 39 |
-
raise HTTPException(status_code=400, detail="File size exceeds 50MB limit.")
|
| 40 |
-
|
| 41 |
-
_, bucket = _init_firebase_storage()
|
| 42 |
-
if not bucket:
|
| 43 |
-
raise HTTPException(status_code=500, detail="Firebase storage is not initialized.")
|
| 44 |
-
|
| 45 |
-
storage_path = f"curriculum/{subjectId}/{file.filename}"
|
| 46 |
-
|
| 47 |
-
try:
|
| 48 |
-
blob = bucket.blob(storage_path)
|
| 49 |
-
blob.upload_from_string(file_content, content_type="application/pdf")
|
| 50 |
-
except Exception as e:
|
| 51 |
-
logger.error(f"Failed to upload PDF: {e}")
|
| 52 |
-
raise HTTPException(status_code=500, detail=f"Failed to upload to Firebase Storage: {e}")
|
| 53 |
-
|
| 54 |
-
# Update metadata in memory before reingesting
|
| 55 |
-
PDF_METADATA[storage_path] = {
|
| 56 |
-
"subject": subjectName,
|
| 57 |
-
"subjectId": subjectId,
|
| 58 |
-
"type": "uploaded_module",
|
| 59 |
-
"semester": semester,
|
| 60 |
-
"quarter": quarter
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
# Reingest
|
| 64 |
-
try:
|
| 65 |
-
ingest_from_firebase_storage(force_reindex=True)
|
| 66 |
-
except Exception as e:
|
| 67 |
-
logger.error(f"Failed to trigger reingestion: {e}")
|
| 68 |
-
|
| 69 |
-
storage_url = f"gs://{bucket.name}/{storage_path}"
|
| 70 |
-
return {
|
| 71 |
-
"success": True,
|
| 72 |
-
"chunkCount": 0,
|
| 73 |
-
"subjectId": subjectId,
|
| 74 |
-
"storageUrl": storage_url
|
| 75 |
-
}
|
| 76 |
-
|
| 77 |
-
@router.post("/reingest-pdf")
|
| 78 |
-
async def reingest_pdf(
|
| 79 |
-
req: Optional[ReingestRequest] = None,
|
| 80 |
-
_admin=Depends(require_admin)
|
| 81 |
-
):
|
| 82 |
-
try:
|
| 83 |
-
ingest_from_firebase_storage(force_reindex=True)
|
| 84 |
-
return {"success": True, "message": "Reingestion triggered successfully."}
|
| 85 |
-
except Exception as e:
|
| 86 |
-
logger.error(f"Failed to reingest: {e}")
|
| 87 |
-
raise HTTPException(status_code=500, detail=f"Failed to reingest: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
routes/curriculum_routes.py
DELETED
|
@@ -1,66 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import logging
|
| 4 |
-
from typing import Optional
|
| 5 |
-
|
| 6 |
-
from fastapi import APIRouter, HTTPException, Query
|
| 7 |
-
from pydantic import BaseModel
|
| 8 |
-
|
| 9 |
-
from services.curriculum_service import (
|
| 10 |
-
get_subject,
|
| 11 |
-
get_subjects,
|
| 12 |
-
get_topic,
|
| 13 |
-
get_topics,
|
| 14 |
-
)
|
| 15 |
-
|
| 16 |
-
logger = logging.getLogger("mathpulse.curriculum")
|
| 17 |
-
router = APIRouter(prefix="/api/curriculum", tags=["curriculum"])
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
class SubjectResponse(BaseModel):
|
| 21 |
-
id: str
|
| 22 |
-
code: str
|
| 23 |
-
name: str
|
| 24 |
-
gradeLevel: str
|
| 25 |
-
semester: str
|
| 26 |
-
color: str
|
| 27 |
-
pdfAvailable: bool
|
| 28 |
-
topics: list
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
class TopicResponse(BaseModel):
|
| 32 |
-
id: str
|
| 33 |
-
name: str
|
| 34 |
-
unit: str
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
@router.get("/subjects", response_model=list[SubjectResponse])
|
| 38 |
-
async def list_subjects(grade_level: Optional[str] = Query(None, description="Filter by grade level (e.g., 'Grade 11', 'Grade 12')")):
|
| 39 |
-
"""List all curriculum subjects, optionally filtered by grade level."""
|
| 40 |
-
subjects = get_subjects(grade_level)
|
| 41 |
-
return subjects
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
@router.get("/subjects/{subject_id}", response_model=SubjectResponse)
|
| 45 |
-
async def get_subject_by_id(subject_id: str):
|
| 46 |
-
"""Get a specific subject by ID."""
|
| 47 |
-
subject = get_subject(subject_id)
|
| 48 |
-
if not subject:
|
| 49 |
-
raise HTTPException(status_code=404, detail=f"Subject not found: {subject_id}")
|
| 50 |
-
return subject
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
@router.get("/subjects/{subject_id}/topics", response_model=list[TopicResponse])
|
| 54 |
-
async def list_subject_topics(subject_id: str):
|
| 55 |
-
"""List all topics for a subject."""
|
| 56 |
-
topics = get_topics(subject_id)
|
| 57 |
-
return topics
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
@router.get("/subjects/{subject_id}/topics/{topic_id}", response_model=TopicResponse)
|
| 61 |
-
async def get_topic_by_id(subject_id: str, topic_id: str):
|
| 62 |
-
"""Get a specific topic."""
|
| 63 |
-
topic = get_topic(subject_id, topic_id)
|
| 64 |
-
if not topic:
|
| 65 |
-
raise HTTPException(status_code=404, detail=f"Topic not found: {subject_id}/{topic_id}")
|
| 66 |
-
return topic
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
routes/diagnostic.py
DELETED
|
@@ -1,797 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
MathPulse AI - Diagnostic Assessment Router
|
| 3 |
-
POST /api/diagnostic/generate - Generate 15-item diagnostic test grounded in RAG curriculum
|
| 4 |
-
POST /api/diagnostic/submit - Score responses, run risk analysis, save to Firestore
|
| 5 |
-
"""
|
| 6 |
-
|
| 7 |
-
from __future__ import annotations
|
| 8 |
-
|
| 9 |
-
import asyncio
|
| 10 |
-
import json
|
| 11 |
-
import logging
|
| 12 |
-
import time
|
| 13 |
-
import traceback
|
| 14 |
-
import uuid
|
| 15 |
-
from collections import defaultdict
|
| 16 |
-
from datetime import datetime, timezone
|
| 17 |
-
from typing import Any, Dict, List, Optional
|
| 18 |
-
|
| 19 |
-
from fastapi import APIRouter, HTTPException, Request
|
| 20 |
-
from pydantic import BaseModel, Field
|
| 21 |
-
|
| 22 |
-
from services.ai_client import CHAT_MODEL, get_deepseek_client
|
| 23 |
-
from rag.curriculum_rag import retrieve_curriculum_context
|
| 24 |
-
import firebase_admin
|
| 25 |
-
from firebase_admin import firestore as fs
|
| 26 |
-
|
| 27 |
-
logger = logging.getLogger("mathpulse.diagnostic")
|
| 28 |
-
|
| 29 |
-
router = APIRouter(prefix="/api/diagnostic", tags=["diagnostic"])
|
| 30 |
-
|
| 31 |
-
# In-memory fallback session store (used if Firestore is unavailable)
|
| 32 |
-
# This ensures assessment works even without Firebase credentials
|
| 33 |
-
_in_memory_sessions: Dict[str, Dict[str, Any]] = defaultdict(dict)
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
# ─── Pydantic Models ───────────────────────────────────────────────
|
| 37 |
-
|
| 38 |
-
class DiagnosticGenerateRequest(BaseModel):
|
| 39 |
-
strand: str = Field(..., description="Student strand: ABM, STEM, HUMSS, GAS, TVL")
|
| 40 |
-
grade_level: str = Field(..., description="Grade level: Grade 11 or Grade 12")
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
class DiagnosticOption(BaseModel):
|
| 44 |
-
A: str
|
| 45 |
-
B: str
|
| 46 |
-
C: str
|
| 47 |
-
D: str
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
class DiagnosticQuestionStripped(BaseModel):
|
| 51 |
-
question_id: str
|
| 52 |
-
competency_code: str
|
| 53 |
-
domain: str
|
| 54 |
-
topic: str
|
| 55 |
-
difficulty: str
|
| 56 |
-
bloom_level: str
|
| 57 |
-
question_text: str
|
| 58 |
-
options: DiagnosticOption
|
| 59 |
-
curriculum_reference: str
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
class DiagnosticGenerateResponse(BaseModel):
|
| 63 |
-
test_id: str
|
| 64 |
-
questions: List[DiagnosticQuestionStripped]
|
| 65 |
-
total_items: int
|
| 66 |
-
estimated_minutes: float
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
class DiagnosticResponseItem(BaseModel):
|
| 70 |
-
question_id: str
|
| 71 |
-
student_answer: str
|
| 72 |
-
time_spent_seconds: int
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
class DiagnosticSubmitRequest(BaseModel):
|
| 76 |
-
test_id: str
|
| 77 |
-
responses: List[DiagnosticResponseItem]
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
class MasterySummary(BaseModel):
|
| 81 |
-
mastered: List[str]
|
| 82 |
-
developing: List[str]
|
| 83 |
-
beginning: List[str]
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
class DiagnosticSubmitResponse(BaseModel):
|
| 87 |
-
success: bool
|
| 88 |
-
overall_risk: str
|
| 89 |
-
overall_score_percent: float
|
| 90 |
-
mastery_summary: MasterySummary
|
| 91 |
-
recommended_intervention: str
|
| 92 |
-
xp_earned: int
|
| 93 |
-
badge_unlocked: str
|
| 94 |
-
redirect_to: str
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
# ─── Competency Code Registry ───────────────────────────────────────
|
| 98 |
-
|
| 99 |
-
COMPETENCY_REGISTRY = {
|
| 100 |
-
"NA-WAGE-01": {"subject": "General Mathematics", "title": "Wages, Salaries, Overtime, Commissions, VAT"},
|
| 101 |
-
"NA-SEQ-01": {"subject": "General Mathematics", "title": "Arithmetic Sequences and Series"},
|
| 102 |
-
"NA-SEQ-02": {"subject": "General Mathematics", "title": "Geometric Sequences and Series"},
|
| 103 |
-
"NA-FUNC-01": {"subject": "General Mathematics", "title": "Functions, Relations, Vertical Line Test"},
|
| 104 |
-
"NA-FUNC-02": {"subject": "General Mathematics", "title": "Evaluating Functions, Operations, Composition"},
|
| 105 |
-
"NA-FUNC-03": {"subject": "General Mathematics", "title": "One-to-One Functions, Inverse Functions"},
|
| 106 |
-
"NA-EXP-01": {"subject": "General Mathematics", "title": "Exponential Functions, Equations, Inequalities"},
|
| 107 |
-
"NA-LOG-01": {"subject": "General Mathematics", "title": "Logarithmic Functions"},
|
| 108 |
-
"MG-TRIG-01": {"subject": "General Mathematics", "title": "Trigonometric Ratios, Right Triangles"},
|
| 109 |
-
"NA-FIN-01": {"subject": "General Mathematics", "title": "Compound Interest, Maturity Value"},
|
| 110 |
-
"NA-FIN-02": {"subject": "General Mathematics", "title": "Simple and General Annuities"},
|
| 111 |
-
"NA-FIN-04": {"subject": "General Mathematics", "title": "Business and Consumer Loans, Amortization"},
|
| 112 |
-
"NA-LOGIC-01": {"subject": "General Mathematics", "title": "Logical Propositions, Connectives, Truth Tables"},
|
| 113 |
-
"BM-FDP-01": {"subject": "Business Mathematics", "title": "Fractions, Decimals, Percent Conversions"},
|
| 114 |
-
"BM-FDP-02": {"subject": "Business Mathematics", "title": "Proportion: Direct, Inverse, Partitive"},
|
| 115 |
-
"BM-BUS-01": {"subject": "Business Mathematics", "title": "Markup, Margin, Trade Discounts, VAT"},
|
| 116 |
-
"BM-BUS-02": {"subject": "Business Mathematics", "title": "Profit, Loss, Break-even Point"},
|
| 117 |
-
"BM-COMM-01": {"subject": "Business Mathematics", "title": "Straight Commission, Salary Plus Commission"},
|
| 118 |
-
"BM-COMM-02": {"subject": "Business Mathematics", "title": "Commission on Cash and Installment Basis"},
|
| 119 |
-
"BM-SW-01": {"subject": "Business Mathematics", "title": "Salary vs. Wage, Income"},
|
| 120 |
-
"BM-SW-03": {"subject": "Business Mathematics", "title": "Mandatory Deductions: SSS, PhilHealth, Pag-IBIG"},
|
| 121 |
-
"BM-SW-04": {"subject": "Business Mathematics", "title": "Overtime Pay Computation (Labor Code)"},
|
| 122 |
-
"SP-RV-01": {"subject": "Statistics & Probability", "title": "Random Variables, Discrete vs. Continuous"},
|
| 123 |
-
"SP-RV-02": {"subject": "Statistics & Probability", "title": "Probability Distribution, Mean, Variance, SD"},
|
| 124 |
-
"SP-NORM-01": {"subject": "Statistics & Probability", "title": "Normal Curve Properties"},
|
| 125 |
-
"SP-NORM-02": {"subject": "Statistics & Probability", "title": "Z-Scores, Standard Normal Table"},
|
| 126 |
-
"SP-SAMP-01": {"subject": "Statistics & Probability", "title": "Types of Random Sampling"},
|
| 127 |
-
"SP-SAMP-03": {"subject": "Statistics & Probability", "title": "Central Limit Theorem"},
|
| 128 |
-
"SP-HYP-01": {"subject": "Statistics & Probability", "title": "Hypothesis Testing: H0 and Ha"},
|
| 129 |
-
"FM1-MAT-01": {"subject": "Finite Mathematics", "title": "Matrices and Matrix Operations"},
|
| 130 |
-
"FM2-PROB-01": {"subject": "Finite Mathematics", "title": "Counting Principles and Permutations"},
|
| 131 |
-
"FM2-PROB-02": {"subject": "Finite Mathematics", "title": "Combinations and Probability"},
|
| 132 |
-
}
|
| 133 |
-
|
| 134 |
-
LEARNING_PATH_ORDER: Dict[str, List[str]] = {
|
| 135 |
-
"BM": ["BM-FDP-01", "BM-FDP-02", "BM-BUS-01", "BM-BUS-02", "BM-COMM-01",
|
| 136 |
-
"BM-COMM-02", "BM-SW-01", "BM-SW-03", "BM-SW-04"],
|
| 137 |
-
"NA": ["NA-WAGE-01", "NA-SEQ-01", "NA-SEQ-02", "NA-FUNC-01", "NA-FUNC-02",
|
| 138 |
-
"NA-FUNC-03", "NA-EXP-01", "NA-LOG-01", "NA-FIN-01", "NA-FIN-02",
|
| 139 |
-
"NA-FIN-04", "NA-LOGIC-01"],
|
| 140 |
-
"SP": ["SP-RV-01", "SP-RV-02", "SP-NORM-01", "SP-NORM-02", "SP-SAMP-01",
|
| 141 |
-
"SP-SAMP-03", "SP-HYP-01"],
|
| 142 |
-
}
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
STRAND_SUBJECTS: Dict[str, List[str]] = {
|
| 146 |
-
"ABM": ["General Mathematics", "Business Mathematics"],
|
| 147 |
-
"STEM": ["General Mathematics", "Statistics and Probability"],
|
| 148 |
-
"HUMSS": ["General Mathematics"],
|
| 149 |
-
"GAS": ["General Mathematics"],
|
| 150 |
-
"TVL": ["General Mathematics"],
|
| 151 |
-
}
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
FULL_QUESTION_SCHEMA: Dict[str, List[str]] = {
|
| 155 |
-
"ABM": [
|
| 156 |
-
"General Mathematics: 5 items",
|
| 157 |
-
"Business Mathematics: 5 items",
|
| 158 |
-
"Statistics & Probability: 5 items",
|
| 159 |
-
],
|
| 160 |
-
"STEM": [
|
| 161 |
-
"General Mathematics: 7 items",
|
| 162 |
-
"Statistics & Probability: 5 items",
|
| 163 |
-
"Finite Mathematics: 3 items",
|
| 164 |
-
],
|
| 165 |
-
"HUMSS": ["General Mathematics: 15 items"],
|
| 166 |
-
"GAS": ["General Mathematics: 15 items"],
|
| 167 |
-
"TVL": ["General Mathematics: 15 items"],
|
| 168 |
-
}
|
| 169 |
-
|
| 170 |
-
STRAND_COVERAGE_TEXT: Dict[str, str] = {
|
| 171 |
-
"ABM": """FOR ABM STRAND:
|
| 172 |
-
- 5 questions: General Mathematics (NA-WAGE, NA-SEQ, NA-FIN topics -- wages, sequences, interest)
|
| 173 |
-
- 5 questions: Business Mathematics (BM-FDP, BM-BUS, BM-COMM, BM-SW topics -- percent, markup, commission, salaries, deductions using SSS/PhilHealth/Pag-IBIG rates)
|
| 174 |
-
- 5 questions: Statistics & Probability (SP-RV, SP-NORM topics -- random variables, normal distribution, z-scores)""",
|
| 175 |
-
"STEM": """FOR STEM STRAND:
|
| 176 |
-
- 7 questions: General Mathematics (NA-FUNC, NA-EXP, NA-LOG, MG-TRIG, NA-FIN -- functions, exponentials, trigonometry, financial math)
|
| 177 |
-
- 5 questions: Statistics & Probability (SP-RV, SP-NORM, SP-SAMP, SP-HYP -- distributions, sampling, hypothesis)
|
| 178 |
-
- 3 questions: Finite Mathematics (FM1-MAT or FM2-PROB -- matrices or counting/probability)""",
|
| 179 |
-
"HUMSS": """FOR HUMSS STRAND:
|
| 180 |
-
- 15 questions: General Mathematics only (spread across NA-WAGE, NA-SEQ, NA-FUNC, NA-FIN, NA-LOGIC -- wages, sequences, functions, interest, logic)""",
|
| 181 |
-
"GAS": """FOR GAS STRAND:
|
| 182 |
-
- 15 questions: General Mathematics only (spread across NA-WAGE, NA-SEQ, NA-FUNC, NA-FIN, NA-LOGIC -- wages, sequences, functions, interest, logic)""",
|
| 183 |
-
"TVL": """FOR TVL STRAND:
|
| 184 |
-
- 15 questions: General Mathematics only (spread across NA-WAGE, NA-SEQ, NA-FUNC, NA-FIN, NA-LOGIC -- wages, sequences, functions, interest, logic)""",
|
| 185 |
-
}
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
def _get_strand_coverage(strand: str) -> str:
|
| 189 |
-
return STRAND_COVERAGE_TEXT.get(strand.upper(), STRAND_COVERAGE_TEXT["STEM"])
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
def _build_rag_context(strand: str) -> str:
|
| 193 |
-
subjects = STRAND_SUBJECTS.get(strand.upper(), ["General Mathematics"])
|
| 194 |
-
rag_context_parts: List[str] = []
|
| 195 |
-
|
| 196 |
-
rag_query = f"SHS {strand} diagnostic assessment competency questions Grade 11"
|
| 197 |
-
|
| 198 |
-
for subject in subjects:
|
| 199 |
-
try:
|
| 200 |
-
chunks = retrieve_curriculum_context(
|
| 201 |
-
query=rag_query,
|
| 202 |
-
subject=subject,
|
| 203 |
-
top_k=3,
|
| 204 |
-
)
|
| 205 |
-
except Exception as e:
|
| 206 |
-
logger.warning(f"[WARN] RAG unavailable for {subject}: {e}")
|
| 207 |
-
continue
|
| 208 |
-
|
| 209 |
-
if not chunks:
|
| 210 |
-
continue
|
| 211 |
-
|
| 212 |
-
chunk_texts: List[str] = []
|
| 213 |
-
for chunk in chunks:
|
| 214 |
-
source = chunk.get("source_file", "unknown")
|
| 215 |
-
content = str(chunk.get("content", ""))[:600]
|
| 216 |
-
chunk_texts.append(f"[Source: {source}]\n{content}")
|
| 217 |
-
rag_context_parts.append(
|
| 218 |
-
f"\n=== {subject.upper()} CURRICULUM REFERENCE ===\n" + "\n---\n".join(chunk_texts)
|
| 219 |
-
)
|
| 220 |
-
|
| 221 |
-
if not rag_context_parts:
|
| 222 |
-
logger.warning("[WARN] RAG unavailable for diagnostic generation -- proceeding without curriculum context")
|
| 223 |
-
return ""
|
| 224 |
-
|
| 225 |
-
return "\n".join(rag_context_parts)
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
async def _get_previous_questions(
|
| 229 |
-
user_id: str,
|
| 230 |
-
firestore_client,
|
| 231 |
-
max_attempts: int = 3,
|
| 232 |
-
) -> list[str]:
|
| 233 |
-
"""Fetch question texts from the user's last N assessment attempts to avoid duplicates."""
|
| 234 |
-
try:
|
| 235 |
-
attempts_ref = (
|
| 236 |
-
firestore_client.collection("assessmentResults")
|
| 237 |
-
.document(user_id)
|
| 238 |
-
.collection("attempts")
|
| 239 |
-
.order_by("completedAt", direction=fs.Query.DESCENDING)
|
| 240 |
-
.limit(max_attempts)
|
| 241 |
-
)
|
| 242 |
-
docs = attempts_ref.stream()
|
| 243 |
-
previous_texts: list[str] = []
|
| 244 |
-
for doc in docs:
|
| 245 |
-
data = doc.to_dict()
|
| 246 |
-
answers = data.get("answers", [])
|
| 247 |
-
for a in answers:
|
| 248 |
-
previous_texts.append(a.get("questionText", ""))
|
| 249 |
-
return previous_texts
|
| 250 |
-
except Exception:
|
| 251 |
-
return []
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
def _build_system_prompt(strand: str, grade_level: str, rag_context: str, variance_seed: int = 0, previous_questions: list[str] | None = None) -> str:
|
| 255 |
-
strand_upper = strand.upper()
|
| 256 |
-
coverage_text = _get_strand_coverage(strand_upper)
|
| 257 |
-
|
| 258 |
-
rag_block = ""
|
| 259 |
-
if rag_context:
|
| 260 |
-
rag_block = f"""
|
| 261 |
-
OFFICIAL CURRICULUM REFERENCE (from indexed DepEd modules via RAG):
|
| 262 |
-
{rag_context}
|
| 263 |
-
|
| 264 |
-
IMPORTANT: Base ALL questions strictly on the curriculum content above.
|
| 265 |
-
Do not invent formulas, definitions, or problem types not found in the
|
| 266 |
-
reference material. If the reference material is insufficient for a topic,
|
| 267 |
-
use only standard DepEd SHS competencies for that strand.
|
| 268 |
-
"""
|
| 269 |
-
|
| 270 |
-
previous_block = ""
|
| 271 |
-
if previous_questions:
|
| 272 |
-
previous_lines = [
|
| 273 |
-
"PREVIOUS QUESTIONS TO AVOID (DO NOT REPEAT):",
|
| 274 |
-
"The following questions were already asked to this student.",
|
| 275 |
-
"You MUST NOT reuse or rephrase any of these:",
|
| 276 |
-
]
|
| 277 |
-
for i, q in enumerate(previous_questions[:20], 1):
|
| 278 |
-
previous_lines.append(f"{i}. {q}")
|
| 279 |
-
previous_block = "\n".join(previous_lines) + "\n\n"
|
| 280 |
-
|
| 281 |
-
variance_block = ""
|
| 282 |
-
if variance_seed > 0:
|
| 283 |
-
variance_block = (
|
| 284 |
-
f"VARIANCE SEED: {variance_seed}\n"
|
| 285 |
-
"To ensure unique questions, use this seed to generate DIFFERENT "
|
| 286 |
-
"numerical values, problem contexts, and variable names compared "
|
| 287 |
-
"to the standard template.\n\n"
|
| 288 |
-
)
|
| 289 |
-
|
| 290 |
-
return f"""SYSTEM ROLE:
|
| 291 |
-
You are MathPulse AI's Diagnostic Test Generator. Your job is to create a
|
| 292 |
-
15-item multiple-choice diagnostic assessment for a Filipino SHS student,
|
| 293 |
-
strictly grounded in the DepEd Strengthened SHS Curriculum (SDO Navotas
|
| 294 |
-
modules and DepEd K-12 Curriculum Guides).
|
| 295 |
-
|
| 296 |
-
STUDENT CONTEXT:
|
| 297 |
-
- Strand: {strand_upper}
|
| 298 |
-
- Grade Level: {grade_level}
|
| 299 |
-
- Test Purpose: DIAGNOSTIC (pre-learning, not summative -- assess current
|
| 300 |
-
knowledge to build a personalized learning path)
|
| 301 |
-
{rag_block}
|
| 302 |
-
STRAND-SUBJECT COVERAGE:
|
| 303 |
-
Generate 15 questions distributed across these subjects and domains:
|
| 304 |
-
|
| 305 |
-
{coverage_text}
|
| 306 |
-
|
| 307 |
-
COMPETENCY CODE FORMAT:
|
| 308 |
-
Assign each question exactly one competency_code from this registry:
|
| 309 |
-
General Math: NA-WAGE-01, NA-SEQ-01, NA-SEQ-02, NA-FUNC-01,
|
| 310 |
-
NA-FUNC-02, NA-FUNC-03, NA-EXP-01, NA-LOG-01,
|
| 311 |
-
MG-TRIG-01, NA-FIN-01, NA-FIN-02, NA-FIN-04,
|
| 312 |
-
NA-LOGIC-01
|
| 313 |
-
Business Math: BM-FDP-01, BM-FDP-02, BM-BUS-01, BM-BUS-02,
|
| 314 |
-
BM-COMM-01, BM-COMM-02, BM-SW-01, BM-SW-03, BM-SW-04
|
| 315 |
-
Statistics: SP-RV-01, SP-RV-02, SP-NORM-01, SP-NORM-02,
|
| 316 |
-
SP-SAMP-01, SP-SAMP-03, SP-HYP-01
|
| 317 |
-
Finite Math: FM1-MAT-01, FM2-PROB-01, FM2-PROB-02
|
| 318 |
-
|
| 319 |
-
{previous_block}{variance_block}DIFFICULTY DISTRIBUTION (across all 15 questions):
|
| 320 |
-
- Easy (Bloom: remembering / understanding): 6 questions (40%)
|
| 321 |
-
- Medium (Bloom: applying / analyzing): 6 questions (40%)
|
| 322 |
-
- Hard (Bloom: evaluating / creating): 3 questions (20%)
|
| 323 |
-
|
| 324 |
-
QUESTION RULES:
|
| 325 |
-
1. All questions are 4-option multiple choice (A, B, C, D).
|
| 326 |
-
2. Use Filipino real-life context: peso amounts, Filipino names
|
| 327 |
-
(Juan, Maria, Jose), Philippine institutions (SSS, PhilHealth,
|
| 328 |
-
Pag-IBIG, BIR, BDO, local schools, SM malls).
|
| 329 |
-
3. Never use trick questions. Wrong options must be plausible but clearly
|
| 330 |
-
incorrect to a student who knows the concept.
|
| 331 |
-
4. Include a solution_hint (1-2 sentences) -- this is for the backend
|
| 332 |
-
scoring engine ONLY. NEVER include it in the client response.
|
| 333 |
-
5. Cover as many different competency codes as possible across 15 items.
|
| 334 |
-
Do not repeat the same competency code more than twice.
|
| 335 |
-
|
| 336 |
-
OUTPUT FORMAT (strict JSON array, no extra text, no markdown):
|
| 337 |
-
[
|
| 338 |
-
{{
|
| 339 |
-
"question_id": "DX-<uuid>",
|
| 340 |
-
"competency_code": "BM-SW-03",
|
| 341 |
-
"domain": "Business Mathematics",
|
| 342 |
-
"topic": "Mandatory Deductions",
|
| 343 |
-
"difficulty": "medium",
|
| 344 |
-
"bloom_level": "applying",
|
| 345 |
-
"question_text": "...",
|
| 346 |
-
"options": {{"A": "...", "B": "...", "C": "...", "D": "..."}},
|
| 347 |
-
"correct_answer": "C",
|
| 348 |
-
"solution_hint": "Compute SSS contribution using the prescribed table...",
|
| 349 |
-
"curriculum_reference": "SDO Navotas Bus. Math SHS 1st Sem - Salaries and Wages"
|
| 350 |
-
}}
|
| 351 |
-
]
|
| 352 |
-
"""
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
async def _call_deepseek(system_prompt: str, user_message: str, temperature: float = 0.7) -> str:
|
| 356 |
-
try:
|
| 357 |
-
client = get_deepseek_client()
|
| 358 |
-
response = client.chat.completions.create(
|
| 359 |
-
model=CHAT_MODEL,
|
| 360 |
-
messages=[
|
| 361 |
-
{"role": "system", "content": system_prompt},
|
| 362 |
-
{"role": "user", "content": user_message},
|
| 363 |
-
],
|
| 364 |
-
temperature=temperature,
|
| 365 |
-
response_format={"type": "json_object"},
|
| 366 |
-
)
|
| 367 |
-
return response.choices[0].message.content or ""
|
| 368 |
-
except Exception as e:
|
| 369 |
-
logger.error(f"DeepSeek API error: {e}")
|
| 370 |
-
raise HTTPException(status_code=500, detail="AI model unavailable. Please try again later.")
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
def _parse_questions_response(raw_response: str) -> List[Dict[str, Any]]:
|
| 374 |
-
try:
|
| 375 |
-
data = json.loads(raw_response)
|
| 376 |
-
if isinstance(data, dict):
|
| 377 |
-
for key in ("questions", "items", "data", "results"):
|
| 378 |
-
if key in data and isinstance(data[key], list):
|
| 379 |
-
return data[key]
|
| 380 |
-
for key, value in data.items():
|
| 381 |
-
if isinstance(value, list) and len(value) > 0 and isinstance(value[0], dict):
|
| 382 |
-
if "question_text" in value[0]:
|
| 383 |
-
return value
|
| 384 |
-
if isinstance(data, list):
|
| 385 |
-
return data
|
| 386 |
-
except json.JSONDecodeError:
|
| 387 |
-
pass
|
| 388 |
-
|
| 389 |
-
import re
|
| 390 |
-
match = re.search(r'\[.*\]', raw_response, re.DOTALL)
|
| 391 |
-
if match:
|
| 392 |
-
try:
|
| 393 |
-
return json.loads(match.group())
|
| 394 |
-
except json.JSONDecodeError:
|
| 395 |
-
pass
|
| 396 |
-
|
| 397 |
-
raise ValueError("Could not parse questions from AI response")
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
async def _generate_questions(
|
| 401 |
-
strand: str,
|
| 402 |
-
grade_level: str,
|
| 403 |
-
user_id: str = "",
|
| 404 |
-
firestore_client=None,
|
| 405 |
-
) -> tuple[str, List[Dict[str, Any]]]:
|
| 406 |
-
test_id = f"DX-{uuid.uuid4().hex[:12]}"
|
| 407 |
-
|
| 408 |
-
# Generate variance seed based on user's attempt count and fetch previous questions
|
| 409 |
-
variance_seed = 0
|
| 410 |
-
previous_questions: list[str] = []
|
| 411 |
-
|
| 412 |
-
if firestore_client and user_id:
|
| 413 |
-
try:
|
| 414 |
-
attempts_ref = (
|
| 415 |
-
firestore_client.collection("assessmentResults")
|
| 416 |
-
.document(user_id)
|
| 417 |
-
.collection("attempts")
|
| 418 |
-
)
|
| 419 |
-
attempts = attempts_ref.stream()
|
| 420 |
-
attempt_count = sum(1 for _ in attempts)
|
| 421 |
-
variance_seed = int(time.time()) % 10000 + attempt_count * 137
|
| 422 |
-
previous_questions = await _get_previous_questions(user_id, firestore_client)
|
| 423 |
-
except Exception:
|
| 424 |
-
pass
|
| 425 |
-
|
| 426 |
-
rag_context = _build_rag_context(strand)
|
| 427 |
-
system_prompt = _build_system_prompt(
|
| 428 |
-
strand,
|
| 429 |
-
grade_level,
|
| 430 |
-
rag_context,
|
| 431 |
-
variance_seed=variance_seed,
|
| 432 |
-
previous_questions=previous_questions,
|
| 433 |
-
)
|
| 434 |
-
user_message = f"Generate 15 diagnostic questions for a Grade 11 {strand} student."
|
| 435 |
-
|
| 436 |
-
for attempt in range(2):
|
| 437 |
-
temperature = 0.7 if attempt == 0 else 0.3
|
| 438 |
-
try:
|
| 439 |
-
raw_response = await _call_deepseek(system_prompt, user_message, temperature)
|
| 440 |
-
questions = _parse_questions_response(raw_response)
|
| 441 |
-
if questions:
|
| 442 |
-
return test_id, questions[:15]
|
| 443 |
-
except ValueError:
|
| 444 |
-
if attempt == 0:
|
| 445 |
-
logger.warning("Malformed JSON from DeepSeek, retrying with temperature=0.3")
|
| 446 |
-
continue
|
| 447 |
-
raise
|
| 448 |
-
|
| 449 |
-
raise HTTPException(status_code=500, detail="Assessment generation failed. Please try again.")
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
async def _store_diagnostic_session(
|
| 453 |
-
firestore_client: Any,
|
| 454 |
-
user_id: str,
|
| 455 |
-
test_id: str,
|
| 456 |
-
strand: str,
|
| 457 |
-
grade_level: str,
|
| 458 |
-
questions: List[Dict[str, Any]],
|
| 459 |
-
) -> bool:
|
| 460 |
-
try:
|
| 461 |
-
doc_ref = (
|
| 462 |
-
firestore_client.collection("diagnosticSessions")
|
| 463 |
-
.document(test_id)
|
| 464 |
-
)
|
| 465 |
-
doc_ref.set({
|
| 466 |
-
"testId": test_id,
|
| 467 |
-
"userId": user_id,
|
| 468 |
-
"generatedAt": fs.SERVER_TIMESTAMP,
|
| 469 |
-
"strand": strand,
|
| 470 |
-
"gradeLevel": grade_level,
|
| 471 |
-
"questions": questions,
|
| 472 |
-
"status": "in_progress",
|
| 473 |
-
})
|
| 474 |
-
return True
|
| 475 |
-
except Exception as e:
|
| 476 |
-
logger.error(f"Failed to store diagnostic session: {e}")
|
| 477 |
-
return False
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
def _strip_answers(questions: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 481 |
-
stripped = []
|
| 482 |
-
for q in questions:
|
| 483 |
-
stripped.append({
|
| 484 |
-
"question_id": q.get("question_id", ""),
|
| 485 |
-
"competency_code": q.get("competency_code", ""),
|
| 486 |
-
"domain": q.get("domain", ""),
|
| 487 |
-
"topic": q.get("topic", ""),
|
| 488 |
-
"difficulty": q.get("difficulty", ""),
|
| 489 |
-
"bloom_level": q.get("bloom_level", ""),
|
| 490 |
-
"question_text": q.get("question_text", ""),
|
| 491 |
-
"options": q.get("options", {}),
|
| 492 |
-
"curriculum_reference": q.get("curriculum_reference", ""),
|
| 493 |
-
})
|
| 494 |
-
return stripped
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
# ─── ENDPOINT 1: Generate Diagnostic ────��───────────────────────────
|
| 498 |
-
|
| 499 |
-
@router.post("/generate", response_model=DiagnosticGenerateResponse)
|
| 500 |
-
async def generate_diagnostic(request: DiagnosticGenerateRequest, req: Request):
|
| 501 |
-
user = getattr(req.state, "user", None)
|
| 502 |
-
if not user or not getattr(user, "uid", None):
|
| 503 |
-
raise HTTPException(status_code=401, detail="Authentication required")
|
| 504 |
-
|
| 505 |
-
try:
|
| 506 |
-
firestore_client = fs.client()
|
| 507 |
-
test_id, questions = await _generate_questions(
|
| 508 |
-
request.strand,
|
| 509 |
-
request.grade_level,
|
| 510 |
-
user_id=user.uid,
|
| 511 |
-
firestore_client=firestore_client,
|
| 512 |
-
)
|
| 513 |
-
except HTTPException:
|
| 514 |
-
raise
|
| 515 |
-
except Exception as e:
|
| 516 |
-
logger.error(f"Generation error: {e}\n{traceback.format_exc()}")
|
| 517 |
-
raise HTTPException(status_code=500, detail="Assessment generation failed. Please try again.")
|
| 518 |
-
|
| 519 |
-
try:
|
| 520 |
-
stored = await _store_diagnostic_session(
|
| 521 |
-
firestore_client,
|
| 522 |
-
user.uid,
|
| 523 |
-
test_id,
|
| 524 |
-
request.strand,
|
| 525 |
-
request.grade_level,
|
| 526 |
-
questions,
|
| 527 |
-
)
|
| 528 |
-
if not stored:
|
| 529 |
-
raise HTTPException(status_code=503, detail="Session storage failed. Please try again.")
|
| 530 |
-
except HTTPException:
|
| 531 |
-
raise
|
| 532 |
-
except Exception as e:
|
| 533 |
-
logger.error(f"Could not store diagnostic session: {e}")
|
| 534 |
-
raise HTTPException(status_code=503, detail="Database unavailable. Please try again.")
|
| 535 |
-
|
| 536 |
-
client_questions = _strip_answers(questions)
|
| 537 |
-
|
| 538 |
-
return DiagnosticGenerateResponse(
|
| 539 |
-
test_id=test_id,
|
| 540 |
-
questions=client_questions,
|
| 541 |
-
total_items=len(client_questions),
|
| 542 |
-
estimated_minutes=11.6,
|
| 543 |
-
)
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
# ─── ENDPOINT 2: Submit and Evaluate ─────────────────────────────────
|
| 547 |
-
|
| 548 |
-
def _score_responses(stored_questions: List[Dict[str, Any]], responses: List[DiagnosticResponseItem]) -> tuple:
|
| 549 |
-
question_map: Dict[str, Dict[str, Any]] = {}
|
| 550 |
-
for q in stored_questions:
|
| 551 |
-
question_map[q.get("question_id", "")] = q
|
| 552 |
-
|
| 553 |
-
scored = []
|
| 554 |
-
total_correct = 0
|
| 555 |
-
domain_correct: Dict[str, int] = {}
|
| 556 |
-
domain_total: Dict[str, int] = {}
|
| 557 |
-
comp_attempts: Dict[str, List[bool]] = {}
|
| 558 |
-
|
| 559 |
-
for resp in responses:
|
| 560 |
-
question = question_map.get(resp.question_id, {})
|
| 561 |
-
correct_answer = question.get("correct_answer", "")
|
| 562 |
-
is_correct = (resp.student_answer.strip().upper() == correct_answer.strip().upper())
|
| 563 |
-
|
| 564 |
-
domain = question.get("domain", "Unknown")
|
| 565 |
-
competency_code = question.get("competency_code", "")
|
| 566 |
-
|
| 567 |
-
if domain not in domain_correct:
|
| 568 |
-
domain_correct[domain] = 0
|
| 569 |
-
domain_total[domain] = 0
|
| 570 |
-
domain_total[domain] += 1
|
| 571 |
-
if is_correct:
|
| 572 |
-
domain_correct[domain] += 1
|
| 573 |
-
total_correct += 1
|
| 574 |
-
|
| 575 |
-
if competency_code not in comp_attempts:
|
| 576 |
-
comp_attempts[competency_code] = []
|
| 577 |
-
comp_attempts[competency_code].append(is_correct)
|
| 578 |
-
|
| 579 |
-
scored.append({
|
| 580 |
-
"question_id": resp.question_id,
|
| 581 |
-
"competency_code": competency_code,
|
| 582 |
-
"domain": domain,
|
| 583 |
-
"topic": question.get("topic", ""),
|
| 584 |
-
"difficulty": question.get("difficulty", ""),
|
| 585 |
-
"bloom_level": question.get("bloom_level", ""),
|
| 586 |
-
"student_answer": resp.student_answer,
|
| 587 |
-
"correct_answer": correct_answer,
|
| 588 |
-
"is_correct": is_correct,
|
| 589 |
-
"time_spent_seconds": resp.time_spent_seconds,
|
| 590 |
-
})
|
| 591 |
-
|
| 592 |
-
return scored, total_correct, domain_correct, domain_total, comp_attempts
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
def _compute_domain_scores(domain_correct: Dict[str, int], domain_total: Dict[str, int]) -> Dict[str, Dict[str, Any]]:
|
| 596 |
-
domain_scores = {}
|
| 597 |
-
for domain in domain_total:
|
| 598 |
-
correct = domain_correct.get(domain, 0)
|
| 599 |
-
total = domain_total[domain]
|
| 600 |
-
pct = (correct / total * 100) if total > 0 else 0
|
| 601 |
-
mastery = "mastered" if pct >= 80 else "developing" if pct >= 60 else "beginning"
|
| 602 |
-
domain_scores[domain] = {
|
| 603 |
-
"correct": correct,
|
| 604 |
-
"total": total,
|
| 605 |
-
"percentage": round(pct, 1),
|
| 606 |
-
"mastery_level": mastery,
|
| 607 |
-
}
|
| 608 |
-
return domain_scores
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
def _compute_risk_profile(
|
| 612 |
-
total_correct: int,
|
| 613 |
-
total_items: int,
|
| 614 |
-
scored_responses: List[Dict[str, Any]],
|
| 615 |
-
domain_scores: Dict[str, Dict[str, Any]],
|
| 616 |
-
) -> Dict[str, Any]:
|
| 617 |
-
overall_pct = (total_correct / total_items * 100) if total_items > 0 else 0
|
| 618 |
-
|
| 619 |
-
mastered = [d for d, s in domain_scores.items() if s["mastery_level"] == "mastered"]
|
| 620 |
-
developing = [d for d, s in domain_scores.items() if s["mastery_level"] == "developing"]
|
| 621 |
-
beginning = [d for d, s in domain_scores.items() if s["mastery_level"] == "beginning"]
|
| 622 |
-
|
| 623 |
-
critical_gaps = []
|
| 624 |
-
for resp in scored_responses:
|
| 625 |
-
code = resp.get("competency_code", "")
|
| 626 |
-
if not code:
|
| 627 |
-
continue
|
| 628 |
-
attempts = [r for r in scored_responses if r.get("competency_code") == code]
|
| 629 |
-
if len(attempts) >= 2 and not any(r.get("is_correct") for r in attempts):
|
| 630 |
-
if code not in critical_gaps:
|
| 631 |
-
critical_gaps.append(code)
|
| 632 |
-
|
| 633 |
-
if overall_pct >= 75 and len(beginning) == 0:
|
| 634 |
-
overall_risk = "low"
|
| 635 |
-
elif overall_pct >= 55 or len(beginning) <= 2:
|
| 636 |
-
overall_risk = "moderate"
|
| 637 |
-
elif overall_pct >= 40 or len(beginning) <= 4:
|
| 638 |
-
overall_risk = "high"
|
| 639 |
-
else:
|
| 640 |
-
overall_risk = "critical"
|
| 641 |
-
|
| 642 |
-
suggested_path = []
|
| 643 |
-
for code in critical_gaps:
|
| 644 |
-
if code not in suggested_path:
|
| 645 |
-
suggested_path.append(code)
|
| 646 |
-
for domain in beginning:
|
| 647 |
-
for prefix in ["NA", "BM", "SP", "FM"]:
|
| 648 |
-
if domain.upper().startswith(prefix) or any(
|
| 649 |
-
s.upper().startswith(prefix) for s in [domain]
|
| 650 |
-
):
|
| 651 |
-
for comp_code in LEARNING_PATH_ORDER.get(prefix, []):
|
| 652 |
-
if comp_code not in suggested_path:
|
| 653 |
-
suggested_path.append(comp_code)
|
| 654 |
-
break
|
| 655 |
-
for domain in developing:
|
| 656 |
-
for prefix in ["NA", "BM", "SP", "FM"]:
|
| 657 |
-
if any(c.startswith(prefix) for c in COMPETENCY_REGISTRY):
|
| 658 |
-
for comp_code in LEARNING_PATH_ORDER.get(prefix, []):
|
| 659 |
-
if comp_code not in suggested_path:
|
| 660 |
-
suggested_path.append(comp_code)
|
| 661 |
-
|
| 662 |
-
interventions = {
|
| 663 |
-
"low": "Great job! You have a solid foundation. Keep practicing to maintain your skills!",
|
| 664 |
-
"moderate": "You're making good progress. Focus on the topics where you need more practice. Kaya mo yan!",
|
| 665 |
-
"high": "Don't worry! With focused practice on your weak areas, you'll improve quickly.",
|
| 666 |
-
"critical": "Let's work on this together. Start with the basics and build up your confidence step by step.",
|
| 667 |
-
}
|
| 668 |
-
|
| 669 |
-
return {
|
| 670 |
-
"overall_risk": overall_risk,
|
| 671 |
-
"overall_score_percent": round(overall_pct, 1),
|
| 672 |
-
"mastery_summary": {
|
| 673 |
-
"mastered": mastered,
|
| 674 |
-
"developing": developing,
|
| 675 |
-
"beginning": beginning,
|
| 676 |
-
},
|
| 677 |
-
"weak_domains": beginning,
|
| 678 |
-
"critical_gaps": critical_gaps,
|
| 679 |
-
"recommended_intervention": interventions.get(overall_risk, interventions["moderate"]),
|
| 680 |
-
"suggested_learning_path": suggested_path[:20],
|
| 681 |
-
}
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
async def _save_results(
|
| 685 |
-
firestore_client: Any,
|
| 686 |
-
user_id: str,
|
| 687 |
-
test_id: str,
|
| 688 |
-
strand: str,
|
| 689 |
-
grade_level: str,
|
| 690 |
-
scored_responses: List[Dict[str, Any]],
|
| 691 |
-
domain_scores: Dict[str, Dict[str, Any]],
|
| 692 |
-
risk_profile: Dict[str, Any],
|
| 693 |
-
total_correct: int,
|
| 694 |
-
total_items: int,
|
| 695 |
-
) -> None:
|
| 696 |
-
try:
|
| 697 |
-
overall_pct = round(total_correct / total_items * 100, 1) if total_items > 0 else 0
|
| 698 |
-
|
| 699 |
-
firestore_client.collection("diagnosticResults").document(user_id).set({
|
| 700 |
-
"userId": user_id,
|
| 701 |
-
"testId": test_id,
|
| 702 |
-
"takenAt": fs.SERVER_TIMESTAMP,
|
| 703 |
-
"strand": strand,
|
| 704 |
-
"gradeLevel": grade_level,
|
| 705 |
-
"status": "completed",
|
| 706 |
-
"totalItems": total_items,
|
| 707 |
-
"totalScore": total_correct,
|
| 708 |
-
"percentageScore": overall_pct,
|
| 709 |
-
"responses": scored_responses,
|
| 710 |
-
"domainScores": domain_scores,
|
| 711 |
-
"riskProfile": risk_profile,
|
| 712 |
-
})
|
| 713 |
-
|
| 714 |
-
mastered_count = len(risk_profile.get("mastery_summary", {}).get("mastered", []))
|
| 715 |
-
|
| 716 |
-
firestore_client.collection("studentProgress").document(user_id).collection("stats").document("main").set({
|
| 717 |
-
"learning_path": risk_profile.get("suggested_learning_path", []),
|
| 718 |
-
"current_topic_index": 0,
|
| 719 |
-
"total_xp": fs.Increment(50 + mastered_count * 10),
|
| 720 |
-
"badges": fs.ArrayUnion(["first_assessment"]),
|
| 721 |
-
"topics_mastered": mastered_count,
|
| 722 |
-
"diagnostic_completed": True,
|
| 723 |
-
"overall_risk": risk_profile.get("overall_risk", "moderate"),
|
| 724 |
-
}, merge=True)
|
| 725 |
-
|
| 726 |
-
firestore_client.collection("diagnosticSessions").document(test_id).update({
|
| 727 |
-
"status": "completed",
|
| 728 |
-
"completedAt": fs.SERVER_TIMESTAMP,
|
| 729 |
-
})
|
| 730 |
-
|
| 731 |
-
except Exception as e:
|
| 732 |
-
logger.error(f"Firestore save error: {e}")
|
| 733 |
-
raise
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
@router.post("/submit", response_model=DiagnosticSubmitResponse)
|
| 737 |
-
async def submit_diagnostic(request: DiagnosticSubmitRequest, req: Request):
|
| 738 |
-
user = getattr(req.state, "user", None)
|
| 739 |
-
if not user or not getattr(user, "uid", None):
|
| 740 |
-
raise HTTPException(status_code=401, detail="Authentication required")
|
| 741 |
-
|
| 742 |
-
try:
|
| 743 |
-
firestore_client = fs.client()
|
| 744 |
-
except Exception as e:
|
| 745 |
-
raise HTTPException(status_code=503, detail="Database unavailable")
|
| 746 |
-
|
| 747 |
-
try:
|
| 748 |
-
session_doc = firestore_client.collection("diagnosticSessions").document(request.test_id).get()
|
| 749 |
-
if not session_doc.exists:
|
| 750 |
-
raise HTTPException(status_code=404, detail="Diagnostic session not found")
|
| 751 |
-
|
| 752 |
-
session_data = session_doc.to_dict() or {}
|
| 753 |
-
stored_questions = session_data.get("questions", [])
|
| 754 |
-
strand = session_data.get("strand", "STEM")
|
| 755 |
-
grade_level = session_data.get("gradeLevel", "Grade 11")
|
| 756 |
-
|
| 757 |
-
if not stored_questions:
|
| 758 |
-
raise HTTPException(status_code=400, detail="No questions found for this session")
|
| 759 |
-
except HTTPException:
|
| 760 |
-
raise
|
| 761 |
-
except Exception as e:
|
| 762 |
-
logger.error(f"Session retrieval error: {e}")
|
| 763 |
-
raise HTTPException(status_code=500, detail="Failed to retrieve diagnostic session")
|
| 764 |
-
|
| 765 |
-
scored_responses, total_correct, domain_correct, domain_total, _ = _score_responses(
|
| 766 |
-
stored_questions, request.responses
|
| 767 |
-
)
|
| 768 |
-
|
| 769 |
-
total_items = len(stored_questions)
|
| 770 |
-
domain_scores = _compute_domain_scores(domain_correct, domain_total)
|
| 771 |
-
risk_profile = _compute_risk_profile(total_correct, total_items, scored_responses, domain_scores)
|
| 772 |
-
|
| 773 |
-
await _save_results(
|
| 774 |
-
firestore_client,
|
| 775 |
-
user.uid,
|
| 776 |
-
request.test_id,
|
| 777 |
-
strand,
|
| 778 |
-
grade_level,
|
| 779 |
-
scored_responses,
|
| 780 |
-
domain_scores,
|
| 781 |
-
risk_profile,
|
| 782 |
-
total_correct,
|
| 783 |
-
total_items,
|
| 784 |
-
)
|
| 785 |
-
|
| 786 |
-
mastered_count = len(risk_profile.get("mastery_summary", {}).get("mastered", []))
|
| 787 |
-
|
| 788 |
-
return DiagnosticSubmitResponse(
|
| 789 |
-
success=True,
|
| 790 |
-
overall_risk=risk_profile["overall_risk"],
|
| 791 |
-
overall_score_percent=risk_profile["overall_score_percent"],
|
| 792 |
-
mastery_summary=MasterySummary(**risk_profile["mastery_summary"]),
|
| 793 |
-
recommended_intervention=risk_profile["recommended_intervention"],
|
| 794 |
-
xp_earned=50 + mastered_count * 10,
|
| 795 |
-
badge_unlocked="first_assessment",
|
| 796 |
-
redirect_to="/dashboard",
|
| 797 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
routes/quiz_battle.py
DELETED
|
@@ -1,205 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Quiz Battle API Routes.
|
| 3 |
-
|
| 4 |
-
Endpoints:
|
| 5 |
-
- POST /api/quiz-battle/generate → Generate varied questions for a battle session
|
| 6 |
-
- POST /api/quiz-battle/ingest-pdf → Trigger PDF ingestion (teacher/admin)
|
| 7 |
-
- GET /api/quiz-battle/bank-status → List processed PDFs (teacher/admin)
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
import os
|
| 11 |
-
from typing import List, Optional, Dict, Any
|
| 12 |
-
from datetime import datetime, timezone
|
| 13 |
-
|
| 14 |
-
from fastapi import APIRouter, Request, HTTPException, Depends
|
| 15 |
-
from pydantic import BaseModel, Field
|
| 16 |
-
|
| 17 |
-
from rag.pdf_ingestion import ingest_pdf, IngestionResult
|
| 18 |
-
from services.question_bank_service import get_questions_for_battle, cache_session_questions, get_cached_session
|
| 19 |
-
from services.variance_engine import apply_variance
|
| 20 |
-
|
| 21 |
-
router = APIRouter(prefix="/api/quiz-battle", tags=["quiz-battle"])
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
# ── Pydantic Models ──────────────────────────────────────────────────
|
| 25 |
-
|
| 26 |
-
class GenerateRequest(BaseModel):
|
| 27 |
-
grade_level: int = Field(..., ge=7, le=12)
|
| 28 |
-
topic: str = Field(..., min_length=1)
|
| 29 |
-
question_count: int = Field(default=10, ge=1, le=50)
|
| 30 |
-
session_id: str = Field(..., min_length=1)
|
| 31 |
-
player_ids: List[str] = Field(default_factory=list)
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
class GenerateResponse(BaseModel):
|
| 35 |
-
questions: List[Dict[str, Any]]
|
| 36 |
-
session_id: str
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
class IngestPdfRequest(BaseModel):
|
| 40 |
-
storage_path: str = Field(..., min_length=1)
|
| 41 |
-
grade_level: int = Field(..., ge=7, le=12)
|
| 42 |
-
topic: str = Field(..., min_length=1)
|
| 43 |
-
force_reingest: bool = False
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
class IngestPdfResponse(BaseModel):
|
| 47 |
-
status: str
|
| 48 |
-
filename: str
|
| 49 |
-
question_count: int
|
| 50 |
-
grade_level: int
|
| 51 |
-
topic: str
|
| 52 |
-
storage_path: str
|
| 53 |
-
timestamp: datetime
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
class BankStatusItem(BaseModel):
|
| 57 |
-
filename: str
|
| 58 |
-
processed: bool
|
| 59 |
-
timestamp: Optional[datetime]
|
| 60 |
-
question_count: int
|
| 61 |
-
grade_level: int
|
| 62 |
-
topic: str
|
| 63 |
-
storage_path: str
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
class BankStatusResponse(BaseModel):
|
| 67 |
-
pdfs: List[BankStatusItem]
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
# ── Helper ───────────────────────────────────────────────────────────
|
| 71 |
-
|
| 72 |
-
def _get_current_user(request: Request):
|
| 73 |
-
user = getattr(request.state, "user", None)
|
| 74 |
-
if user is None:
|
| 75 |
-
raise HTTPException(status_code=401, detail="Authentication required")
|
| 76 |
-
return user
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def _is_internal_request(request: Request) -> bool:
|
| 80 |
-
"""Check if request is from an internal service (Cloud Functions)."""
|
| 81 |
-
internal_secret = request.headers.get("X-Internal-Service")
|
| 82 |
-
expected = os.getenv("QUIZ_BATTLE_INTERNAL_SECRET")
|
| 83 |
-
if expected and internal_secret == expected:
|
| 84 |
-
return True
|
| 85 |
-
return False
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
# ── Endpoints ────────────────────────────────────────────────────────
|
| 89 |
-
|
| 90 |
-
@router.post("/generate", response_model=GenerateResponse)
|
| 91 |
-
async def generate_questions(
|
| 92 |
-
body: GenerateRequest,
|
| 93 |
-
request: Request,
|
| 94 |
-
):
|
| 95 |
-
"""
|
| 96 |
-
Generate varied questions for a quiz battle session.
|
| 97 |
-
|
| 98 |
-
Returns questions with choices but WITHOUT correct_answer (unless called
|
| 99 |
-
by an internal service with X-Internal-Service header).
|
| 100 |
-
"""
|
| 101 |
-
# 1. Fetch base questions
|
| 102 |
-
questions = await get_questions_for_battle(
|
| 103 |
-
body.grade_level,
|
| 104 |
-
body.topic,
|
| 105 |
-
body.question_count,
|
| 106 |
-
)
|
| 107 |
-
|
| 108 |
-
if not questions:
|
| 109 |
-
raise HTTPException(
|
| 110 |
-
status_code=404,
|
| 111 |
-
detail=f"No questions found for grade {body.grade_level}, topic '{body.topic}'",
|
| 112 |
-
)
|
| 113 |
-
|
| 114 |
-
# 2. Apply variance (with 24h cache)
|
| 115 |
-
varied = await apply_variance(questions, body.session_id)
|
| 116 |
-
|
| 117 |
-
# 3. Cache session metadata
|
| 118 |
-
await cache_session_questions(
|
| 119 |
-
body.session_id,
|
| 120 |
-
varied,
|
| 121 |
-
body.player_ids,
|
| 122 |
-
body.grade_level,
|
| 123 |
-
body.topic,
|
| 124 |
-
)
|
| 125 |
-
|
| 126 |
-
# 4. Prepare response
|
| 127 |
-
is_internal = _is_internal_request(request)
|
| 128 |
-
response_questions = []
|
| 129 |
-
for q in varied:
|
| 130 |
-
q_copy = dict(q)
|
| 131 |
-
if not is_internal:
|
| 132 |
-
q_copy.pop("correct_answer", None)
|
| 133 |
-
response_questions.append(q_copy)
|
| 134 |
-
|
| 135 |
-
return GenerateResponse(questions=response_questions, session_id=body.session_id)
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
@router.post("/ingest-pdf", response_model=IngestPdfResponse)
|
| 139 |
-
async def ingest_pdf_endpoint(
|
| 140 |
-
body: IngestPdfRequest,
|
| 141 |
-
user=Depends(_get_current_user),
|
| 142 |
-
):
|
| 143 |
-
"""
|
| 144 |
-
Trigger PDF ingestion into the question bank.
|
| 145 |
-
|
| 146 |
-
Requires teacher or admin role.
|
| 147 |
-
"""
|
| 148 |
-
if user.role not in ("teacher", "admin"):
|
| 149 |
-
raise HTTPException(status_code=403, detail="Teacher or admin access required")
|
| 150 |
-
|
| 151 |
-
try:
|
| 152 |
-
result = await ingest_pdf(
|
| 153 |
-
storage_path=body.storage_path,
|
| 154 |
-
grade_level=body.grade_level,
|
| 155 |
-
topic=body.topic,
|
| 156 |
-
force_reingest=body.force_reingest,
|
| 157 |
-
)
|
| 158 |
-
except FileNotFoundError as e:
|
| 159 |
-
raise HTTPException(status_code=404, detail=str(e))
|
| 160 |
-
except ValueError as e:
|
| 161 |
-
raise HTTPException(status_code=400, detail=str(e))
|
| 162 |
-
except Exception as e:
|
| 163 |
-
raise HTTPException(status_code=500, detail=f"Ingestion failed: {str(e)}")
|
| 164 |
-
|
| 165 |
-
return IngestPdfResponse(
|
| 166 |
-
status="processed" if result.processed else "skipped",
|
| 167 |
-
filename=result.filename,
|
| 168 |
-
question_count=result.question_count,
|
| 169 |
-
grade_level=result.grade_level,
|
| 170 |
-
topic=result.topic,
|
| 171 |
-
storage_path=result.storage_path,
|
| 172 |
-
timestamp=result.timestamp,
|
| 173 |
-
)
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
@router.get("/bank-status", response_model=BankStatusResponse)
|
| 177 |
-
async def bank_status(
|
| 178 |
-
user=Depends(_get_current_user),
|
| 179 |
-
):
|
| 180 |
-
"""
|
| 181 |
-
Get the status of all processed PDFs in the question bank.
|
| 182 |
-
|
| 183 |
-
Requires teacher or admin role.
|
| 184 |
-
"""
|
| 185 |
-
if user.role not in ("teacher", "admin"):
|
| 186 |
-
raise HTTPException(status_code=403, detail="Teacher or admin access required")
|
| 187 |
-
|
| 188 |
-
from google.cloud import firestore
|
| 189 |
-
db = firestore.Client(project=os.getenv("FIREBASE_AUTH_PROJECT_ID", "mathpulse-ai-2026"))
|
| 190 |
-
|
| 191 |
-
docs = db.collection("pdf_processing_status").stream()
|
| 192 |
-
pdfs = []
|
| 193 |
-
for doc in docs:
|
| 194 |
-
data = doc.to_dict()
|
| 195 |
-
pdfs.append(BankStatusItem(
|
| 196 |
-
filename=doc.id,
|
| 197 |
-
processed=data.get("processed", False),
|
| 198 |
-
timestamp=data.get("timestamp"),
|
| 199 |
-
question_count=data.get("question_count", 0),
|
| 200 |
-
grade_level=data.get("grade_level", 0),
|
| 201 |
-
topic=data.get("topic", ""),
|
| 202 |
-
storage_path=data.get("storage_path", ""),
|
| 203 |
-
))
|
| 204 |
-
|
| 205 |
-
return BankStatusResponse(pdfs=pdfs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
routes/quiz_generation_routes.py
DELETED
|
@@ -1,356 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Unified Quiz Generation Routes.
|
| 3 |
-
|
| 4 |
-
Generates dynamic quiz questions using DeepSeek AI + RAG curriculum context.
|
| 5 |
-
Used by: lesson practice quizzes, module quizzes, and quiz battle.
|
| 6 |
-
|
| 7 |
-
When new PDFs are ingested into the vectorstore, this endpoint automatically
|
| 8 |
-
picks up the new content via RAG retrieval.
|
| 9 |
-
"""
|
| 10 |
-
|
| 11 |
-
from __future__ import annotations
|
| 12 |
-
|
| 13 |
-
import json
|
| 14 |
-
import logging
|
| 15 |
-
import random
|
| 16 |
-
import re
|
| 17 |
-
from typing import Any, Dict, List, Optional
|
| 18 |
-
|
| 19 |
-
from fastapi import APIRouter, HTTPException, Request
|
| 20 |
-
from pydantic import BaseModel, Field
|
| 21 |
-
|
| 22 |
-
from rag.curriculum_rag import (
|
| 23 |
-
retrieve_curriculum_context,
|
| 24 |
-
summarize_retrieval_confidence,
|
| 25 |
-
)
|
| 26 |
-
from services.inference_client import (
|
| 27 |
-
InferenceRequest,
|
| 28 |
-
create_default_client,
|
| 29 |
-
get_model_for_task,
|
| 30 |
-
)
|
| 31 |
-
|
| 32 |
-
logger = logging.getLogger("mathpulse.quiz_generation")
|
| 33 |
-
router = APIRouter(prefix="/api/quiz", tags=["quiz-generation"])
|
| 34 |
-
|
| 35 |
-
_inference_client = None
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
def _get_inference_client():
|
| 39 |
-
global _inference_client
|
| 40 |
-
if _inference_client is None:
|
| 41 |
-
_inference_client = create_default_client()
|
| 42 |
-
return _inference_client
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
# ── Request/Response Models ────────────────────────────────────────────
|
| 46 |
-
|
| 47 |
-
class QuizGenerationRequest(BaseModel):
|
| 48 |
-
topic: str = Field(..., min_length=1, description="Lesson topic or competency")
|
| 49 |
-
subject: str = Field(..., min_length=1, description="Subject name (e.g., 'General Mathematics')")
|
| 50 |
-
lessonTitle: Optional[str] = Field(default=None, description="Full lesson title")
|
| 51 |
-
questionCount: int = Field(default=6, ge=1, le=20, description="Number of questions to generate")
|
| 52 |
-
questionTypes: List[str] = Field(
|
| 53 |
-
default=["multiple-choice", "true-false", "fill-in-blank"],
|
| 54 |
-
description="Question types to include",
|
| 55 |
-
)
|
| 56 |
-
difficulty: str = Field(default="medium", pattern="^(easy|medium|hard)$")
|
| 57 |
-
quarter: Optional[int] = Field(default=1, ge=1, le=4)
|
| 58 |
-
moduleId: Optional[str] = Field(default=None)
|
| 59 |
-
lessonId: Optional[str] = Field(default=None)
|
| 60 |
-
competencyCode: Optional[str] = Field(default=None)
|
| 61 |
-
storagePath: Optional[str] = Field(default=None)
|
| 62 |
-
userId: Optional[str] = Field(default=None)
|
| 63 |
-
varianceSeed: Optional[int] = Field(default=None, description="Random seed for variance across generations")
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
class QuizQuestion(BaseModel):
|
| 67 |
-
id: int
|
| 68 |
-
type: str
|
| 69 |
-
question: str
|
| 70 |
-
options: Optional[List[str]] = None
|
| 71 |
-
correctAnswer: str
|
| 72 |
-
explanation: str
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
class QuizGenerationResponse(BaseModel):
|
| 76 |
-
questions: List[QuizQuestion]
|
| 77 |
-
retrievalConfidence: Dict[str, Any]
|
| 78 |
-
sourceChunks: int
|
| 79 |
-
generatedAt: str
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
# ── Prompt Builder ─────────────────────────────────────────────────────
|
| 83 |
-
|
| 84 |
-
def _build_quiz_generation_prompt(
|
| 85 |
-
topic: str,
|
| 86 |
-
subject: str,
|
| 87 |
-
lesson_title: Optional[str],
|
| 88 |
-
question_count: int,
|
| 89 |
-
question_types: List[str],
|
| 90 |
-
difficulty: str,
|
| 91 |
-
retrieved_context: str,
|
| 92 |
-
variance_seed: Optional[int] = None,
|
| 93 |
-
) -> str:
|
| 94 |
-
"""Build the DeepSeek prompt for quiz generation with variance."""
|
| 95 |
-
|
| 96 |
-
# Build variance instruction based on seed
|
| 97 |
-
variance_instruction = ""
|
| 98 |
-
if variance_seed is not None:
|
| 99 |
-
variance_instruction = f"""
|
| 100 |
-
8. VARIANCE REQUIREMENT: Use seed {variance_seed} to ensure variety. Generate DIFFERENT questions each time.
|
| 101 |
-
- Paraphrase concepts in fresh ways
|
| 102 |
-
- Use different numerical values and scenarios
|
| 103 |
-
- Vary question phrasing and structure
|
| 104 |
-
- Avoid repeating similar question patterns"""
|
| 105 |
-
|
| 106 |
-
return f"""You are a DepEd-aligned mathematics quiz generator for Filipino Senior High School students (Grades 11-12).
|
| 107 |
-
|
| 108 |
-
Given the following curriculum context about "{topic}" from {subject}, generate {question_count} {difficulty}-difficulty quiz questions.
|
| 109 |
-
|
| 110 |
-
## Retrieved Curriculum Context
|
| 111 |
-
{retrieved_context}
|
| 112 |
-
|
| 113 |
-
## Instructions
|
| 114 |
-
1. Generate exactly {question_count} questions covering the topic above.
|
| 115 |
-
2. Question types to use: {', '.join(question_types)}
|
| 116 |
-
3. DISTRIBUTION (for {question_count} questions):
|
| 117 |
-
- 2 items: Recall and Basics (simple recall, definitions, fundamental facts)
|
| 118 |
-
- 4 items: Direct Application (real-world context with pesos, jeepney, sari-sari store, etc.)
|
| 119 |
-
- 3 items: Mixed/Interleaved Problems (combine concepts, multi-step reasoning)
|
| 120 |
-
- 1 item: Metacognitive/Reflective (explain reasoning, justify approach, identify errors)
|
| 121 |
-
4. Difficulty: {difficulty} — appropriate for Grade 11-12 Filipino STEM students.
|
| 122 |
-
5. Use Filipino-localized context where possible (pesos, jeepney, barangay, sari-sari store, etc.).
|
| 123 |
-
6. Each question must be mathematically accurate and curriculum-aligned.
|
| 124 |
-
7. Provide clear explanations for the correct answer.{variance_instruction}
|
| 125 |
-
|
| 126 |
-
## Question Type Rules
|
| 127 |
-
- multiple-choice: 4 options (A/B/C/D format), exactly one correct answer
|
| 128 |
-
- true-false: statement that is either True or False
|
| 129 |
-
- fill-in-blank: question with a single numeric or short text answer
|
| 130 |
-
|
| 131 |
-
## Output Format
|
| 132 |
-
Return ONLY a valid JSON array. No markdown, no extra text. Format:
|
| 133 |
-
[
|
| 134 |
-
{{
|
| 135 |
-
"type": "multiple-choice",
|
| 136 |
-
"question": "What is the derivative of f(x) = x³?",
|
| 137 |
-
"options": ["2x²", "3x²", "x²", "3x"],
|
| 138 |
-
"correctAnswer": "3x²",
|
| 139 |
-
"explanation": "Using the power rule: d/dx(xⁿ) = nxⁿ⁻¹. So d/dx(x³) = 3x²."
|
| 140 |
-
}},
|
| 141 |
-
{{
|
| 142 |
-
"type": "true-false",
|
| 143 |
-
"question": "The sum of angles in a triangle is 180 degrees.",
|
| 144 |
-
"options": ["True", "False"],
|
| 145 |
-
"correctAnswer": "True",
|
| 146 |
-
"explanation": "By the triangle angle sum theorem, the interior angles of any Euclidean triangle sum to 180°."
|
| 147 |
-
}},
|
| 148 |
-
{{
|
| 149 |
-
"type": "fill-in-blank",
|
| 150 |
-
"question": "If f(x) = 2x + 3, then f(4) = ___",
|
| 151 |
-
"options": null,
|
| 152 |
-
"correctAnswer": "11",
|
| 153 |
-
"explanation": "Substitute x = 4: f(4) = 2(4) + 3 = 8 + 3 = 11."
|
| 154 |
-
}}
|
| 155 |
-
]
|
| 156 |
-
|
| 157 |
-
IMPORTANT:
|
| 158 |
-
- Return ONLY the JSON array, no other text
|
| 159 |
-
- Ensure correctAnswer exactly matches one of the options (for MC/TF)
|
| 160 |
-
- For fill-in-blank, correctAnswer is the exact text that fills the blank
|
| 161 |
-
- Generate FRESH, VARIED questions - no two questions should be identical or nearly identical
|
| 162 |
-
- Questions should feel like they were created independently, not templated"""
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
# ── Response Parser ────────────────────────────────────────────────────
|
| 166 |
-
|
| 167 |
-
def _parse_quiz_response(text: str, expected_count: int) -> List[Dict[str, Any]]:
|
| 168 |
-
"""Parse and validate DeepSeek quiz generation response."""
|
| 169 |
-
cleaned = text.strip()
|
| 170 |
-
|
| 171 |
-
# Strip markdown fences
|
| 172 |
-
cleaned = re.sub(r"^```json\s*", "", cleaned, flags=re.IGNORECASE)
|
| 173 |
-
cleaned = re.sub(r"^```\s*", "", cleaned)
|
| 174 |
-
cleaned = re.sub(r"\s*```$", "", cleaned)
|
| 175 |
-
cleaned = cleaned.strip()
|
| 176 |
-
|
| 177 |
-
try:
|
| 178 |
-
questions = json.loads(cleaned)
|
| 179 |
-
except json.JSONDecodeError as e:
|
| 180 |
-
logger.error(f"Failed to parse quiz response as JSON: {e}")
|
| 181 |
-
# Try to extract JSON array from text
|
| 182 |
-
match = re.search(r"\[.*\]", cleaned, re.DOTALL)
|
| 183 |
-
if match:
|
| 184 |
-
try:
|
| 185 |
-
questions = json.loads(match.group())
|
| 186 |
-
except json.JSONDecodeError:
|
| 187 |
-
raise ValueError(f"Invalid JSON in quiz response: {e}")
|
| 188 |
-
else:
|
| 189 |
-
raise ValueError(f"No JSON array found in quiz response")
|
| 190 |
-
|
| 191 |
-
if not isinstance(questions, list):
|
| 192 |
-
raise ValueError("Quiz response is not a JSON array")
|
| 193 |
-
|
| 194 |
-
validated = []
|
| 195 |
-
for i, q in enumerate(questions):
|
| 196 |
-
if not isinstance(q, dict):
|
| 197 |
-
continue
|
| 198 |
-
|
| 199 |
-
# Ensure required fields
|
| 200 |
-
if "question" not in q or "correctAnswer" not in q:
|
| 201 |
-
continue
|
| 202 |
-
|
| 203 |
-
# Normalize field names
|
| 204 |
-
normalized = {
|
| 205 |
-
"id": i + 1,
|
| 206 |
-
"type": q.get("type", "multiple-choice"),
|
| 207 |
-
"question": q["question"],
|
| 208 |
-
"correctAnswer": q["correctAnswer"],
|
| 209 |
-
"explanation": q.get("explanation", ""),
|
| 210 |
-
}
|
| 211 |
-
|
| 212 |
-
# Handle options
|
| 213 |
-
if "options" in q and q["options"]:
|
| 214 |
-
normalized["options"] = q["options"]
|
| 215 |
-
elif "choices" in q and q["choices"]:
|
| 216 |
-
normalized["options"] = q["choices"]
|
| 217 |
-
else:
|
| 218 |
-
# For true-false, auto-populate options
|
| 219 |
-
if normalized["type"] == "true-false":
|
| 220 |
-
normalized["options"] = ["True", "False"]
|
| 221 |
-
else:
|
| 222 |
-
normalized["options"] = None
|
| 223 |
-
|
| 224 |
-
validated.append(normalized)
|
| 225 |
-
|
| 226 |
-
if len(validated) < min(expected_count, 3):
|
| 227 |
-
raise ValueError(f"Only {len(validated)} valid questions parsed, expected at least {min(expected_count, 3)}")
|
| 228 |
-
|
| 229 |
-
return validated[:expected_count]
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
# ── Variance Application ───────────────────────────────────────────────
|
| 233 |
-
|
| 234 |
-
def _apply_variance(questions: List[Dict[str, Any]], seed: int) -> List[Dict[str, Any]]:
|
| 235 |
-
"""Apply deterministic variance to questions (shuffle choices, etc.)."""
|
| 236 |
-
rng = random.Random(seed)
|
| 237 |
-
|
| 238 |
-
for q in questions:
|
| 239 |
-
# Shuffle multiple-choice options while tracking correct answer
|
| 240 |
-
if q.get("type") == "multiple-choice" and q.get("options"):
|
| 241 |
-
options = q["options"].copy()
|
| 242 |
-
correct = q["correctAnswer"]
|
| 243 |
-
|
| 244 |
-
# Only shuffle if correct answer is in options
|
| 245 |
-
if correct in options:
|
| 246 |
-
rng.shuffle(options)
|
| 247 |
-
q["options"] = options
|
| 248 |
-
q["correctAnswer"] = correct # Keep original correct answer text
|
| 249 |
-
|
| 250 |
-
return questions
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
# ── Endpoints ──────────────────────────────────────────────────────────
|
| 254 |
-
|
| 255 |
-
@router.post("/generate", response_model=QuizGenerationResponse)
|
| 256 |
-
async def generate_quiz(request: QuizGenerationRequest):
|
| 257 |
-
"""
|
| 258 |
-
Generate a dynamic quiz using DeepSeek AI + RAG curriculum context.
|
| 259 |
-
|
| 260 |
-
This endpoint retrieves relevant curriculum chunks from the vectorstore,
|
| 261 |
-
then calls DeepSeek to generate varied quiz questions based on that context.
|
| 262 |
-
When new PDFs are ingested, they automatically become available via RAG.
|
| 263 |
-
"""
|
| 264 |
-
try:
|
| 265 |
-
# 1. Retrieve curriculum context via RAG
|
| 266 |
-
query = request.lessonTitle or request.topic
|
| 267 |
-
chunks = retrieve_curriculum_context(
|
| 268 |
-
query=query,
|
| 269 |
-
subject=request.subject,
|
| 270 |
-
quarter=request.quarter,
|
| 271 |
-
module_id=request.moduleId,
|
| 272 |
-
lesson_id=request.lessonId,
|
| 273 |
-
competency_code=request.competencyCode,
|
| 274 |
-
storage_path=request.storagePath,
|
| 275 |
-
top_k=8,
|
| 276 |
-
)
|
| 277 |
-
|
| 278 |
-
if not chunks:
|
| 279 |
-
logger.warning(f"No curriculum chunks found for topic '{request.topic}' in subject '{request.subject}'")
|
| 280 |
-
raise HTTPException(
|
| 281 |
-
status_code=404,
|
| 282 |
-
detail=f"No curriculum content found for topic '{request.topic}'. Please ensure PDFs are ingested.",
|
| 283 |
-
)
|
| 284 |
-
|
| 285 |
-
# Shuffle retrieved chunks for variance BEFORE formatting prompt context
|
| 286 |
-
# This ensures different lessons → different curriculum context → different generated questions
|
| 287 |
-
seed = request.varianceSeed if request.varianceSeed else hash(f"{request.topic}:{request.subject}:{request.lessonTitle or ''}:{request.userId or 'anon'}") % (2**32)
|
| 288 |
-
rng = random.Random(seed)
|
| 289 |
-
rng.shuffle(chunks) # In-place shuffle for deterministic variety per seed
|
| 290 |
-
|
| 291 |
-
# Format retrieved chunks for the prompt
|
| 292 |
-
formatted_context = "\n\n---\n\n".join(
|
| 293 |
-
f"[Source: {chunk.get('metadata', {}).get('source_file', 'Unknown')}, Page {chunk.get('metadata', {}).get('page', 'N/A')}]\n{chunk.get('document', '')}"
|
| 294 |
-
for chunk in chunks
|
| 295 |
-
)
|
| 296 |
-
|
| 297 |
-
confidence = summarize_retrieval_confidence(chunks)
|
| 298 |
-
|
| 299 |
-
# 2. Build generation prompt
|
| 300 |
-
prompt = _build_quiz_generation_prompt(
|
| 301 |
-
topic=request.topic,
|
| 302 |
-
subject=request.subject,
|
| 303 |
-
lesson_title=request.lessonTitle,
|
| 304 |
-
question_count=request.questionCount,
|
| 305 |
-
question_types=request.questionTypes,
|
| 306 |
-
difficulty=request.difficulty,
|
| 307 |
-
retrieved_context=formatted_context,
|
| 308 |
-
variance_seed=request.varianceSeed,
|
| 309 |
-
)
|
| 310 |
-
|
| 311 |
-
# 3. Call DeepSeek with higher temperature for variance
|
| 312 |
-
inference_request = InferenceRequest(
|
| 313 |
-
messages=[
|
| 314 |
-
{"role": "system", "content": "You are a precise DepEd-aligned curriculum quiz generator. Generate FRESH, VARIED questions each time - do not repeat patterns."},
|
| 315 |
-
{"role": "user", "content": prompt},
|
| 316 |
-
],
|
| 317 |
-
task_type="quiz_generation",
|
| 318 |
-
max_new_tokens=3000,
|
| 319 |
-
temperature=0.7, # Higher temp for variance
|
| 320 |
-
top_p=0.9,
|
| 321 |
-
)
|
| 322 |
-
|
| 323 |
-
raw_response = _get_inference_client().generate_from_messages(inference_request)
|
| 324 |
-
|
| 325 |
-
# 4. Parse response
|
| 326 |
-
questions = _parse_quiz_response(raw_response, request.questionCount)
|
| 327 |
-
|
| 328 |
-
# 5. Apply variance (shuffle options) with user-based seed for consistency
|
| 329 |
-
seed = request.varianceSeed if request.varianceSeed else hash(f"{request.topic}:{request.subject}:{request.lessonTitle or ''}:{request.userId or 'anon'}") % (2**32)
|
| 330 |
-
varied_questions = _apply_variance(questions, seed)
|
| 331 |
-
|
| 332 |
-
# 6. Build response
|
| 333 |
-
return QuizGenerationResponse(
|
| 334 |
-
questions=[QuizQuestion(**q) for q in varied_questions],
|
| 335 |
-
retrievalConfidence=confidence,
|
| 336 |
-
sourceChunks=len(chunks),
|
| 337 |
-
generatedAt=__import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat(),
|
| 338 |
-
)
|
| 339 |
-
|
| 340 |
-
except HTTPException:
|
| 341 |
-
raise
|
| 342 |
-
except Exception as e:
|
| 343 |
-
logger.exception("Quiz generation failed")
|
| 344 |
-
raise HTTPException(status_code=500, detail=f"Quiz generation failed: {str(e)}")
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
@router.get("/health")
|
| 348 |
-
async def quiz_generation_health():
|
| 349 |
-
"""Check quiz generation service health."""
|
| 350 |
-
model = get_model_for_task("quiz_generation")
|
| 351 |
-
return {
|
| 352 |
-
"status": "ok",
|
| 353 |
-
"activeModel": model,
|
| 354 |
-
"endpoint": "/api/quiz/generate",
|
| 355 |
-
"features": ["rag-retrieval", "deepseek-generation", "choice-shuffling", "auto-pdf-updates"],
|
| 356 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
routes/rag_routes.py
CHANGED
|
@@ -2,8 +2,6 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import json
|
| 4 |
import logging
|
| 5 |
-
import os
|
| 6 |
-
import re
|
| 7 |
from datetime import datetime, timezone
|
| 8 |
from threading import Lock
|
| 9 |
from typing import Any, Dict, List, Optional
|
|
@@ -11,28 +9,21 @@ from typing import Any, Dict, List, Optional
|
|
| 11 |
from fastapi import APIRouter, HTTPException, Request
|
| 12 |
from pydantic import BaseModel, Field
|
| 13 |
|
| 14 |
-
from services.inference_client import
|
| 15 |
-
InferenceRequest,
|
| 16 |
-
create_default_client,
|
| 17 |
-
is_sequential_model,
|
| 18 |
-
get_model_for_task,
|
| 19 |
-
)
|
| 20 |
from rag.curriculum_rag import (
|
| 21 |
build_analysis_curriculum_context,
|
| 22 |
build_lesson_prompt,
|
| 23 |
build_lesson_query,
|
| 24 |
build_problem_generation_prompt,
|
| 25 |
-
format_retrieved_chunks,
|
| 26 |
retrieve_curriculum_context,
|
| 27 |
-
retrieve_lesson_pdf_context,
|
| 28 |
summarize_retrieval_confidence,
|
| 29 |
)
|
| 30 |
-
from rag.vectorstore_loader import get_vectorstore_health
|
| 31 |
|
| 32 |
try:
|
| 33 |
-
from firebase_admin import firestore as firebase_firestore
|
| 34 |
except Exception:
|
| 35 |
-
firebase_firestore = None
|
| 36 |
|
| 37 |
logger = logging.getLogger("mathpulse.rag")
|
| 38 |
router = APIRouter(prefix="/api/rag", tags=["rag"])
|
|
@@ -50,12 +41,7 @@ def _get_inference_client():
|
|
| 50 |
return _inference_client
|
| 51 |
|
| 52 |
|
| 53 |
-
async def _generate_text(
|
| 54 |
-
prompt: str,
|
| 55 |
-
task_type: str,
|
| 56 |
-
max_new_tokens: int = 900,
|
| 57 |
-
enable_thinking: bool = False,
|
| 58 |
-
) -> str:
|
| 59 |
request = InferenceRequest(
|
| 60 |
messages=[
|
| 61 |
{"role": "system", "content": "You are a precise DepEd-aligned curriculum assistant."},
|
|
@@ -65,7 +51,6 @@ async def _generate_text(
|
|
| 65 |
max_new_tokens=max_new_tokens,
|
| 66 |
temperature=0.2,
|
| 67 |
top_p=0.9,
|
| 68 |
-
enable_thinking=enable_thinking,
|
| 69 |
)
|
| 70 |
return _get_inference_client().generate_from_messages(request)
|
| 71 |
|
|
@@ -103,21 +88,6 @@ def _log_rag_usage(
|
|
| 103 |
logger.warning("rag_usage logging skipped: %s", exc)
|
| 104 |
|
| 105 |
|
| 106 |
-
def _strip_thinking_and_parse(text: str) -> dict:
|
| 107 |
-
cleaned = text.strip()
|
| 108 |
-
cleaned = re.sub(r" </think>", "", cleaned, flags=re.DOTALL).strip()
|
| 109 |
-
if "{" in cleaned and "}" in cleaned:
|
| 110 |
-
try:
|
| 111 |
-
start = cleaned.find("{")
|
| 112 |
-
end = cleaned.rfind("}") + 1
|
| 113 |
-
parsed = json.loads(cleaned[start:end])
|
| 114 |
-
if isinstance(parsed, dict):
|
| 115 |
-
return parsed
|
| 116 |
-
except Exception:
|
| 117 |
-
pass
|
| 118 |
-
return {"explanation": text}
|
| 119 |
-
|
| 120 |
-
|
| 121 |
class RagLessonRequest(BaseModel):
|
| 122 |
topic: str
|
| 123 |
subject: str
|
|
@@ -127,10 +97,6 @@ class RagLessonRequest(BaseModel):
|
|
| 127 |
moduleUnit: Optional[str] = None
|
| 128 |
learnerLevel: Optional[str] = None
|
| 129 |
userId: Optional[str] = None
|
| 130 |
-
moduleId: Optional[str] = None
|
| 131 |
-
lessonId: Optional[str] = None
|
| 132 |
-
competencyCode: Optional[str] = None
|
| 133 |
-
storagePath: Optional[str] = None
|
| 134 |
|
| 135 |
|
| 136 |
class RagProblemRequest(BaseModel):
|
|
@@ -149,8 +115,6 @@ class RagAnalysisContextRequest(BaseModel):
|
|
| 149 |
|
| 150 |
@router.get("/health")
|
| 151 |
async def rag_health():
|
| 152 |
-
active_model = get_model_for_task("rag_lesson")
|
| 153 |
-
is_seq = is_sequential_model(active_model)
|
| 154 |
try:
|
| 155 |
health = get_vectorstore_health()
|
| 156 |
return {
|
|
@@ -158,8 +122,6 @@ async def rag_health():
|
|
| 158 |
"chunkCount": health["chunkCount"],
|
| 159 |
"subjects": health["subjects"],
|
| 160 |
"lastIngested": datetime.now(timezone.utc).isoformat(),
|
| 161 |
-
"activeModel": active_model,
|
| 162 |
-
"isSequentialModel": is_seq,
|
| 163 |
}
|
| 164 |
except Exception as exc:
|
| 165 |
return {
|
|
@@ -167,273 +129,68 @@ async def rag_health():
|
|
| 167 |
"chunkCount": 0,
|
| 168 |
"subjects": {},
|
| 169 |
"lastIngested": None,
|
| 170 |
-
"activeModel": active_model,
|
| 171 |
-
"isSequentialModel": is_seq,
|
| 172 |
"warning": str(exc),
|
| 173 |
}
|
| 174 |
|
| 175 |
|
| 176 |
-
def _fetch_youtube_videos(
|
| 177 |
-
lesson_title: str,
|
| 178 |
-
subject: str,
|
| 179 |
-
competency: str,
|
| 180 |
-
quarter: int,
|
| 181 |
-
lesson_id: Optional[str] = None,
|
| 182 |
-
) -> List[Dict]:
|
| 183 |
-
"""Fetch up to 3 relevant YouTube videos for a lesson."""
|
| 184 |
-
try:
|
| 185 |
-
from services.youtube_service import get_video_search_results
|
| 186 |
-
except ImportError:
|
| 187 |
-
return []
|
| 188 |
-
try:
|
| 189 |
-
result = get_video_search_results(
|
| 190 |
-
topic=lesson_title,
|
| 191 |
-
subject=subject,
|
| 192 |
-
lesson_context=competency,
|
| 193 |
-
grade_level=f"Grade {quarter + 10}",
|
| 194 |
-
lesson_id=lesson_id,
|
| 195 |
-
max_results=3,
|
| 196 |
-
)
|
| 197 |
-
return result.get("videos", [])
|
| 198 |
-
except Exception as e:
|
| 199 |
-
logger.warning("YouTube video search failed: %s", e)
|
| 200 |
-
return []
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
def _ensure_7_sections(lesson_data: dict, lesson_title: str) -> dict:
|
| 204 |
-
sections = lesson_data.get("sections", [])
|
| 205 |
-
section_types = {s.get("type") for s in sections}
|
| 206 |
-
required = ["introduction", "key_concepts", "video", "worked_examples", "important_notes", "try_it_yourself", "summary"]
|
| 207 |
-
|
| 208 |
-
default_content = {
|
| 209 |
-
"introduction": {"type": "introduction", "title": "Introduction", "content": f"Welcome to the lesson on {lesson_title}. This topic builds foundational skills for your mathematics journey."},
|
| 210 |
-
"key_concepts": {"type": "key_concepts", "title": "Key Concepts", "content": f"The following key concepts are essential for mastering {lesson_title}:", "callouts": [{"type": "important", "text": "Review the curriculum PDF for detailed explanations of each concept."}]},
|
| 211 |
-
"video": {"type": "video", "title": "Video Lesson", "content": "Watch the video explanation below to understand the concepts visually.", "videoId": "", "videoTitle": "", "videoChannel": "", "embedUrl": "", "thumbnailUrl": ""},
|
| 212 |
-
"worked_examples": {"type": "worked_examples", "title": "Worked Examples", "examples": [{"problem": f"Sample problem for {lesson_title}", "steps": ["Step 1: Identify the given information.", "Step 2: Apply the appropriate formula or method.", "Step 3: Solve step-by-step.", "Step 4: Verify your answer."], "answer": "Solution will vary based on specific problem parameters."}]},
|
| 213 |
-
"important_notes": {"type": "important_notes", "title": "Important Notes", "bulletPoints": [f"Always read problems carefully before solving {lesson_title} questions.", "Check your units and ensure consistency throughout calculations.", "Practice regularly to build fluency with these concepts."]},
|
| 214 |
-
"try_it_yourself": {"type": "try_it_yourself", "title": "Try It Yourself", "practiceProblems": [{"question": f"Practice applying {lesson_title} concepts. Solve a similar problem from your textbook or worksheets.", "solution": "Compare your solution with the worked examples above. If stuck, re-read the key concepts section or ask your teacher for guidance."}]},
|
| 215 |
-
"summary": {"type": "summary", "title": "Summary", "content": f"In this lesson on {lesson_title}, you explored key concepts, worked through examples, and practiced problem-solving techniques. Continue reviewing these materials and seek additional practice to strengthen your understanding."},
|
| 216 |
-
}
|
| 217 |
-
|
| 218 |
-
def _is_section_blank(section: dict, s_type: str) -> bool:
|
| 219 |
-
"""Check if a section has effectively no content."""
|
| 220 |
-
if not section:
|
| 221 |
-
return True
|
| 222 |
-
text_content = (section.get("content") or "").strip()
|
| 223 |
-
if s_type in ("introduction", "key_concepts", "video", "summary"):
|
| 224 |
-
return len(text_content) < 10
|
| 225 |
-
if s_type == "worked_examples":
|
| 226 |
-
examples = section.get("examples") or []
|
| 227 |
-
return not examples or all(not (ex.get("problem") or "").strip() for ex in examples)
|
| 228 |
-
if s_type == "important_notes":
|
| 229 |
-
bullets = section.get("bulletPoints") or []
|
| 230 |
-
return not bullets or all(not (b or "").strip() for b in bullets)
|
| 231 |
-
if s_type == "try_it_yourself":
|
| 232 |
-
problems = section.get("practiceProblems") or []
|
| 233 |
-
return not problems or all(not (p.get("question") or "").strip() for p in problems)
|
| 234 |
-
return False
|
| 235 |
-
|
| 236 |
-
filled = {}
|
| 237 |
-
for req_type in required:
|
| 238 |
-
for existing in sections:
|
| 239 |
-
if existing.get("type") == req_type:
|
| 240 |
-
filled[req_type] = existing
|
| 241 |
-
break
|
| 242 |
-
else:
|
| 243 |
-
filled[req_type] = default_content[req_type]
|
| 244 |
-
|
| 245 |
-
# Validate and replace blank sections with defaults
|
| 246 |
-
for req_type in required:
|
| 247 |
-
if _is_section_blank(filled[req_type], req_type):
|
| 248 |
-
filled[req_type] = default_content[req_type]
|
| 249 |
-
|
| 250 |
-
ordered = [filled[t] for t in required]
|
| 251 |
-
|
| 252 |
-
for i, section in enumerate(ordered):
|
| 253 |
-
s_type = section.get("type")
|
| 254 |
-
if s_type == "key_concepts" and not section.get("callouts"):
|
| 255 |
-
section["callouts"] = []
|
| 256 |
-
if s_type == "worked_examples" and not section.get("examples"):
|
| 257 |
-
section["examples"] = []
|
| 258 |
-
if s_type == "important_notes" and not section.get("bulletPoints"):
|
| 259 |
-
section["bulletPoints"] = []
|
| 260 |
-
if s_type == "try_it_yourself" and not section.get("practiceProblems"):
|
| 261 |
-
section["practiceProblems"] = []
|
| 262 |
-
ordered[i] = section
|
| 263 |
-
|
| 264 |
-
return {**lesson_data, "sections": ordered}
|
| 265 |
-
|
| 266 |
-
|
| 267 |
@router.post("/lesson")
|
| 268 |
async def rag_lesson(request: Request, payload: RagLessonRequest):
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
status_code=503,
|
| 296 |
-
detail={
|
| 297 |
-
"error": "retrieval_failed",
|
| 298 |
-
"message": f"Curriculum retrieval failed: {exc}",
|
| 299 |
-
"type": type(exc).__name__,
|
| 300 |
-
},
|
| 301 |
-
)
|
| 302 |
-
|
| 303 |
-
if not chunks:
|
| 304 |
-
raise HTTPException(
|
| 305 |
-
status_code=404,
|
| 306 |
-
detail={
|
| 307 |
-
"error": "no_curriculum_context",
|
| 308 |
-
"message": f"No curriculum content found for lesson '{payload.lessonTitle}' ({payload.subject} Q{payload.quarter}). Please ensure the PDF has been ingested.",
|
| 309 |
-
"retrievalBand": "low",
|
| 310 |
-
"sources": [],
|
| 311 |
-
},
|
| 312 |
-
)
|
| 313 |
-
|
| 314 |
-
# ── Step 2: Build prompt ─────────────────────────────────────────────────
|
| 315 |
-
try:
|
| 316 |
-
prompt = build_lesson_prompt(
|
| 317 |
-
lesson_title=payload.lessonTitle or payload.topic,
|
| 318 |
-
competency=payload.learningCompetency or payload.topic,
|
| 319 |
-
grade_level="Grade 11-12",
|
| 320 |
-
subject=payload.subject,
|
| 321 |
-
quarter=payload.quarter,
|
| 322 |
-
learner_level=payload.learnerLevel,
|
| 323 |
-
module_unit=payload.moduleUnit,
|
| 324 |
-
curriculum_chunks=chunks,
|
| 325 |
-
competency_code=payload.competencyCode,
|
| 326 |
-
)
|
| 327 |
-
except Exception as exc:
|
| 328 |
-
logger.error(f"RAG prompt build error: {type(exc).__name__}: {exc}")
|
| 329 |
-
raise HTTPException(
|
| 330 |
-
status_code=500,
|
| 331 |
-
detail={
|
| 332 |
-
"error": "prompt_build_failed",
|
| 333 |
-
"message": f"Failed to build lesson prompt: {exc}",
|
| 334 |
-
"type": type(exc).__name__,
|
| 335 |
-
},
|
| 336 |
-
)
|
| 337 |
-
|
| 338 |
-
# ── Step 3: AI inference ─────────────────────────────────────────────────
|
| 339 |
-
try:
|
| 340 |
-
raw_explanation = await _generate_text(
|
| 341 |
-
prompt,
|
| 342 |
-
task_type="rag_lesson",
|
| 343 |
-
max_new_tokens=1800,
|
| 344 |
-
enable_thinking=True,
|
| 345 |
-
)
|
| 346 |
-
except Exception as exc:
|
| 347 |
-
logger.error(f"RAG inference error: {type(exc).__name__}: {exc}")
|
| 348 |
-
raise HTTPException(
|
| 349 |
-
status_code=502,
|
| 350 |
-
detail={
|
| 351 |
-
"error": "inference_failed",
|
| 352 |
-
"message": f"AI model call failed: {exc}",
|
| 353 |
-
"type": type(exc).__name__,
|
| 354 |
-
},
|
| 355 |
-
)
|
| 356 |
-
|
| 357 |
-
# ── Step 4: Parse & validate response ────────────────────────────────────
|
| 358 |
-
try:
|
| 359 |
-
parsed_lesson = _strip_thinking_and_parse(raw_explanation)
|
| 360 |
-
parsed_lesson = _ensure_7_sections(parsed_lesson, payload.lessonTitle or payload.topic)
|
| 361 |
-
except Exception as exc:
|
| 362 |
-
logger.error(f"RAG parse error: {type(exc).__name__}: {exc}")
|
| 363 |
-
raise HTTPException(
|
| 364 |
-
status_code=500,
|
| 365 |
-
detail={
|
| 366 |
-
"error": "parse_failed",
|
| 367 |
-
"message": f"Failed to parse AI response: {exc}",
|
| 368 |
-
"type": type(exc).__name__,
|
| 369 |
-
},
|
| 370 |
-
)
|
| 371 |
-
|
| 372 |
-
# ── Step 5: Enrich with videos ───────────────────────────────────────────
|
| 373 |
-
if parsed_lesson.get("sections"):
|
| 374 |
-
video_section = next((s for s in parsed_lesson["sections"] if s.get("type") == "video"), None)
|
| 375 |
-
if video_section:
|
| 376 |
-
try:
|
| 377 |
-
videos = _fetch_youtube_videos(
|
| 378 |
-
payload.lessonTitle or payload.topic,
|
| 379 |
-
payload.subject,
|
| 380 |
-
payload.learningCompetency or "",
|
| 381 |
-
payload.quarter,
|
| 382 |
-
lesson_id=payload.lessonId,
|
| 383 |
-
)
|
| 384 |
-
if videos:
|
| 385 |
-
# Primary video for backwards compatibility
|
| 386 |
-
primary = videos[0]
|
| 387 |
-
video_section["videoId"] = primary.get("videoId", "")
|
| 388 |
-
video_section["videoTitle"] = primary.get("title", "")
|
| 389 |
-
video_section["videoChannel"] = primary.get("channelTitle", "")
|
| 390 |
-
video_section["embedUrl"] = f"https://www.youtube.com/embed/{primary.get('videoId', '')}"
|
| 391 |
-
video_section["thumbnailUrl"] = primary.get("thumbnailUrl", "")
|
| 392 |
-
# New: full videos array for Smart Video Integration
|
| 393 |
-
video_section["videos"] = videos
|
| 394 |
-
except Exception as exc:
|
| 395 |
-
logger.warning("YouTube enrichment skipped: %s", exc)
|
| 396 |
-
|
| 397 |
-
# ── Step 6: Assemble response ────────────────────────────────────────────
|
| 398 |
retrieval_summary = summarize_retrieval_confidence(chunks)
|
| 399 |
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
)
|
| 409 |
-
except Exception as exc:
|
| 410 |
-
logger.warning("RAG usage logging skipped: %s", exc)
|
| 411 |
-
|
| 412 |
-
needs_review = parsed_lesson.get("needsReview", False)
|
| 413 |
-
if retrieval_summary.get("band") == "low":
|
| 414 |
-
needs_review = True
|
| 415 |
|
| 416 |
return {
|
| 417 |
-
|
| 418 |
"retrievalConfidence": retrieval_summary.get("confidence", 0.0),
|
| 419 |
"retrievalBand": retrieval_summary.get("band", "low"),
|
| 420 |
-
"
|
| 421 |
-
"needsReview":
|
| 422 |
"sources": [
|
| 423 |
{
|
| 424 |
"subject": row.get("subject"),
|
| 425 |
"quarter": row.get("quarter"),
|
| 426 |
"source_file": row.get("source_file"),
|
| 427 |
-
"storage_path": row.get("storage_path"),
|
| 428 |
"page": row.get("page"),
|
| 429 |
"score": row.get("score"),
|
|
|
|
| 430 |
"content_domain": row.get("content_domain"),
|
| 431 |
"chunk_type": row.get("chunk_type"),
|
| 432 |
-
"content": row.get("content"),
|
| 433 |
}
|
| 434 |
for row in chunks
|
| 435 |
],
|
| 436 |
-
"activeModel": get_model_for_task("rag_lesson"),
|
| 437 |
}
|
| 438 |
|
| 439 |
|
|
@@ -446,20 +203,19 @@ async def rag_generate_problem(request: Request, payload: RagProblemRequest):
|
|
| 446 |
top_k=5,
|
| 447 |
)
|
| 448 |
prompt = build_problem_generation_prompt(payload.topic, payload.difficulty, chunks)
|
| 449 |
-
raw = await _generate_text(
|
| 450 |
-
prompt,
|
| 451 |
-
task_type="quiz_generation",
|
| 452 |
-
max_new_tokens=600,
|
| 453 |
-
enable_thinking=False,
|
| 454 |
-
)
|
| 455 |
|
| 456 |
-
parsed =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
|
| 458 |
problem = str(parsed.get("problem") or raw)
|
| 459 |
-
if not problem or problem.startswith("{"):
|
| 460 |
-
problem = str(parsed.get("content") or str(parsed))
|
| 461 |
-
if len(problem) < 3 or problem.startswith("{"):
|
| 462 |
-
problem = raw
|
| 463 |
solution = str(parsed.get("solution") or "")
|
| 464 |
competency_ref = str(parsed.get("competencyReference") or "DepEd competency-aligned")
|
| 465 |
|
|
@@ -511,4 +267,4 @@ async def rag_analysis_context(request: Request, payload: RagAnalysisContextRequ
|
|
| 511 |
chunks=chunks,
|
| 512 |
)
|
| 513 |
|
| 514 |
-
return {"curriculumContext": "\n".join(lines)}
|
|
|
|
| 2 |
|
| 3 |
import json
|
| 4 |
import logging
|
|
|
|
|
|
|
| 5 |
from datetime import datetime, timezone
|
| 6 |
from threading import Lock
|
| 7 |
from typing import Any, Dict, List, Optional
|
|
|
|
| 9 |
from fastapi import APIRouter, HTTPException, Request
|
| 10 |
from pydantic import BaseModel, Field
|
| 11 |
|
| 12 |
+
from services.inference_client import InferenceRequest, create_default_client
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
from rag.curriculum_rag import (
|
| 14 |
build_analysis_curriculum_context,
|
| 15 |
build_lesson_prompt,
|
| 16 |
build_lesson_query,
|
| 17 |
build_problem_generation_prompt,
|
|
|
|
| 18 |
retrieve_curriculum_context,
|
|
|
|
| 19 |
summarize_retrieval_confidence,
|
| 20 |
)
|
| 21 |
+
from rag.vectorstore_loader import get_vectorstore_health
|
| 22 |
|
| 23 |
try:
|
| 24 |
+
from firebase_admin import firestore as firebase_firestore # type: ignore[import-not-found]
|
| 25 |
except Exception:
|
| 26 |
+
firebase_firestore = None # type: ignore[assignment]
|
| 27 |
|
| 28 |
logger = logging.getLogger("mathpulse.rag")
|
| 29 |
router = APIRouter(prefix="/api/rag", tags=["rag"])
|
|
|
|
| 41 |
return _inference_client
|
| 42 |
|
| 43 |
|
| 44 |
+
async def _generate_text(prompt: str, task_type: str, max_new_tokens: int = 900) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
request = InferenceRequest(
|
| 46 |
messages=[
|
| 47 |
{"role": "system", "content": "You are a precise DepEd-aligned curriculum assistant."},
|
|
|
|
| 51 |
max_new_tokens=max_new_tokens,
|
| 52 |
temperature=0.2,
|
| 53 |
top_p=0.9,
|
|
|
|
| 54 |
)
|
| 55 |
return _get_inference_client().generate_from_messages(request)
|
| 56 |
|
|
|
|
| 88 |
logger.warning("rag_usage logging skipped: %s", exc)
|
| 89 |
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
class RagLessonRequest(BaseModel):
|
| 92 |
topic: str
|
| 93 |
subject: str
|
|
|
|
| 97 |
moduleUnit: Optional[str] = None
|
| 98 |
learnerLevel: Optional[str] = None
|
| 99 |
userId: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
|
| 102 |
class RagProblemRequest(BaseModel):
|
|
|
|
| 115 |
|
| 116 |
@router.get("/health")
|
| 117 |
async def rag_health():
|
|
|
|
|
|
|
| 118 |
try:
|
| 119 |
health = get_vectorstore_health()
|
| 120 |
return {
|
|
|
|
| 122 |
"chunkCount": health["chunkCount"],
|
| 123 |
"subjects": health["subjects"],
|
| 124 |
"lastIngested": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
|
|
| 125 |
}
|
| 126 |
except Exception as exc:
|
| 127 |
return {
|
|
|
|
| 129 |
"chunkCount": 0,
|
| 130 |
"subjects": {},
|
| 131 |
"lastIngested": None,
|
|
|
|
|
|
|
| 132 |
"warning": str(exc),
|
| 133 |
}
|
| 134 |
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
@router.post("/lesson")
|
| 137 |
async def rag_lesson(request: Request, payload: RagLessonRequest):
|
| 138 |
+
retrieval_query = build_lesson_query(
|
| 139 |
+
payload.topic,
|
| 140 |
+
payload.subject,
|
| 141 |
+
payload.quarter,
|
| 142 |
+
lesson_title=payload.lessonTitle,
|
| 143 |
+
competency=payload.learningCompetency,
|
| 144 |
+
module_unit=payload.moduleUnit,
|
| 145 |
+
learner_level=payload.learnerLevel,
|
| 146 |
+
)
|
| 147 |
+
chunks = retrieve_curriculum_context(
|
| 148 |
+
query=retrieval_query,
|
| 149 |
+
subject=payload.subject,
|
| 150 |
+
quarter=payload.quarter,
|
| 151 |
+
top_k=5,
|
| 152 |
+
)
|
| 153 |
+
prompt = build_lesson_prompt(
|
| 154 |
+
lesson_title=payload.lessonTitle or payload.topic,
|
| 155 |
+
competency=payload.learningCompetency or payload.topic,
|
| 156 |
+
grade_level="Grade 11-12",
|
| 157 |
+
subject=payload.subject,
|
| 158 |
+
quarter=payload.quarter,
|
| 159 |
+
learner_level=payload.learnerLevel,
|
| 160 |
+
module_unit=payload.moduleUnit,
|
| 161 |
+
curriculum_chunks=chunks,
|
| 162 |
+
)
|
| 163 |
+
explanation = await _generate_text(prompt, task_type="lesson_generation")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
retrieval_summary = summarize_retrieval_confidence(chunks)
|
| 165 |
|
| 166 |
+
_log_rag_usage(
|
| 167 |
+
request,
|
| 168 |
+
event_type="lesson",
|
| 169 |
+
topic=retrieval_query,
|
| 170 |
+
subject=payload.subject,
|
| 171 |
+
quarter=payload.quarter,
|
| 172 |
+
chunks=chunks,
|
| 173 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
return {
|
| 176 |
+
"explanation": explanation,
|
| 177 |
"retrievalConfidence": retrieval_summary.get("confidence", 0.0),
|
| 178 |
"retrievalBand": retrieval_summary.get("band", "low"),
|
| 179 |
+
"retrievalQuery": retrieval_query,
|
| 180 |
+
"needsReview": retrieval_summary.get("band", "low") == "low",
|
| 181 |
"sources": [
|
| 182 |
{
|
| 183 |
"subject": row.get("subject"),
|
| 184 |
"quarter": row.get("quarter"),
|
| 185 |
"source_file": row.get("source_file"),
|
|
|
|
| 186 |
"page": row.get("page"),
|
| 187 |
"score": row.get("score"),
|
| 188 |
+
"content": row.get("content"),
|
| 189 |
"content_domain": row.get("content_domain"),
|
| 190 |
"chunk_type": row.get("chunk_type"),
|
|
|
|
| 191 |
}
|
| 192 |
for row in chunks
|
| 193 |
],
|
|
|
|
| 194 |
}
|
| 195 |
|
| 196 |
|
|
|
|
| 203 |
top_k=5,
|
| 204 |
)
|
| 205 |
prompt = build_problem_generation_prompt(payload.topic, payload.difficulty, chunks)
|
| 206 |
+
raw = await _generate_text(prompt, task_type="quiz_generation")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
|
| 208 |
+
parsed: Dict[str, Any] = {}
|
| 209 |
+
cleaned = raw.strip()
|
| 210 |
+
if "{" in cleaned and "}" in cleaned:
|
| 211 |
+
try:
|
| 212 |
+
start = cleaned.find("{")
|
| 213 |
+
end = cleaned.rfind("}") + 1
|
| 214 |
+
parsed = json.loads(cleaned[start:end])
|
| 215 |
+
except Exception:
|
| 216 |
+
parsed = {}
|
| 217 |
|
| 218 |
problem = str(parsed.get("problem") or raw)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
solution = str(parsed.get("solution") or "")
|
| 220 |
competency_ref = str(parsed.get("competencyReference") or "DepEd competency-aligned")
|
| 221 |
|
|
|
|
| 267 |
chunks=chunks,
|
| 268 |
)
|
| 269 |
|
| 270 |
+
return {"curriculumContext": "\n".join(lines)}
|
routes/video_routes.py
DELETED
|
@@ -1,102 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Video lesson search routes for MathPulse AI.
|
| 3 |
-
POST /api/lessons/videos/search — smart YouTube video search with RAG enrichment.
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
from __future__ import annotations
|
| 7 |
-
|
| 8 |
-
import logging
|
| 9 |
-
from typing import List, Optional
|
| 10 |
-
|
| 11 |
-
from fastapi import APIRouter, HTTPException, Request
|
| 12 |
-
from pydantic import BaseModel, Field
|
| 13 |
-
|
| 14 |
-
from services.youtube_service import (
|
| 15 |
-
get_video_search_results,
|
| 16 |
-
YOUTUBE_API_KEY,
|
| 17 |
-
)
|
| 18 |
-
|
| 19 |
-
logger = logging.getLogger("mathpulse.videos")
|
| 20 |
-
router = APIRouter(prefix="/api/lessons/videos", tags=["videos"])
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
class VideoSearchRequest(BaseModel):
|
| 24 |
-
topic: str = Field(..., min_length=1, max_length=200)
|
| 25 |
-
grade_level: str = Field(default="Grade 11", max_length=50)
|
| 26 |
-
subject: str = Field(default="General Mathematics", max_length=100)
|
| 27 |
-
lesson_context: str = Field(default="", max_length=1000)
|
| 28 |
-
lesson_id: Optional[str] = Field(default=None, max_length=100)
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
class VideoResult(BaseModel):
|
| 32 |
-
videoId: str
|
| 33 |
-
title: str
|
| 34 |
-
channelTitle: str
|
| 35 |
-
thumbnailUrl: str
|
| 36 |
-
durationSeconds: int
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
class VideoSearchResponse(BaseModel):
|
| 40 |
-
videos: List[VideoResult]
|
| 41 |
-
cached: bool = False
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
@router.post("/search", response_model=VideoSearchResponse)
|
| 45 |
-
async def search_videos(request: Request, payload: VideoSearchRequest):
|
| 46 |
-
"""
|
| 47 |
-
Search for relevant educational YouTube videos for a lesson topic.
|
| 48 |
-
|
| 49 |
-
- Checks Firestore video_cache first (7-day TTL)
|
| 50 |
-
- Enriches the search query with RAG curriculum keywords
|
| 51 |
-
- Filters for educational channels, medium/long duration, HD quality
|
| 52 |
-
- Returns up to 3 video results
|
| 53 |
-
"""
|
| 54 |
-
# Graceful degradation: if YouTube API key is not configured, return 503
|
| 55 |
-
# so the frontend can hide the video section silently
|
| 56 |
-
if not YOUTUBE_API_KEY:
|
| 57 |
-
logger.warning("YouTube API key not configured")
|
| 58 |
-
raise HTTPException(
|
| 59 |
-
status_code=503,
|
| 60 |
-
detail={
|
| 61 |
-
"error": "youtube_api_not_configured",
|
| 62 |
-
"message": "YouTube API key is not configured on the server.",
|
| 63 |
-
},
|
| 64 |
-
)
|
| 65 |
-
|
| 66 |
-
try:
|
| 67 |
-
result = get_video_search_results(
|
| 68 |
-
topic=payload.topic,
|
| 69 |
-
subject=payload.subject,
|
| 70 |
-
lesson_context=payload.lesson_context,
|
| 71 |
-
grade_level=payload.grade_level,
|
| 72 |
-
lesson_id=payload.lesson_id,
|
| 73 |
-
max_results=3,
|
| 74 |
-
)
|
| 75 |
-
|
| 76 |
-
videos = [
|
| 77 |
-
VideoResult(
|
| 78 |
-
videoId=v["videoId"],
|
| 79 |
-
title=v["title"],
|
| 80 |
-
channelTitle=v["channelTitle"],
|
| 81 |
-
thumbnailUrl=v["thumbnailUrl"],
|
| 82 |
-
durationSeconds=v["durationSeconds"],
|
| 83 |
-
)
|
| 84 |
-
for v in result.get("videos", [])
|
| 85 |
-
]
|
| 86 |
-
|
| 87 |
-
return VideoSearchResponse(
|
| 88 |
-
videos=videos,
|
| 89 |
-
cached=result.get("cached", False),
|
| 90 |
-
)
|
| 91 |
-
|
| 92 |
-
except HTTPException:
|
| 93 |
-
raise
|
| 94 |
-
except Exception as exc:
|
| 95 |
-
logger.error("Video search endpoint error: %s", exc)
|
| 96 |
-
raise HTTPException(
|
| 97 |
-
status_code=500,
|
| 98 |
-
detail={
|
| 99 |
-
"error": "video_search_failed",
|
| 100 |
-
"message": f"Failed to search videos: {exc}",
|
| 101 |
-
},
|
| 102 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/download_vectorstore_from_firebase.py
CHANGED
|
@@ -1,11 +1,10 @@
|
|
| 1 |
"""
|
| 2 |
Download vectorstore directory from Firebase Storage at container startup.
|
| 3 |
-
Run: python
|
| 4 |
"""
|
| 5 |
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
-
import json
|
| 9 |
import logging
|
| 10 |
import os
|
| 11 |
import sys
|
|
@@ -13,66 +12,17 @@ from pathlib import Path
|
|
| 13 |
|
| 14 |
logger = logging.getLogger("mathpulse.download_vectorstore")
|
| 15 |
|
| 16 |
-
|
| 17 |
-
_FIREBASE_INITIALIZED = False
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def _init_firebase() -> any | None:
|
| 21 |
-
global _FIREBASE_INITIALIZED
|
| 22 |
-
|
| 23 |
-
if _FIREBASE_INITIALIZED:
|
| 24 |
-
try:
|
| 25 |
-
from firebase_admin import storage as fb_storage
|
| 26 |
-
return fb_storage.bucket()
|
| 27 |
-
except Exception as e:
|
| 28 |
-
logger.warning("Firebase storage unavailable: %s", e)
|
| 29 |
-
_FIREBASE_INITIALIZED = False
|
| 30 |
-
return None
|
| 31 |
-
|
| 32 |
-
try:
|
| 33 |
-
import firebase_admin
|
| 34 |
-
from firebase_admin import credentials, storage
|
| 35 |
-
except ImportError:
|
| 36 |
-
logger.warning("firebase_admin not installed")
|
| 37 |
-
return None
|
| 38 |
-
|
| 39 |
-
if firebase_admin._apps:
|
| 40 |
-
_FIREBASE_INITIALIZED = True
|
| 41 |
-
try:
|
| 42 |
-
return storage.bucket()
|
| 43 |
-
except Exception as e:
|
| 44 |
-
logger.warning("Firebase storage bucket unavailable: %s", e)
|
| 45 |
-
return None
|
| 46 |
-
|
| 47 |
-
sa_json = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON")
|
| 48 |
-
sa_file = os.getenv("FIREBASE_SERVICE_ACCOUNT_FILE")
|
| 49 |
-
bucket_name = os.getenv("FIREBASE_STORAGE_BUCKET", "mathpulse-ai-2026.firebasestorage.app")
|
| 50 |
|
| 51 |
-
|
| 52 |
-
if sa_json:
|
| 53 |
-
creds = credentials.Certificate(json.loads(sa_json))
|
| 54 |
-
elif sa_file and Path(sa_file).exists():
|
| 55 |
-
creds = credentials.Certificate(sa_file)
|
| 56 |
-
else:
|
| 57 |
-
creds = credentials.ApplicationDefault()
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
return storage.bucket()
|
| 62 |
-
except Exception as e:
|
| 63 |
-
logger.error("Firebase init failed: %s", e)
|
| 64 |
-
return None
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
def _resolve_dest_dir() -> Path:
|
| 68 |
-
raw = os.getenv("CURRICULUM_VECTORSTORE_DIR") or os.getenv("VECTORSTORE_DIR")
|
| 69 |
-
if raw:
|
| 70 |
-
return Path(raw)
|
| 71 |
-
return Path("/app/datasets/vectorstore")
|
| 72 |
|
| 73 |
|
| 74 |
def download_vectorstore(dest_dir: Path, prefix: str = REMOTE_PREFIX):
|
| 75 |
-
|
|
|
|
| 76 |
if bucket is None:
|
| 77 |
logger.warning("Firebase Storage not available, vectorstore download skipped")
|
| 78 |
return False
|
|
@@ -85,7 +35,6 @@ def download_vectorstore(dest_dir: Path, prefix: str = REMOTE_PREFIX):
|
|
| 85 |
return False
|
| 86 |
|
| 87 |
downloaded = 0
|
| 88 |
-
skipped = 0
|
| 89 |
errors = 0
|
| 90 |
|
| 91 |
for blob in blobs:
|
|
@@ -97,10 +46,6 @@ def download_vectorstore(dest_dir: Path, prefix: str = REMOTE_PREFIX):
|
|
| 97 |
local_path.parent.mkdir(parents=True, exist_ok=True)
|
| 98 |
|
| 99 |
try:
|
| 100 |
-
if local_path.exists() and blob.size is not None and local_path.stat().st_size == blob.size:
|
| 101 |
-
logger.info("Skipped (already up-to-date): %s", blob.name)
|
| 102 |
-
skipped += 1
|
| 103 |
-
continue
|
| 104 |
blob.download_to_filename(str(local_path))
|
| 105 |
logger.info("Downloaded: %s (%d bytes)", blob.name, blob.size or 0)
|
| 106 |
downloaded += 1
|
|
@@ -108,20 +53,10 @@ def download_vectorstore(dest_dir: Path, prefix: str = REMOTE_PREFIX):
|
|
| 108 |
logger.error("Failed to download %s: %s", blob.name, e)
|
| 109 |
errors += 1
|
| 110 |
|
| 111 |
-
logger.info("Download complete: %d downloaded, %d
|
| 112 |
return errors == 0
|
| 113 |
|
| 114 |
|
| 115 |
if __name__ == "__main__":
|
| 116 |
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
| 117 |
-
|
| 118 |
-
print(f"INFO: Using vectorstore destination: {dest_dir}")
|
| 119 |
-
print(f"INFO: CURRICULUM_VECTORSTORE_DIR env: {os.environ.get('CURRICULUM_VECTORSTORE_DIR', 'not set')}")
|
| 120 |
-
print(f"INFO: VECTORSTORE_DIR env: {os.environ.get('VECTORSTORE_DIR', 'not set')}")
|
| 121 |
-
print(f"INFO: FIREBASE_STORAGE_BUCKET env: {os.environ.get('FIREBASE_STORAGE_BUCKET', 'not set')}")
|
| 122 |
-
print(f"INFO: FIREBASE_SERVICE_ACCOUNT_JSON length: {len(os.environ.get('FIREBASE_SERVICE_ACCOUNT_JSON', ''))}")
|
| 123 |
-
result = download_vectorstore(dest_dir, REMOTE_PREFIX)
|
| 124 |
-
if result:
|
| 125 |
-
print("SUCCESS: Vectorstore download completed")
|
| 126 |
-
else:
|
| 127 |
-
print("FAILURE: Vectorstore download failed")
|
|
|
|
| 1 |
"""
|
| 2 |
Download vectorstore directory from Firebase Storage at container startup.
|
| 3 |
+
Run: python -m hf_space_test.scripts.download_vectorstore_from_firebase
|
| 4 |
"""
|
| 5 |
|
| 6 |
from __future__ import annotations
|
| 7 |
|
|
|
|
| 8 |
import logging
|
| 9 |
import os
|
| 10 |
import sys
|
|
|
|
| 12 |
|
| 13 |
logger = logging.getLogger("mathpulse.download_vectorstore")
|
| 14 |
|
| 15 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
+
from backend.rag.firebase_storage_loader import _init_firebase_storage
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
REMOTE_PREFIX = "vectorstore/"
|
| 20 |
+
LOCAL_DEST_DIR = Path("/app/datasets/vectorstore")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
def download_vectorstore(dest_dir: Path, prefix: str = REMOTE_PREFIX):
|
| 24 |
+
"""Download all files under a prefix from Firebase Storage, preserving structure."""
|
| 25 |
+
_, bucket = _init_firebase_storage()
|
| 26 |
if bucket is None:
|
| 27 |
logger.warning("Firebase Storage not available, vectorstore download skipped")
|
| 28 |
return False
|
|
|
|
| 35 |
return False
|
| 36 |
|
| 37 |
downloaded = 0
|
|
|
|
| 38 |
errors = 0
|
| 39 |
|
| 40 |
for blob in blobs:
|
|
|
|
| 46 |
local_path.parent.mkdir(parents=True, exist_ok=True)
|
| 47 |
|
| 48 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
blob.download_to_filename(str(local_path))
|
| 50 |
logger.info("Downloaded: %s (%d bytes)", blob.name, blob.size or 0)
|
| 51 |
downloaded += 1
|
|
|
|
| 53 |
logger.error("Failed to download %s: %s", blob.name, e)
|
| 54 |
errors += 1
|
| 55 |
|
| 56 |
+
logger.info("Download complete: %d files downloaded, %d errors", downloaded, errors)
|
| 57 |
return errors == 0
|
| 58 |
|
| 59 |
|
| 60 |
if __name__ == "__main__":
|
| 61 |
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
| 62 |
+
download_vectorstore(LOCAL_DEST_DIR, REMOTE_PREFIX)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/ingest_curriculum.py
CHANGED
|
@@ -1,159 +1,244 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
import argparse
|
| 4 |
-
import hashlib
|
| 5 |
import json
|
| 6 |
-
import logging
|
| 7 |
import os
|
| 8 |
-
import
|
|
|
|
|
|
|
| 9 |
from pathlib import Path
|
| 10 |
-
from typing import
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
from rag.vectorstore_loader import (
|
| 15 |
-
get_vectorstore_components,
|
| 16 |
-
reset_vectorstore_singleton,
|
| 17 |
-
)
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
|
| 70 |
def main() -> None:
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
ids: List[str] = []
|
| 99 |
-
embeddings_list: List[List[float]] = []
|
| 100 |
-
|
| 101 |
-
for record in records:
|
| 102 |
-
total_processed += 1
|
| 103 |
-
content = str(record.get("content") or "").strip()
|
| 104 |
-
if not content:
|
| 105 |
-
logger.debug("Skipping empty content in %s", file_path.name)
|
| 106 |
-
continue
|
| 107 |
-
|
| 108 |
-
try:
|
| 109 |
-
subject = str(record.get("subject") or "unknown")
|
| 110 |
-
quarter = int(record.get("quarter") or 0)
|
| 111 |
-
page = int(record.get("page") or 0)
|
| 112 |
-
content_domain = str(record.get("content_domain") or "unknown")
|
| 113 |
-
chunk_type = str(record.get("chunk_type") or "unknown")
|
| 114 |
-
source_file = str(record.get("source_file") or file_path.name)
|
| 115 |
-
|
| 116 |
-
embedding = embedder.encode(content).tolist()
|
| 117 |
-
chunk_id = _build_id(source_file, page, content)
|
| 118 |
|
| 119 |
metadata = {
|
| 120 |
"subject": subject,
|
| 121 |
"quarter": quarter,
|
| 122 |
-
"content_domain":
|
| 123 |
"chunk_type": chunk_type,
|
| 124 |
-
"source_file":
|
| 125 |
-
"page":
|
| 126 |
}
|
|
|
|
| 127 |
|
| 128 |
-
documents.append(
|
| 129 |
metadatas.append(metadata)
|
| 130 |
ids.append(chunk_id)
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
|
| 157 |
if __name__ == "__main__":
|
| 158 |
-
|
| 159 |
-
main()
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import json
|
|
|
|
| 4 |
import os
|
| 5 |
+
import re
|
| 6 |
+
from collections import Counter
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
from pathlib import Path
|
| 9 |
+
from typing import Dict, List
|
| 10 |
|
| 11 |
+
import chromadb
|
| 12 |
+
import pdfplumber
|
| 13 |
+
from huggingface_hub import snapshot_download
|
| 14 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 15 |
+
from sentence_transformers import SentenceTransformer
|
| 16 |
+
|
| 17 |
+
BASE_DIR = Path(__file__).resolve().parents[1]
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
+
def _resolve_default_dir(local_path: Path, data_path: Path) -> Path:
|
| 21 |
+
return data_path if data_path.parent.exists() else local_path
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
CURRICULUM_DIR = Path(
|
| 25 |
+
os.getenv(
|
| 26 |
+
"CURRICULUM_DIR",
|
| 27 |
+
str(_resolve_default_dir(BASE_DIR / "datasets" / "curriculum", Path("/data/curriculum"))),
|
| 28 |
+
)
|
| 29 |
+
)
|
| 30 |
+
VECTORSTORE_DIR = Path(
|
| 31 |
+
os.getenv(
|
| 32 |
+
"VECTORSTORE_DIR",
|
| 33 |
+
str(_resolve_default_dir(BASE_DIR / "datasets" / "vectorstore", Path("/data/vectorstore"))),
|
| 34 |
+
)
|
| 35 |
+
)
|
| 36 |
+
COLLECTION_NAME = "curriculum_chunks"
|
| 37 |
+
EMBED_MODEL_NAME = "BAAI/bge-small-en-v1.5"
|
| 38 |
+
CURRICULUM_SOURCE_REPO_ID = os.getenv("CURRICULUM_SOURCE_REPO_ID", "").strip()
|
| 39 |
+
CURRICULUM_SOURCE_REPO_TYPE = os.getenv("CURRICULUM_SOURCE_REPO_TYPE", "dataset").strip() or "dataset"
|
| 40 |
+
CURRICULUM_SOURCE_REVISION = os.getenv("CURRICULUM_SOURCE_REVISION", "main").strip() or "main"
|
| 41 |
+
|
| 42 |
+
SUBJECT_MAP = {
|
| 43 |
+
"SDO_Navotas_Gen.Math_SHS_1stSem.FV.pdf": "general_math",
|
| 44 |
+
"GENERAL-MATHEMATICS-1-2.pdf": "general_math",
|
| 45 |
+
"GENERAL-MATHEMATICS-1.pdf": "general_math",
|
| 46 |
+
"SDO_Navotas_Bus.Math_SHS_1stSem.FV-5.pdf": "business_math",
|
| 47 |
+
"SDO_Navotas_Bus.Math_SHS_1stSem.FV.pdf": "business_math",
|
| 48 |
+
"SDO_Navotas_STAT_PROB_SHS_1stSem.FV-3.pdf": "stat_prob",
|
| 49 |
+
"SDO_Navotas_STAT_PROB_SHS_1stSem.FV.pdf": "stat_prob",
|
| 50 |
+
"SDO_Navotas_SHS_ABM_OrgAndMngt_FirstSem_FV-4.pdf": "org_management",
|
| 51 |
+
"SDO_Navotas_SHS_ABM_OrgAndMngt_FirstSem_FV.pdf": "org_management",
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
QUARTER_HINTS = {
|
| 55 |
+
1: ["q1", "quarter 1", "business", "finance", "arithmetic sequence", "geometric sequence", "series"],
|
| 56 |
+
2: ["q2", "quarter 2", "measurement", "conversion", "functions", "piecewise", "statistics"],
|
| 57 |
+
3: ["q3", "quarter 3", "trigonometry", "practical measurements", "random variable", "sampling"],
|
| 58 |
+
4: ["q4", "quarter 4", "compound interest", "annuities", "loan", "hypothesis testing", "linear regression", "logic"],
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
DOMAIN_HINTS = {
|
| 62 |
+
"NA": ["number", "algebra", "sequence", "series", "interest", "annuity", "loan", "logic"],
|
| 63 |
+
"MG": ["measurement", "geometry", "trigonometry", "graph", "function", "piecewise"],
|
| 64 |
+
"DP": ["data", "probability", "statistics", "random variable", "sampling", "hypothesis", "regression"],
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
CHUNK_TYPE_HINTS = {
|
| 68 |
+
"learning_competency": ["learning competency", "code", "most essential learning", "melc", "competency"],
|
| 69 |
+
"example_problem": ["example", "solve", "problem", "exercise", "activity"],
|
| 70 |
+
"content_explanation": ["discussion", "content", "concept", "definition", "explain"],
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _norm(text: str) -> str:
|
| 75 |
+
return re.sub(r"\s+", " ", text.strip().lower())
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def infer_quarter(text: str) -> int:
|
| 79 |
+
probe = _norm(text)
|
| 80 |
+
for quarter, hints in QUARTER_HINTS.items():
|
| 81 |
+
if any(h in probe for h in hints):
|
| 82 |
+
return quarter
|
| 83 |
+
return 1
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def infer_domain(text: str) -> str:
|
| 87 |
+
probe = _norm(text)
|
| 88 |
+
scores: Dict[str, int] = {}
|
| 89 |
+
for domain, hints in DOMAIN_HINTS.items():
|
| 90 |
+
scores[domain] = sum(1 for hint in hints if hint in probe)
|
| 91 |
+
return max(scores, key=scores.get) if any(scores.values()) else "NA"
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def infer_chunk_type(text: str) -> str:
|
| 95 |
+
probe = _norm(text)
|
| 96 |
+
scores: Dict[str, int] = {}
|
| 97 |
+
for chunk_type, hints in CHUNK_TYPE_HINTS.items():
|
| 98 |
+
scores[chunk_type] = sum(1 for hint in hints if hint in probe)
|
| 99 |
+
best = max(scores, key=scores.get)
|
| 100 |
+
return best if scores[best] > 0 else "content_explanation"
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def extract_pdf_pages(pdf_path: Path) -> List[Dict[str, object]]:
|
| 104 |
+
rows: List[Dict[str, object]] = []
|
| 105 |
+
with pdfplumber.open(str(pdf_path)) as pdf:
|
| 106 |
+
for page_index, page in enumerate(pdf.pages, start=1):
|
| 107 |
+
page_text = page.extract_text() or ""
|
| 108 |
+
table_lines: List[str] = []
|
| 109 |
+
for table in page.extract_tables() or []:
|
| 110 |
+
for row in table:
|
| 111 |
+
cells = [str(cell).strip() for cell in (row or []) if str(cell or "").strip()]
|
| 112 |
+
if cells:
|
| 113 |
+
table_lines.append(" | ".join(cells))
|
| 114 |
+
combined = "\n".join([page_text, "\n".join(table_lines)]).strip()
|
| 115 |
+
if combined:
|
| 116 |
+
rows.append({"page": page_index, "text": combined})
|
| 117 |
+
return rows
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def chunk_text(page_text: str) -> List[str]:
|
| 121 |
+
splitter = RecursiveCharacterTextSplitter(
|
| 122 |
+
chunk_size=2000,
|
| 123 |
+
chunk_overlap=200,
|
| 124 |
+
separators=["\n\n", "\n", ". ", " ", ""],
|
| 125 |
+
)
|
| 126 |
+
return [chunk.strip() for chunk in splitter.split_text(page_text) if chunk.strip()]
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _ensure_curriculum_pdfs() -> List[Path]:
|
| 130 |
+
pdf_files = sorted(CURRICULUM_DIR.glob("*.pdf"))
|
| 131 |
+
if pdf_files:
|
| 132 |
+
return pdf_files
|
| 133 |
+
|
| 134 |
+
if not CURRICULUM_SOURCE_REPO_ID:
|
| 135 |
+
raise SystemExit(
|
| 136 |
+
"No PDF files found in datasets/curriculum/ and CURRICULUM_SOURCE_REPO_ID is not set. "
|
| 137 |
+
"Upload the PDFs to a Hugging Face repo and point CURRICULUM_SOURCE_REPO_ID at it."
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
snapshot_dir = Path(
|
| 141 |
+
snapshot_download(
|
| 142 |
+
repo_id=CURRICULUM_SOURCE_REPO_ID,
|
| 143 |
+
repo_type=CURRICULUM_SOURCE_REPO_TYPE,
|
| 144 |
+
revision=CURRICULUM_SOURCE_REVISION,
|
| 145 |
+
allow_patterns=["*.pdf", "**/*.pdf"],
|
| 146 |
+
)
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
source_pdfs = sorted(snapshot_dir.rglob("*.pdf"))
|
| 150 |
+
if not source_pdfs:
|
| 151 |
+
raise SystemExit(
|
| 152 |
+
f"No PDF files found in Hugging Face repo {CURRICULUM_SOURCE_REPO_TYPE}:{CURRICULUM_SOURCE_REPO_ID}@{CURRICULUM_SOURCE_REVISION}."
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
CURRICULUM_DIR.mkdir(parents=True, exist_ok=True)
|
| 156 |
+
for source_pdf in source_pdfs:
|
| 157 |
+
target_pdf = CURRICULUM_DIR / source_pdf.name
|
| 158 |
+
target_pdf.write_bytes(source_pdf.read_bytes())
|
| 159 |
+
|
| 160 |
+
return sorted(CURRICULUM_DIR.glob("*.pdf"))
|
| 161 |
|
| 162 |
|
| 163 |
def main() -> None:
|
| 164 |
+
if not CURRICULUM_DIR.exists():
|
| 165 |
+
raise SystemExit(f"Missing curriculum directory: {CURRICULUM_DIR}")
|
| 166 |
+
|
| 167 |
+
pdf_files = _ensure_curriculum_pdfs()
|
| 168 |
+
if not pdf_files:
|
| 169 |
+
raise SystemExit("No PDF files found in datasets/curriculum/")
|
| 170 |
+
|
| 171 |
+
VECTORSTORE_DIR.mkdir(parents=True, exist_ok=True)
|
| 172 |
+
|
| 173 |
+
documents: List[str] = []
|
| 174 |
+
metadatas: List[Dict[str, object]] = []
|
| 175 |
+
ids: List[str] = []
|
| 176 |
+
|
| 177 |
+
per_subject = Counter()
|
| 178 |
+
per_quarter = Counter()
|
| 179 |
+
per_domain = Counter()
|
| 180 |
+
|
| 181 |
+
for pdf_file in pdf_files:
|
| 182 |
+
subject = SUBJECT_MAP.get(pdf_file.name, "general_math")
|
| 183 |
+
page_rows = extract_pdf_pages(pdf_file)
|
| 184 |
+
for page_row in page_rows:
|
| 185 |
+
page_number = int(page_row["page"])
|
| 186 |
+
text = str(page_row["text"])
|
| 187 |
+
for idx, chunk in enumerate(chunk_text(text), start=1):
|
| 188 |
+
quarter = infer_quarter(chunk)
|
| 189 |
+
domain = infer_domain(chunk)
|
| 190 |
+
chunk_type = infer_chunk_type(chunk)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
metadata = {
|
| 193 |
"subject": subject,
|
| 194 |
"quarter": quarter,
|
| 195 |
+
"content_domain": domain,
|
| 196 |
"chunk_type": chunk_type,
|
| 197 |
+
"source_file": pdf_file.name,
|
| 198 |
+
"page": page_number,
|
| 199 |
}
|
| 200 |
+
chunk_id = f"{pdf_file.stem}-{page_number}-{idx}"
|
| 201 |
|
| 202 |
+
documents.append(chunk)
|
| 203 |
metadatas.append(metadata)
|
| 204 |
ids.append(chunk_id)
|
| 205 |
+
|
| 206 |
+
per_subject[subject] += 1
|
| 207 |
+
per_quarter[str(quarter)] += 1
|
| 208 |
+
per_domain[domain] += 1
|
| 209 |
+
|
| 210 |
+
embedder = SentenceTransformer(EMBED_MODEL_NAME)
|
| 211 |
+
embeddings = embedder.encode(documents, show_progress_bar=True).tolist()
|
| 212 |
+
|
| 213 |
+
client = chromadb.PersistentClient(path=str(VECTORSTORE_DIR))
|
| 214 |
+
existing = [c.name for c in client.list_collections()]
|
| 215 |
+
if COLLECTION_NAME in existing:
|
| 216 |
+
client.delete_collection(COLLECTION_NAME)
|
| 217 |
+
collection = client.create_collection(name=COLLECTION_NAME)
|
| 218 |
+
collection.add(ids=ids, documents=documents, metadatas=metadatas, embeddings=embeddings)
|
| 219 |
+
|
| 220 |
+
summary = {
|
| 221 |
+
"lastIngested": datetime.now(timezone.utc).isoformat(),
|
| 222 |
+
"totalChunks": len(documents),
|
| 223 |
+
"chunksPerSubject": dict(per_subject),
|
| 224 |
+
"chunksPerQuarter": dict(per_quarter),
|
| 225 |
+
"chunksPerDomain": dict(per_domain),
|
| 226 |
+
"sourceFiles": [pdf.name for pdf in pdf_files],
|
| 227 |
+
}
|
| 228 |
+
(VECTORSTORE_DIR / "ingest_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
| 229 |
+
|
| 230 |
+
print("=== Curriculum Ingestion Summary ===")
|
| 231 |
+
print(f"Total chunks: {summary['totalChunks']}")
|
| 232 |
+
print("Chunks per subject:")
|
| 233 |
+
for subject, count in sorted(per_subject.items()):
|
| 234 |
+
print(f" - {subject}: {count}")
|
| 235 |
+
print("Chunks per quarter:")
|
| 236 |
+
for quarter, count in sorted(per_quarter.items()):
|
| 237 |
+
print(f" - Q{quarter}: {count}")
|
| 238 |
+
print("Chunks per domain:")
|
| 239 |
+
for domain, count in sorted(per_domain.items()):
|
| 240 |
+
print(f" - {domain}: {count}")
|
| 241 |
|
| 242 |
|
| 243 |
if __name__ == "__main__":
|
| 244 |
+
main()
|
|
|
scripts/ingest_from_storage.py
DELETED
|
@@ -1,285 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Ingest curriculum PDFs from Firebase Storage into ChromaDB.
|
| 3 |
-
Run: python -m backend.scripts.ingest_from_storage
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
from __future__ import annotations
|
| 7 |
-
|
| 8 |
-
import logging
|
| 9 |
-
import os
|
| 10 |
-
import sys
|
| 11 |
-
from pathlib import Path
|
| 12 |
-
from typing import Any, Dict, List, Optional
|
| 13 |
-
|
| 14 |
-
logger = logging.getLogger("mathpulse.ingest")
|
| 15 |
-
|
| 16 |
-
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
| 17 |
-
|
| 18 |
-
from rag.firebase_storage_loader import (
|
| 19 |
-
PDF_METADATA,
|
| 20 |
-
download_pdf_from_storage,
|
| 21 |
-
list_curriculum_blobs,
|
| 22 |
-
)
|
| 23 |
-
|
| 24 |
-
_CONTENT_DOMAIN_CLASSIFIERS = [
|
| 25 |
-
("introduction", ["introduction", "welcome", "overview", "objectives", "learning objectives"]),
|
| 26 |
-
("key_concepts", ["key concepts", "key ideas", "main concepts", "definitions", "key terms"]),
|
| 27 |
-
("worked_examples", ["example", "worked example", "illustrative example", "sample problem", "solution"]),
|
| 28 |
-
("important_notes", ["important", "note", "remember", "tip", "caution", "warning", "key point"]),
|
| 29 |
-
("practice", ["practice", "exercise", "try it", "your turn", "activity", "problem set"]),
|
| 30 |
-
("summary", ["summary", "recap", "key takeaways", "wrap-up", "conclusion"]),
|
| 31 |
-
("assessment", ["assessment", "quiz", "test", "evaluation", "exam"]),
|
| 32 |
-
]
|
| 33 |
-
|
| 34 |
-
_CONTENT_TYPE_CLASSIFIERS = [
|
| 35 |
-
("definition", ["definition", "define", "means", "is defined as"]),
|
| 36 |
-
("formula", ["formula", "equation", "expression", "rule"]),
|
| 37 |
-
("procedure", ["step", "method", "how to", "procedure", "process"]),
|
| 38 |
-
("concept", ["concept", "idea", "principle", "theory"]),
|
| 39 |
-
("application", ["application", "use", "example", "solve", "problem"]),
|
| 40 |
-
]
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def _classify_chunk(content: str) -> tuple[str, str]:
|
| 44 |
-
content_lower = content.lower()
|
| 45 |
-
content_domain = "general"
|
| 46 |
-
chunk_type = "concept"
|
| 47 |
-
|
| 48 |
-
for domain, keywords in _CONTENT_DOMAIN_CLASSIFIERS:
|
| 49 |
-
if any(kw in content_lower for kw in keywords):
|
| 50 |
-
content_domain = domain
|
| 51 |
-
break
|
| 52 |
-
|
| 53 |
-
for ctype, keywords in _CONTENT_TYPE_CLASSIFIERS:
|
| 54 |
-
if any(kw in content_lower for kw in keywords):
|
| 55 |
-
chunk_type = ctype
|
| 56 |
-
break
|
| 57 |
-
|
| 58 |
-
return content_domain, chunk_type
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
def _classify_lesson_section(content: str) -> str:
|
| 62 |
-
content_lower = content.lower().strip()
|
| 63 |
-
first_sentence = content_lower[:200]
|
| 64 |
-
|
| 65 |
-
for domain, keywords in _CONTENT_DOMAIN_CLASSIFIERS:
|
| 66 |
-
if any(kw in first_sentence for kw in keywords):
|
| 67 |
-
return domain
|
| 68 |
-
return "general"
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
def chunk_text_preserve_pages(text: str, page_starts: List[int], chunk_size: int = 500, overlap: int = 80) -> List[Dict[str, Any]]:
|
| 72 |
-
"""Split text into overlapping chunks, preserving page traceability."""
|
| 73 |
-
# Filter out None/empty entries that can result from malformed PDF text extraction
|
| 74 |
-
words = [w for w in text.split() if w is not None and str(w).strip()]
|
| 75 |
-
chunks = []
|
| 76 |
-
i = 0
|
| 77 |
-
chunk_idx = 0
|
| 78 |
-
while i < len(words):
|
| 79 |
-
chunk_words = words[i : i + chunk_size]
|
| 80 |
-
chunk_text = " ".join(str(w) for w in chunk_words)
|
| 81 |
-
estimated_page = max(1, (i // chunk_size) + 1)
|
| 82 |
-
content_domain, chunk_type = _classify_chunk(chunk_text)
|
| 83 |
-
|
| 84 |
-
chunks.append({
|
| 85 |
-
"text": chunk_text,
|
| 86 |
-
"chunk_index": chunk_idx,
|
| 87 |
-
"estimated_page": estimated_page,
|
| 88 |
-
"content_domain": content_domain,
|
| 89 |
-
"chunk_type": chunk_type,
|
| 90 |
-
})
|
| 91 |
-
i += chunk_size - overlap
|
| 92 |
-
chunk_idx += 1
|
| 93 |
-
return chunks
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
def extract_pdf_text_and_pages(pdf_bytes: bytes) -> tuple[str, List[int]]:
|
| 97 |
-
"""Extract text from PDF bytes, returning full text and page start positions."""
|
| 98 |
-
try:
|
| 99 |
-
from pypdf import PdfReader
|
| 100 |
-
except ImportError:
|
| 101 |
-
try:
|
| 102 |
-
import PyPDF2 as PdfReaderModule
|
| 103 |
-
from PyPDF2 import PdfReader
|
| 104 |
-
except ImportError:
|
| 105 |
-
logger.error("No PDF library available. Install: pip install pypdf")
|
| 106 |
-
return "", []
|
| 107 |
-
|
| 108 |
-
import io
|
| 109 |
-
reader = PdfReader(io.BytesIO(pdf_bytes))
|
| 110 |
-
pages: List[str] = []
|
| 111 |
-
for page in reader.pages:
|
| 112 |
-
text = page.extract_text() or ""
|
| 113 |
-
pages.append(text)
|
| 114 |
-
|
| 115 |
-
page_starts = []
|
| 116 |
-
position = 0
|
| 117 |
-
for page_text in pages:
|
| 118 |
-
page_starts.append(position)
|
| 119 |
-
position += len(page_text) + 1
|
| 120 |
-
|
| 121 |
-
full_text = "\n".join(pages)
|
| 122 |
-
return full_text, page_starts
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
def get_firestore_client():
|
| 126 |
-
try:
|
| 127 |
-
import firebase_admin
|
| 128 |
-
from firebase_admin import firestore
|
| 129 |
-
if not firebase_admin._apps:
|
| 130 |
-
sa_json = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON")
|
| 131 |
-
sa_file = os.getenv("FIREBASE_SERVICE_ACCOUNT_FILE")
|
| 132 |
-
bucket_name = os.getenv("FIREBASE_STORAGE_BUCKET", "mathpulse-ai-2026.firebasestorage.app")
|
| 133 |
-
if sa_json:
|
| 134 |
-
import json as _json
|
| 135 |
-
from firebase_admin import credentials
|
| 136 |
-
creds = credentials.Certificate(_json.loads(sa_json))
|
| 137 |
-
firebase_admin.initialize_app(creds, {"storageBucket": bucket_name})
|
| 138 |
-
elif sa_file and Path(sa_file).exists():
|
| 139 |
-
from firebase_admin import credentials
|
| 140 |
-
creds = credentials.Certificate(sa_file)
|
| 141 |
-
firebase_admin.initialize_app(creds, {"storageBucket": bucket_name})
|
| 142 |
-
else:
|
| 143 |
-
firebase_admin.initialize_app(options={"storageBucket": bucket_name})
|
| 144 |
-
return firestore.client()
|
| 145 |
-
except Exception as e:
|
| 146 |
-
logger.warning("Firestore unavailable: %s", e)
|
| 147 |
-
return None
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
def ingest_from_firebase_storage(force_reindex: bool = False):
|
| 151 |
-
"""Download PDFs from Firebase Storage and ingest into ChromaDB."""
|
| 152 |
-
try:
|
| 153 |
-
from sentence_transformers import SentenceTransformer
|
| 154 |
-
import chromadb
|
| 155 |
-
except ImportError:
|
| 156 |
-
logger.error("Missing dependencies. Install: pip install chromadb sentence-transformers pypdf")
|
| 157 |
-
return
|
| 158 |
-
|
| 159 |
-
chroma_path = os.getenv("CURRICULUM_VECTORSTORE_DIR", "datasets/vectorstore")
|
| 160 |
-
chroma_client = chromadb.PersistentClient(path=chroma_path)
|
| 161 |
-
collection = chroma_client.get_or_create_collection(
|
| 162 |
-
name="curriculum_chunks",
|
| 163 |
-
metadata={"hnsw:space": "cosine"},
|
| 164 |
-
)
|
| 165 |
-
embedder = SentenceTransformer("BAAI/bge-base-en-v1.5")
|
| 166 |
-
|
| 167 |
-
db = get_firestore_client()
|
| 168 |
-
|
| 169 |
-
logger.info("Starting ingestion from Firebase Storage...")
|
| 170 |
-
ingested_count = 0
|
| 171 |
-
skipped_count = 0
|
| 172 |
-
error_count = 0
|
| 173 |
-
|
| 174 |
-
for storage_path, metadata in PDF_METADATA.items():
|
| 175 |
-
doc_id = storage_path.replace("/", "_").replace(".pdf", "")
|
| 176 |
-
|
| 177 |
-
if db:
|
| 178 |
-
try:
|
| 179 |
-
doc_ref = db.collection("curriculumDocuments").document(doc_id)
|
| 180 |
-
existing = doc_ref.get()
|
| 181 |
-
if existing.exists:
|
| 182 |
-
if not force_reindex and existing.to_dict().get("status") == "ingested":
|
| 183 |
-
logger.info("[SKIP] %s already ingested", storage_path)
|
| 184 |
-
skipped_count += 1
|
| 185 |
-
continue
|
| 186 |
-
except Exception as e:
|
| 187 |
-
logger.warning("Firestore check failed for %s: %s", storage_path, e)
|
| 188 |
-
|
| 189 |
-
logger.info("Downloading: %s", storage_path)
|
| 190 |
-
pdf_bytes = download_pdf_from_storage(storage_path)
|
| 191 |
-
if pdf_bytes is None:
|
| 192 |
-
logger.error("[ERROR] Failed to download: %s", storage_path)
|
| 193 |
-
if db:
|
| 194 |
-
try:
|
| 195 |
-
doc_ref.set({
|
| 196 |
-
"storagePath": storage_path,
|
| 197 |
-
"status": "failed",
|
| 198 |
-
"error": "download_failed",
|
| 199 |
-
**metadata,
|
| 200 |
-
}, merge=True)
|
| 201 |
-
except:
|
| 202 |
-
pass
|
| 203 |
-
error_count += 1
|
| 204 |
-
continue
|
| 205 |
-
|
| 206 |
-
logger.info("Extracting text from: %s (%d bytes)", storage_path, len(pdf_bytes))
|
| 207 |
-
full_text, page_starts = extract_pdf_text_and_pages(pdf_bytes)
|
| 208 |
-
if not full_text.strip():
|
| 209 |
-
logger.warning("[WARN] No text extracted from: %s", storage_path)
|
| 210 |
-
error_count += 1
|
| 211 |
-
continue
|
| 212 |
-
|
| 213 |
-
chunks = chunk_text_preserve_pages(full_text, page_starts)
|
| 214 |
-
logger.info(" -> %d chunks created", len(chunks))
|
| 215 |
-
|
| 216 |
-
existing_ids = [cid for cid in collection.get()["ids"] if cid.startswith(f"{doc_id}_chunk_")]
|
| 217 |
-
if existing_ids:
|
| 218 |
-
collection.delete(ids=existing_ids)
|
| 219 |
-
logger.info(" Removed %d existing chunks", len(existing_ids))
|
| 220 |
-
|
| 221 |
-
for chunk in chunks:
|
| 222 |
-
chunk_text = chunk.get("text", "")
|
| 223 |
-
if not isinstance(chunk_text, str) or not chunk_text.strip():
|
| 224 |
-
logger.warning(" Skipping empty/invalid chunk %s (type=%s, len=%d)", chunk.get("chunk_index"), type(chunk_text), len(chunk_text))
|
| 225 |
-
continue
|
| 226 |
-
chunk_id = f"{doc_id}_chunk_{chunk['chunk_index']}"
|
| 227 |
-
try:
|
| 228 |
-
embedding = embedder.encode(chunk_text, normalize_embeddings=True).tolist()
|
| 229 |
-
except Exception as enc_err:
|
| 230 |
-
logger.warning(" Skipping unencodable chunk %s: %s", chunk.get("chunk_index"), enc_err)
|
| 231 |
-
continue
|
| 232 |
-
|
| 233 |
-
collection.add(
|
| 234 |
-
embeddings=[embedding],
|
| 235 |
-
documents=[chunk_text],
|
| 236 |
-
metadatas=[{
|
| 237 |
-
"document_id": doc_id,
|
| 238 |
-
"module_id": metadata.get("subjectId", ""),
|
| 239 |
-
"lesson_id": f"lesson-{doc_id}",
|
| 240 |
-
"title": metadata.get("subject", ""),
|
| 241 |
-
"subject": metadata.get("subject", ""),
|
| 242 |
-
"subjectId": metadata.get("subjectId", ""),
|
| 243 |
-
"quarter": metadata.get("quarter", 1),
|
| 244 |
-
"competency_code": metadata.get("competency_code", ""),
|
| 245 |
-
"content_domain": chunk["content_domain"],
|
| 246 |
-
"chunk_type": chunk["chunk_type"],
|
| 247 |
-
"source_file": storage_path.split("/")[-1],
|
| 248 |
-
"storage_path": storage_path,
|
| 249 |
-
"page": chunk["estimated_page"],
|
| 250 |
-
"chunk_index": chunk["chunk_index"],
|
| 251 |
-
"type": metadata.get("type", ""),
|
| 252 |
-
}],
|
| 253 |
-
ids=[chunk_id],
|
| 254 |
-
)
|
| 255 |
-
|
| 256 |
-
if db:
|
| 257 |
-
try:
|
| 258 |
-
doc_ref.set({
|
| 259 |
-
"id": doc_id,
|
| 260 |
-
"storagePath": storage_path,
|
| 261 |
-
"status": "ingested",
|
| 262 |
-
"ingestedAt": __import__("firebase_admin").firestore.SERVER_TIMESTAMP,
|
| 263 |
-
"chunkCount": len(chunks),
|
| 264 |
-
**metadata,
|
| 265 |
-
}, merge=True)
|
| 266 |
-
except Exception as e:
|
| 267 |
-
logger.warning("Firestore update failed: %s", e)
|
| 268 |
-
|
| 269 |
-
logger.info("[OK] Ingested %s (%d chunks)", storage_path, len(chunks))
|
| 270 |
-
ingested_count += 1
|
| 271 |
-
|
| 272 |
-
logger.info("=" * 50)
|
| 273 |
-
logger.info("Ingestion complete: %d ingested, %d skipped, %d errors", ingested_count, skipped_count, error_count)
|
| 274 |
-
logger.info("Total chunks in ChromaDB: %d", collection.count())
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
if __name__ == "__main__":
|
| 278 |
-
import argparse
|
| 279 |
-
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
| 280 |
-
|
| 281 |
-
parser = argparse.ArgumentParser(description="Ingest curriculum PDFs from Firebase Storage into ChromaDB")
|
| 282 |
-
parser.add_argument("--force", action="store_true", help="Re-ingest even if already ingested")
|
| 283 |
-
args = parser.parse_args()
|
| 284 |
-
|
| 285 |
-
ingest_from_firebase_storage(force_reindex=args.force)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/migrate_grade12_to_grade11.py
DELETED
|
@@ -1,107 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Migrate Grade 12 users to Grade 11.
|
| 3 |
-
|
| 4 |
-
Run this to convert all existing Grade 12 users to Grade 11:
|
| 5 |
-
python backend/scripts/migrate_grade12_to_grade11.py
|
| 6 |
-
|
| 7 |
-
This handles:
|
| 8 |
-
- Firestore user profiles
|
| 9 |
-
- Progress records
|
| 10 |
-
- Any references to Grade 12
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
-
import logging
|
| 14 |
-
import os
|
| 15 |
-
import sys
|
| 16 |
-
from pathlib import Path
|
| 17 |
-
|
| 18 |
-
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 19 |
-
|
| 20 |
-
logger = logging.getLogger(__name__)
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def migrate_grade_12_to_grade_11():
|
| 24 |
-
"""Migrate all Grade 12 users to Grade 11."""
|
| 25 |
-
try:
|
| 26 |
-
import firebase_admin
|
| 27 |
-
from firebase_admin import firestore
|
| 28 |
-
|
| 29 |
-
svc_account = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON")
|
| 30 |
-
if svc_account:
|
| 31 |
-
import json
|
| 32 |
-
from firebase_admin import credentials
|
| 33 |
-
creds = credentials.Certificate(json.loads(svc_account))
|
| 34 |
-
firebase_admin.initialize_app(creds)
|
| 35 |
-
else:
|
| 36 |
-
firebase_admin.initialize_app()
|
| 37 |
-
|
| 38 |
-
db = firestore.client()
|
| 39 |
-
print("Firebase initialized")
|
| 40 |
-
|
| 41 |
-
except Exception as e:
|
| 42 |
-
print(f"Failed to initialize Firebase: {e}")
|
| 43 |
-
return
|
| 44 |
-
|
| 45 |
-
# Count migrations
|
| 46 |
-
users_migrated = 0
|
| 47 |
-
progress_migrated = 0
|
| 48 |
-
|
| 49 |
-
# Migrate users collection
|
| 50 |
-
print("\n--- Migrating users ---")
|
| 51 |
-
users_ref = db.collection("users")
|
| 52 |
-
|
| 53 |
-
# Batch update for users
|
| 54 |
-
batch = db.batch()
|
| 55 |
-
user_count = 0
|
| 56 |
-
|
| 57 |
-
for doc in users_ref.stream():
|
| 58 |
-
data = doc.to_dict()
|
| 59 |
-
if data.get("grade") == "Grade 12":
|
| 60 |
-
batch.update(doc.reference, {"grade": "Grade 11"})
|
| 61 |
-
user_count += 1
|
| 62 |
-
print(f" Migrating user: {doc.id} ({data.get('name', 'Unknown')})")
|
| 63 |
-
|
| 64 |
-
if user_count >= 500:
|
| 65 |
-
batch.commit()
|
| 66 |
-
users_migrated += user_count
|
| 67 |
-
user_count = 0
|
| 68 |
-
batch = db.batch()
|
| 69 |
-
|
| 70 |
-
if user_count > 0:
|
| 71 |
-
batch.commit()
|
| 72 |
-
users_migrated += user_count
|
| 73 |
-
|
| 74 |
-
print(f" => Migrated {users_migrated} users to Grade 11")
|
| 75 |
-
|
| 76 |
-
# Migrate progress collection
|
| 77 |
-
print("\n--- Migrating progress ---")
|
| 78 |
-
progress_ref = db.collection("progress")
|
| 79 |
-
batch = db.batch()
|
| 80 |
-
progress_count = 0
|
| 81 |
-
|
| 82 |
-
for doc in progress_ref.stream():
|
| 83 |
-
data = doc.to_dict()
|
| 84 |
-
if data.get("gradeLevel") == "Grade 12":
|
| 85 |
-
batch.update(doc.reference, {"gradeLevel": "Grade 11"})
|
| 86 |
-
progress_count += 1
|
| 87 |
-
|
| 88 |
-
if progress_count >= 500:
|
| 89 |
-
batch.commit()
|
| 90 |
-
progress_migrated += progress_count
|
| 91 |
-
progress_count = 0
|
| 92 |
-
batch = db.batch()
|
| 93 |
-
|
| 94 |
-
if progress_count > 0:
|
| 95 |
-
batch.commit()
|
| 96 |
-
progress_migrated += progress_count
|
| 97 |
-
|
| 98 |
-
print(f" => Migrated {progress_migrated} progress records to Grade 11")
|
| 99 |
-
|
| 100 |
-
print(f"\n=== Migration complete ===")
|
| 101 |
-
print(f"Users migrated: {users_migrated}")
|
| 102 |
-
print(f"Progress migrated: {progress_migrated}")
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
if __name__ == "__main__":
|
| 106 |
-
logging.basicConfig(level=logging.INFO)
|
| 107 |
-
migrate_grade_12_to_grade_11()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/register_firestore_metadata.py
DELETED
|
@@ -1,183 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Register curriculum document metadata in Firestore.
|
| 3 |
-
Populates the curriculumDocuments collection so the app can display
|
| 4 |
-
lessons mapped to their source PDFs before ingestion.
|
| 5 |
-
|
| 6 |
-
Run: python backend/scripts/register_firestore_metadata.py
|
| 7 |
-
"""
|
| 8 |
-
|
| 9 |
-
from __future__ import annotations
|
| 10 |
-
|
| 11 |
-
import os
|
| 12 |
-
import sys
|
| 13 |
-
from pathlib import Path
|
| 14 |
-
|
| 15 |
-
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def _get_firestore_client():
|
| 19 |
-
try:
|
| 20 |
-
import firebase_admin
|
| 21 |
-
from firebase_admin import firestore
|
| 22 |
-
if not firebase_admin._apps:
|
| 23 |
-
sa_json = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON")
|
| 24 |
-
sa_file = os.getenv("FIREBASE_SERVICE_ACCOUNT_FILE")
|
| 25 |
-
bucket_name = os.getenv("FIREBASE_STORAGE_BUCKET", "mathpulse-ai-2026.firebasestorage.app")
|
| 26 |
-
if sa_json:
|
| 27 |
-
import json as _json
|
| 28 |
-
from firebase_admin import credentials
|
| 29 |
-
creds = credentials.Certificate(_json.loads(sa_json))
|
| 30 |
-
firebase_admin.initialize_app(creds, {"storageBucket": bucket_name})
|
| 31 |
-
elif sa_file and Path(sa_file).exists():
|
| 32 |
-
from firebase_admin import credentials
|
| 33 |
-
creds = credentials.Certificate(sa_file)
|
| 34 |
-
firebase_admin.initialize_app(creds, {"storageBucket": bucket_name})
|
| 35 |
-
else:
|
| 36 |
-
firebase_admin.initialize_app(options={"storageBucket": bucket_name})
|
| 37 |
-
return firestore.client()
|
| 38 |
-
except Exception as e:
|
| 39 |
-
print(f"Firestore init failed: {e}")
|
| 40 |
-
return None
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
CURRICULUM_DOCUMENTS = [
|
| 44 |
-
{
|
| 45 |
-
"id": "gm_lesson_1",
|
| 46 |
-
"moduleId": "gm-q1-business-finance",
|
| 47 |
-
"lessonId": "gm-q1-bf-1",
|
| 48 |
-
"title": "Represent business transactions and financial goals using variables and equations.",
|
| 49 |
-
"subject": "General Mathematics",
|
| 50 |
-
"subjectId": "gen-math",
|
| 51 |
-
"quarter": 1,
|
| 52 |
-
"competencyCode": "GM11-BF-1",
|
| 53 |
-
"learningCompetency": "Represent business transactions and financial goals using variables and equations.",
|
| 54 |
-
"storagePath": "curriculum/general_math/GENERAL-MATHEMATICS-1.pdf",
|
| 55 |
-
"status": "uploaded",
|
| 56 |
-
},
|
| 57 |
-
{
|
| 58 |
-
"id": "gm_navotas_lesson_1",
|
| 59 |
-
"moduleId": "gm-q1-patterns-sequences-series",
|
| 60 |
-
"lessonId": "gm-q1-pss-1",
|
| 61 |
-
"title": "Identify and describe arithmetic and geometric patterns in data.",
|
| 62 |
-
"subject": "General Mathematics",
|
| 63 |
-
"subjectId": "gen-math",
|
| 64 |
-
"quarter": 1,
|
| 65 |
-
"competencyCode": "GM11-PSS-1",
|
| 66 |
-
"learningCompetency": "Identify and describe arithmetic and geometric patterns in data.",
|
| 67 |
-
"storagePath": "curriculum/gen_math_sdo/SDO_Navotas_Gen.Math_SHS_1stSem.FV.pdf",
|
| 68 |
-
"status": "uploaded",
|
| 69 |
-
},
|
| 70 |
-
{
|
| 71 |
-
"id": "bm_lesson_1",
|
| 72 |
-
"moduleId": "bm-q1-business-math",
|
| 73 |
-
"lessonId": "bm-q1-1",
|
| 74 |
-
"title": "Translate verbal phrases to mathematical expressions; model business scenarios using linear equations and inequalities.",
|
| 75 |
-
"subject": "Business Mathematics",
|
| 76 |
-
"subjectId": "business-math",
|
| 77 |
-
"quarter": 1,
|
| 78 |
-
"competencyCode": "ABM_BM11BS-Ia-b-1",
|
| 79 |
-
"learningCompetency": "Translate verbal phrases to mathematical expressions; model business scenarios using linear equations and inequalities.",
|
| 80 |
-
"storagePath": "curriculum/business_math/SDO_Navotas_Bus.Math_SHS_1stSem.FV.pdf",
|
| 81 |
-
"status": "uploaded",
|
| 82 |
-
},
|
| 83 |
-
{
|
| 84 |
-
"id": "stat_lesson_1",
|
| 85 |
-
"moduleId": "stat-q1-probability",
|
| 86 |
-
"lessonId": "stat-q1-1",
|
| 87 |
-
"title": "Define and describe random variables and their types.",
|
| 88 |
-
"subject": "Statistics and Probability",
|
| 89 |
-
"subjectId": "stats-prob",
|
| 90 |
-
"quarter": 1,
|
| 91 |
-
"competencyCode": "SP_SHS11-Ia-1",
|
| 92 |
-
"learningCompetency": "Define and describe random variables and their types.",
|
| 93 |
-
"storagePath": "curriculum/stat_prob/SDO_Navotas_STAT_PROB_SHS_1stSem.FV.pdf",
|
| 94 |
-
"status": "uploaded",
|
| 95 |
-
},
|
| 96 |
-
{
|
| 97 |
-
"id": "fm1_lesson_1",
|
| 98 |
-
"moduleId": "fm1-q1-counting",
|
| 99 |
-
"lessonId": "fm1-q1-fpc-1",
|
| 100 |
-
"title": "Apply the fundamental counting principle in contextual problems.",
|
| 101 |
-
"subject": "Finite Mathematics 1",
|
| 102 |
-
"subjectId": "finite-math-1",
|
| 103 |
-
"quarter": 1,
|
| 104 |
-
"competencyCode": "FM1-SHS11-Ia-1",
|
| 105 |
-
"learningCompetency": "Apply the fundamental counting principle in contextual problems.",
|
| 106 |
-
"storagePath": "curriculum/finite_math/Finite-Mathematics-1-1.pdf",
|
| 107 |
-
"status": "uploaded",
|
| 108 |
-
},
|
| 109 |
-
{
|
| 110 |
-
"id": "fm2_lesson_1",
|
| 111 |
-
"moduleId": "fm2-q1-matrices",
|
| 112 |
-
"lessonId": "fm2-q1-matrices-1",
|
| 113 |
-
"title": "Represent contextual data using matrix notation.",
|
| 114 |
-
"subject": "Finite Mathematics 2",
|
| 115 |
-
"subjectId": "finite-math-2",
|
| 116 |
-
"quarter": 1,
|
| 117 |
-
"competencyCode": "FM2-SHS11-Ia-1",
|
| 118 |
-
"learningCompetency": "Represent contextual data using matrix notation.",
|
| 119 |
-
"storagePath": "curriculum/finite_math/Finite-Mathematics-2-1.pdf",
|
| 120 |
-
"status": "uploaded",
|
| 121 |
-
},
|
| 122 |
-
{
|
| 123 |
-
"id": "org_mgmt_lesson_1",
|
| 124 |
-
"moduleId": "org-mgmt-q1",
|
| 125 |
-
"lessonId": "org-mgmt-q1-1",
|
| 126 |
-
"title": "Understand the fundamental concepts of organization and management.",
|
| 127 |
-
"subject": "Organization and Management",
|
| 128 |
-
"subjectId": "org-mgmt",
|
| 129 |
-
"quarter": 1,
|
| 130 |
-
"competencyCode": "ABM_OM11-Ia-1",
|
| 131 |
-
"learningCompetency": "Understand the fundamental concepts of organization and management.",
|
| 132 |
-
"storagePath": "curriculum/org_mgmt/SDO_Navotas_SHS_ABM_OrgAndMngt_FirstSem_FV.pdf",
|
| 133 |
-
"status": "uploaded",
|
| 134 |
-
},
|
| 135 |
-
]
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
def register_metadata(force: bool = False):
|
| 139 |
-
db = _get_firestore_client()
|
| 140 |
-
if db is None:
|
| 141 |
-
print("ERROR: Cannot connect to Firestore. Check credentials.")
|
| 142 |
-
print("Set FIREBASE_SERVICE_ACCOUNT_JSON or place mathpulse-sa.json in backend/ directory.")
|
| 143 |
-
return
|
| 144 |
-
|
| 145 |
-
print("Connected to Firestore.")
|
| 146 |
-
print("-" * 50)
|
| 147 |
-
|
| 148 |
-
registered = 0
|
| 149 |
-
skipped = 0
|
| 150 |
-
updated = 0
|
| 151 |
-
|
| 152 |
-
for doc in CURRICULUM_DOCUMENTS:
|
| 153 |
-
doc_id = doc["id"]
|
| 154 |
-
doc_ref = db.collection("curriculumDocuments").document(doc_id)
|
| 155 |
-
existing = doc_ref.get()
|
| 156 |
-
|
| 157 |
-
if existing.exists and not force:
|
| 158 |
-
print(f"[SKIP] {doc_id} already registered")
|
| 159 |
-
skipped += 1
|
| 160 |
-
continue
|
| 161 |
-
|
| 162 |
-
if existing.exists and force:
|
| 163 |
-
updated += 1
|
| 164 |
-
else:
|
| 165 |
-
registered += 1
|
| 166 |
-
|
| 167 |
-
data = {
|
| 168 |
-
**doc,
|
| 169 |
-
"uploadedAt": None,
|
| 170 |
-
}
|
| 171 |
-
doc_ref.set(data, merge=True)
|
| 172 |
-
print(f"[OK] {'Updated' if force and existing.exists else 'Registered'} {doc_id} -> {doc.get('storagePath')}")
|
| 173 |
-
|
| 174 |
-
print("-" * 50)
|
| 175 |
-
print(f"Done: {registered} registered, {skipped} skipped, {updated} updated.")
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
if __name__ == "__main__":
|
| 179 |
-
import argparse
|
| 180 |
-
parser = argparse.ArgumentParser(description="Register curriculum document metadata in Firestore")
|
| 181 |
-
parser.add_argument("--force", action="store_true", help="Overwrite existing records")
|
| 182 |
-
args = parser.parse_args()
|
| 183 |
-
register_metadata(force=args.force)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/seed_curriculum.py
DELETED
|
@@ -1,64 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Seed Firestore curriculum collection from static data.
|
| 3 |
-
|
| 4 |
-
Run this ONCE to migrate static curriculum to Firestore:
|
| 5 |
-
python backend/scripts/seed_curriculum.py
|
| 6 |
-
|
| 7 |
-
After seeding, the curriculum API will read from Firestore.
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
import logging
|
| 11 |
-
import json
|
| 12 |
-
import os
|
| 13 |
-
import sys
|
| 14 |
-
from pathlib import Path
|
| 15 |
-
|
| 16 |
-
# Add backend to path
|
| 17 |
-
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 18 |
-
|
| 19 |
-
from services.curriculum_service import _STATIC_SUBJECTS
|
| 20 |
-
|
| 21 |
-
logger = logging.getLogger(__name__)
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def seed_curriculum():
|
| 25 |
-
"""Seed curriculum subjects to Firestore."""
|
| 26 |
-
try:
|
| 27 |
-
import firebase_admin
|
| 28 |
-
from firebase_admin import firestore, credentials
|
| 29 |
-
|
| 30 |
-
# Initialize Firebase
|
| 31 |
-
svc_account = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON")
|
| 32 |
-
if svc_account:
|
| 33 |
-
sa_creds = credentials.Certificate(json.loads(svc_account))
|
| 34 |
-
firebase_admin.initialize_app(sa_creds)
|
| 35 |
-
else:
|
| 36 |
-
firebase_admin.initialize_app()
|
| 37 |
-
|
| 38 |
-
db = firestore.client()
|
| 39 |
-
print("Firebase initialized")
|
| 40 |
-
|
| 41 |
-
except Exception as e:
|
| 42 |
-
print(f"Failed to initialize Firebase: {e}")
|
| 43 |
-
return
|
| 44 |
-
|
| 45 |
-
# Seed new subjects
|
| 46 |
-
subjects_ref = db.collection("subjects")
|
| 47 |
-
count = 0
|
| 48 |
-
|
| 49 |
-
for subject in _STATIC_SUBJECTS:
|
| 50 |
-
doc_ref = subjects_ref.document(subject["id"])
|
| 51 |
-
doc_ref.set(subject)
|
| 52 |
-
count += 1
|
| 53 |
-
print(f" Seeded: {subject['id']} - {subject['name']} ({len(subject.get('topics', []))} topics)")
|
| 54 |
-
|
| 55 |
-
print(f"\nSeeded {count} subjects to Firestore")
|
| 56 |
-
print("\nCurriculum is now available at:")
|
| 57 |
-
print(" GET /api/curriculum/subjects")
|
| 58 |
-
print(" GET /api/curriculum/subjects/{id}")
|
| 59 |
-
print(" GET /api/curriculum/subjects/{id}/topics")
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
if __name__ == "__main__":
|
| 63 |
-
logging.basicConfig(level=logging.INFO)
|
| 64 |
-
seed_curriculum()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/upload_curriculum_pdfs.py
DELETED
|
@@ -1,264 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Upload DepEd curriculum PDFs to Firebase Storage.
|
| 3 |
-
Run once during initial setup: python scripts/upload_curriculum_pdfs.py
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
from __future__ import annotations
|
| 7 |
-
|
| 8 |
-
import os
|
| 9 |
-
import sys
|
| 10 |
-
from pathlib import Path
|
| 11 |
-
from typing import Dict, List
|
| 12 |
-
|
| 13 |
-
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 14 |
-
|
| 15 |
-
LOCAL_PDF_DIR = r"C:\Users\Deign\Downloads\Documents"
|
| 16 |
-
|
| 17 |
-
PDF_METADATA: Dict[str, Dict[str, object]] = {
|
| 18 |
-
"GENERAL-MATHEMATICS-1.pdf": {
|
| 19 |
-
"subject": "General Mathematics",
|
| 20 |
-
"type": "curriculum_guide",
|
| 21 |
-
"strand": ["STEM", "ABM", "HUMSS", "GAS", "TVL"],
|
| 22 |
-
"quarters": ["Q1", "Q2", "Q3", "Q4"],
|
| 23 |
-
"storage_path": "curriculum/general_math/GENERAL-MATHEMATICS-1.pdf",
|
| 24 |
-
},
|
| 25 |
-
"Finite-Mathematics-1-1.pdf": {
|
| 26 |
-
"subject": "Finite Mathematics 1",
|
| 27 |
-
"type": "curriculum_guide",
|
| 28 |
-
"strand": ["STEM", "ABM"],
|
| 29 |
-
"quarters": ["Q1", "Q2"],
|
| 30 |
-
"storage_path": "curriculum/finite_math/Finite-Mathematics-1-1.pdf",
|
| 31 |
-
},
|
| 32 |
-
"Finite-Mathematics-2-1.pdf": {
|
| 33 |
-
"subject": "Finite Mathematics 2",
|
| 34 |
-
"type": "curriculum_guide",
|
| 35 |
-
"strand": ["STEM", "ABM"],
|
| 36 |
-
"quarters": ["Q1", "Q2"],
|
| 37 |
-
"storage_path": "curriculum/finite_math/Finite-Mathematics-2-1.pdf",
|
| 38 |
-
},
|
| 39 |
-
"SDO_Navotas_Gen.Math_SHS_1stSem.FV.pdf": {
|
| 40 |
-
"subject": "General Mathematics",
|
| 41 |
-
"type": "sdo_module",
|
| 42 |
-
"strand": ["STEM", "ABM", "HUMSS", "GAS", "TVL"],
|
| 43 |
-
"quarters": ["Q1", "Q2"],
|
| 44 |
-
"storage_path": "curriculum/gen_math_sdo/SDO_Navotas_Gen.Math_SHS_1stSem.FV.pdf",
|
| 45 |
-
},
|
| 46 |
-
"SDO_Navotas_Bus.Math_SHS_1stSem.FV.pdf": {
|
| 47 |
-
"subject": "Business Mathematics",
|
| 48 |
-
"type": "sdo_module",
|
| 49 |
-
"strand": ["ABM"],
|
| 50 |
-
"quarters": ["Q1", "Q2"],
|
| 51 |
-
"storage_path": "curriculum/business_math/SDO_Navotas_Bus.Math_SHS_1stSem.FV.pdf",
|
| 52 |
-
},
|
| 53 |
-
"SDO_Navotas_SHS_ABM_OrgAndMngt_FirstSem_FV.pdf": {
|
| 54 |
-
"subject": "Organization and Management",
|
| 55 |
-
"type": "sdo_module",
|
| 56 |
-
"strand": ["ABM"],
|
| 57 |
-
"quarters": ["Q1", "Q2"],
|
| 58 |
-
"storage_path": "curriculum/org_mgmt/SDO_Navotas_SHS_ABM_OrgAndMngt_FirstSem_FV.pdf",
|
| 59 |
-
},
|
| 60 |
-
"SDO_Navotas_STAT_PROB_SHS_1stSem_FV.pdf": {
|
| 61 |
-
"subject": "Statistics and Probability",
|
| 62 |
-
"type": "sdo_module",
|
| 63 |
-
"strand": ["STEM", "ABM"],
|
| 64 |
-
"quarters": ["Q1", "Q2"],
|
| 65 |
-
"storage_path": "curriculum/stat_prob/SDO_Navotas_STAT_PROB_SHS_1stSem_FV.pdf",
|
| 66 |
-
},
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
def chunk_text(text: str, chunk_size: int = 600, overlap: int = 100) -> List[str]:
|
| 71 |
-
"""Split text into overlapping chunks."""
|
| 72 |
-
words = text.split()
|
| 73 |
-
chunks: List[str] = []
|
| 74 |
-
i = 0
|
| 75 |
-
while i < len(words):
|
| 76 |
-
chunk = " ".join(words[i : i + chunk_size])
|
| 77 |
-
chunks.append(chunk)
|
| 78 |
-
i += chunk_size - overlap
|
| 79 |
-
return chunks
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
def upload_pdfs():
|
| 83 |
-
"""Upload PDFs from local directory to Firebase Storage."""
|
| 84 |
-
try:
|
| 85 |
-
import firebase_admin
|
| 86 |
-
from firebase_admin import credentials, storage, firestore
|
| 87 |
-
except ImportError:
|
| 88 |
-
print("ERROR: firebase-admin not installed. Run: pip install firebase-admin")
|
| 89 |
-
return
|
| 90 |
-
|
| 91 |
-
service_account_path = Path(__file__).resolve().parents[1] / "serviceAccountKey.json"
|
| 92 |
-
if not service_account_path.exists():
|
| 93 |
-
print(f"ERROR: Service account key not found at {service_account_path}")
|
| 94 |
-
return
|
| 95 |
-
|
| 96 |
-
bucket_name = os.getenv("FIREBASE_STORAGE_BUCKET", "").strip()
|
| 97 |
-
if not bucket_name:
|
| 98 |
-
print("ERROR: FIREBASE_STORAGE_BUCKET not set in environment")
|
| 99 |
-
return
|
| 100 |
-
|
| 101 |
-
cred = credentials.Certificate(str(service_account_path))
|
| 102 |
-
firebase_admin.initialize_app(cred, {"storageBucket": bucket_name})
|
| 103 |
-
|
| 104 |
-
bucket = storage.bucket()
|
| 105 |
-
db = firestore.client()
|
| 106 |
-
|
| 107 |
-
print(f"Scanning: {LOCAL_PDF_DIR}")
|
| 108 |
-
print("-" * 50)
|
| 109 |
-
|
| 110 |
-
uploaded = 0
|
| 111 |
-
skipped = 0
|
| 112 |
-
|
| 113 |
-
for filename, meta in PDF_METADATA.items():
|
| 114 |
-
local_path = Path(LOCAL_PDF_DIR) / filename
|
| 115 |
-
|
| 116 |
-
if not local_path.exists():
|
| 117 |
-
print(f"[SKIP] {filename} not found in {LOCAL_PDF_DIR}")
|
| 118 |
-
skipped += 1
|
| 119 |
-
continue
|
| 120 |
-
|
| 121 |
-
doc_ref = db.collection("curriculumDocs").document(filename)
|
| 122 |
-
if doc_ref.get().exists:
|
| 123 |
-
print(f"[SKIP] {filename} already uploaded")
|
| 124 |
-
skipped += 1
|
| 125 |
-
continue
|
| 126 |
-
|
| 127 |
-
try:
|
| 128 |
-
blob = bucket.blob(meta["storage_path"])
|
| 129 |
-
blob.upload_from_filename(str(local_path), content_type="application/pdf")
|
| 130 |
-
|
| 131 |
-
doc_ref.set(
|
| 132 |
-
{
|
| 133 |
-
"filename": filename,
|
| 134 |
-
"subject": meta["subject"],
|
| 135 |
-
"type": meta["type"],
|
| 136 |
-
"strand": meta["strand"],
|
| 137 |
-
"quarters": meta["quarters"],
|
| 138 |
-
"storage_path": meta["storage_path"],
|
| 139 |
-
"uploaded_at": firestore.SERVER_TIMESTAMP,
|
| 140 |
-
"indexed": False,
|
| 141 |
-
}
|
| 142 |
-
)
|
| 143 |
-
|
| 144 |
-
print(f"[OK] Uploaded {filename}")
|
| 145 |
-
uploaded += 1
|
| 146 |
-
except Exception as e:
|
| 147 |
-
print(f"[ERROR] {filename}: {e}")
|
| 148 |
-
|
| 149 |
-
print("-" * 50)
|
| 150 |
-
print(f"Upload complete: {uploaded} uploaded, {skipped} skipped")
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
def index_pdfs():
|
| 154 |
-
"""Extract text from PDFs, chunk, embed, and store in ChromaDB."""
|
| 155 |
-
try:
|
| 156 |
-
from pypdf import PdfReader
|
| 157 |
-
import chromadb
|
| 158 |
-
from sentence_transformers import SentenceTransformer
|
| 159 |
-
from firebase_admin import firestore
|
| 160 |
-
except ImportError:
|
| 161 |
-
print("ERROR: Missing dependencies. Run: pip install pypdf chromadb sentence-transformers firebase-admin")
|
| 162 |
-
return
|
| 163 |
-
|
| 164 |
-
chroma_path = os.getenv("CHROMA_PERSIST_PATH", "./datasets/vectorstore")
|
| 165 |
-
|
| 166 |
-
chroma_client = chromadb.PersistentClient(path=chroma_path)
|
| 167 |
-
collection = chroma_client.get_or_create_collection(
|
| 168 |
-
name="curriculum_chunks",
|
| 169 |
-
metadata={"hnsw:space": "cosine"},
|
| 170 |
-
)
|
| 171 |
-
embedder = SentenceTransformer("BAAI/bge-base-en-v1.5")
|
| 172 |
-
|
| 173 |
-
try:
|
| 174 |
-
import firebase_admin
|
| 175 |
-
from firebase_admin import firestore as FS
|
| 176 |
-
db = FS.client()
|
| 177 |
-
except Exception:
|
| 178 |
-
db = None
|
| 179 |
-
|
| 180 |
-
print(f"Indexing PDFs from: {LOCAL_PDF_DIR}")
|
| 181 |
-
print("-" * 50)
|
| 182 |
-
|
| 183 |
-
indexed = 0
|
| 184 |
-
skipped = 0
|
| 185 |
-
|
| 186 |
-
for filename, meta in PDF_METADATA.items():
|
| 187 |
-
if db:
|
| 188 |
-
doc_ref = db.collection("curriculumDocs").document(filename)
|
| 189 |
-
doc = doc_ref.get()
|
| 190 |
-
if doc and doc.to_dict().get("indexed", False):
|
| 191 |
-
print(f"[SKIP] {filename} already indexed")
|
| 192 |
-
skipped += 1
|
| 193 |
-
continue
|
| 194 |
-
|
| 195 |
-
local_path = Path(LOCAL_PDF_DIR) / filename
|
| 196 |
-
if not local_path.exists():
|
| 197 |
-
print(f"[SKIP] {filename} not found")
|
| 198 |
-
skipped += 1
|
| 199 |
-
continue
|
| 200 |
-
|
| 201 |
-
try:
|
| 202 |
-
reader = PdfReader(str(local_path))
|
| 203 |
-
full_text = "\n".join(page.extract_text() or "" for page in reader.pages)
|
| 204 |
-
|
| 205 |
-
if not full_text.strip():
|
| 206 |
-
print(f"[WARN] {filename} has no extractable text")
|
| 207 |
-
continue
|
| 208 |
-
|
| 209 |
-
chunks = chunk_text(full_text)
|
| 210 |
-
print(f"[INFO] {filename} -> {len(chunks)} chunks")
|
| 211 |
-
|
| 212 |
-
for i, chunk in enumerate(chunks):
|
| 213 |
-
chunk_id = f"{filename}_chunk_{i}"
|
| 214 |
-
|
| 215 |
-
existing = collection.get(ids=[chunk_id])
|
| 216 |
-
if existing and existing.get("ids"):
|
| 217 |
-
continue
|
| 218 |
-
|
| 219 |
-
chunk_embedding = embedder.encode(
|
| 220 |
-
chunk,
|
| 221 |
-
normalize_embeddings=True,
|
| 222 |
-
).tolist()
|
| 223 |
-
|
| 224 |
-
collection.add(
|
| 225 |
-
embeddings=[chunk_embedding],
|
| 226 |
-
documents=[chunk],
|
| 227 |
-
metadatas=[
|
| 228 |
-
{
|
| 229 |
-
"source_file": filename,
|
| 230 |
-
"subject": meta["subject"],
|
| 231 |
-
"strand": ",".join(meta["strand"]),
|
| 232 |
-
"quarter": ",".join(meta["quarters"]),
|
| 233 |
-
"chunk_index": i,
|
| 234 |
-
"type": meta["type"],
|
| 235 |
-
}
|
| 236 |
-
],
|
| 237 |
-
ids=[chunk_id],
|
| 238 |
-
)
|
| 239 |
-
|
| 240 |
-
if db:
|
| 241 |
-
doc_ref.update({"indexed": True})
|
| 242 |
-
|
| 243 |
-
print(f"[OK] Indexed {filename}")
|
| 244 |
-
indexed += 1
|
| 245 |
-
except Exception as e:
|
| 246 |
-
print(f"[ERROR] {filename}: {e}")
|
| 247 |
-
|
| 248 |
-
print("-" * 50)
|
| 249 |
-
print(f"Indexing complete: {indexed} indexed, {skipped} skipped")
|
| 250 |
-
print(f"Total chunks in ChromaDB: {collection.count()}")
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
if __name__ == "__main__":
|
| 254 |
-
import argparse
|
| 255 |
-
|
| 256 |
-
parser = argparse.ArgumentParser(description="Upload and index DepEd curriculum PDFs")
|
| 257 |
-
parser.add_argument("action", choices=["upload", "index", "both"], help="Action to perform")
|
| 258 |
-
args = parser.parse_args()
|
| 259 |
-
|
| 260 |
-
if args.action in ("upload", "both"):
|
| 261 |
-
upload_pdfs()
|
| 262 |
-
|
| 263 |
-
if args.action in ("index", "both"):
|
| 264 |
-
index_pdfs()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/upload_lesson_modules.py
DELETED
|
@@ -1,142 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Merge DepEd lesson module PDFs and upload to Firebase Storage.
|
| 3 |
-
Run: python backend/scripts/upload_lesson_modules.py
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
from __future__ import annotations
|
| 7 |
-
|
| 8 |
-
import os
|
| 9 |
-
import sys
|
| 10 |
-
from pathlib import Path
|
| 11 |
-
|
| 12 |
-
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 13 |
-
|
| 14 |
-
from pypdf import PdfWriter, PdfReader
|
| 15 |
-
|
| 16 |
-
LOCAL_MODULES_DIR = Path(__file__).resolve().parents[1].parent / "datasets" / "lesson_modules"
|
| 17 |
-
FIREBASE_STORAGE_BUCKET = "mathpulse-ai-2026.firebasestorage.app"
|
| 18 |
-
|
| 19 |
-
# Upload plan
|
| 20 |
-
UPLOAD_JOBS = [
|
| 21 |
-
{
|
| 22 |
-
"id": "basic-calc-q3",
|
| 23 |
-
"display_name": "Basic Calculus Q3",
|
| 24 |
-
"subject": "Basic Calculus",
|
| 25 |
-
"subjectId": "basic-calc",
|
| 26 |
-
"quarter": 3,
|
| 27 |
-
"storage_path": "curriculum/basic_calc/SDO_Navotas_BasicCalc_SHS_Q3.FV.pdf",
|
| 28 |
-
"local_dir": LOCAL_MODULES_DIR / "basic_calculus_q3",
|
| 29 |
-
"filename": "Basic Calculus-Q3-Module-{n}.pdf",
|
| 30 |
-
"modules": list(range(1, 9)), # Modules 1-8
|
| 31 |
-
},
|
| 32 |
-
{
|
| 33 |
-
"id": "gen-math-q2",
|
| 34 |
-
"display_name": "General Mathematics Q2",
|
| 35 |
-
"subject": "General Mathematics",
|
| 36 |
-
"subjectId": "gen-math",
|
| 37 |
-
"quarter": 2,
|
| 38 |
-
"storage_path": "curriculum/gen_math_q2/SDO_Navotas_GenMath_SHS_Q2.FV.pdf",
|
| 39 |
-
"local_dir": LOCAL_MODULES_DIR / "genmath_q2",
|
| 40 |
-
"filename": "genmath_q2_mod{n}_*.pdf",
|
| 41 |
-
"modules": [2, 3], # Modules 2 and 3 only
|
| 42 |
-
},
|
| 43 |
-
]
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
def merge_pdfs(job: dict) -> Path | None:
|
| 47 |
-
"""Merge multiple PDFs into a single output file. Returns output path."""
|
| 48 |
-
output_dir = LOCAL_MODULES_DIR / "merged"
|
| 49 |
-
output_dir.mkdir(parents=True, exist_ok=True)
|
| 50 |
-
output_path = output_dir / f"{job['id']}_merged.pdf"
|
| 51 |
-
|
| 52 |
-
writer = PdfWriter()
|
| 53 |
-
|
| 54 |
-
for mod_num in job["modules"]:
|
| 55 |
-
if job["id"] == "basic-calc-q3":
|
| 56 |
-
fname = job["filename"].format(n=mod_num)
|
| 57 |
-
else:
|
| 58 |
-
# GenMath modules have specific naming
|
| 59 |
-
fname = None
|
| 60 |
-
pattern = job["filename"].format(n=mod_num)
|
| 61 |
-
for f in job["local_dir"].glob(pattern):
|
| 62 |
-
fname = f.name
|
| 63 |
-
break
|
| 64 |
-
if fname is None:
|
| 65 |
-
print(f" [WARN] Could not find file for module {mod_num}")
|
| 66 |
-
continue
|
| 67 |
-
|
| 68 |
-
src_path = job["local_dir"] / fname
|
| 69 |
-
if not src_path.exists():
|
| 70 |
-
print(f" [WARN] File not found: {src_path}")
|
| 71 |
-
continue
|
| 72 |
-
|
| 73 |
-
reader = PdfReader(str(src_path))
|
| 74 |
-
print(f" + {src_path.name} ({len(reader.pages)} pages)")
|
| 75 |
-
for page in reader.pages:
|
| 76 |
-
writer.add_page(page)
|
| 77 |
-
|
| 78 |
-
print(f" Writing {output_path.name} ({len(writer.pages)} total pages)")
|
| 79 |
-
with open(output_path, "wb") as f:
|
| 80 |
-
writer.write(f)
|
| 81 |
-
|
| 82 |
-
return output_path
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
def upload_to_firebase(local_path: Path, storage_path: str) -> bool:
|
| 86 |
-
"""Upload a PDF file to Firebase Storage."""
|
| 87 |
-
try:
|
| 88 |
-
import firebase_admin
|
| 89 |
-
from firebase_admin import credentials, storage
|
| 90 |
-
except ImportError:
|
| 91 |
-
print(" ERROR: firebase-admin not installed")
|
| 92 |
-
return False
|
| 93 |
-
|
| 94 |
-
sa_file = Path(__file__).resolve().parents[1].parent / ".secrets" / "firebase-service-account.json"
|
| 95 |
-
if not sa_file.exists():
|
| 96 |
-
print(f" ERROR: Service account not found at {sa_file}")
|
| 97 |
-
return False
|
| 98 |
-
|
| 99 |
-
if not firebase_admin._apps:
|
| 100 |
-
cred = credentials.Certificate(str(sa_file))
|
| 101 |
-
firebase_admin.initialize_app(cred, {"storageBucket": FIREBASE_STORAGE_BUCKET})
|
| 102 |
-
|
| 103 |
-
bucket = storage.bucket()
|
| 104 |
-
blob = bucket.blob(storage_path)
|
| 105 |
-
|
| 106 |
-
print(f" Uploading to gs://{bucket.name}/{storage_path}")
|
| 107 |
-
blob.upload_from_filename(str(local_path), content_type="application/pdf")
|
| 108 |
-
print(f" Upload complete!")
|
| 109 |
-
return True
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
def main():
|
| 113 |
-
print("=" * 60)
|
| 114 |
-
print("MathPulse AI — Lesson Module PDF Uploader")
|
| 115 |
-
print("=" * 60)
|
| 116 |
-
|
| 117 |
-
for job in UPLOAD_JOBS:
|
| 118 |
-
print(f"\n[{job['display_name']}]")
|
| 119 |
-
print("-" * 40)
|
| 120 |
-
|
| 121 |
-
# Step 1: Merge PDFs
|
| 122 |
-
output_path = merge_pdfs(job)
|
| 123 |
-
if not output_path or not output_path.exists():
|
| 124 |
-
print(f" [FAIL] Merge failed for {job['id']}")
|
| 125 |
-
continue
|
| 126 |
-
|
| 127 |
-
# Step 2: Upload to Firebase
|
| 128 |
-
success = upload_to_firebase(output_path, job["storage_path"])
|
| 129 |
-
if not success:
|
| 130 |
-
print(f" [FAIL] Upload failed for {job['id']}")
|
| 131 |
-
continue
|
| 132 |
-
|
| 133 |
-
print(f"\n SUCCESS: {job['display_name']}")
|
| 134 |
-
print(f" Storage path: gs://{FIREBASE_STORAGE_BUCKET}/{job['storage_path']}")
|
| 135 |
-
print(f" Pages: {len(PdfReader(str(output_path)).pages)}")
|
| 136 |
-
|
| 137 |
-
print("\n" + "=" * 60)
|
| 138 |
-
print("Done!")
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
if __name__ == "__main__":
|
| 142 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/upload_vectorstore_to_firebase.py
DELETED
|
@@ -1,71 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Upload vectorstore directory to Firebase Storage.
|
| 3 |
-
Run: python -m backend.scripts.upload_vectorstore_to_firebase
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
from __future__ import annotations
|
| 7 |
-
|
| 8 |
-
import logging
|
| 9 |
-
import os
|
| 10 |
-
import sys
|
| 11 |
-
from pathlib import Path
|
| 12 |
-
|
| 13 |
-
logger = logging.getLogger("mathpulse.upload_vectorstore")
|
| 14 |
-
|
| 15 |
-
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
| 16 |
-
|
| 17 |
-
from backend.rag.firebase_storage_loader import _init_firebase_storage
|
| 18 |
-
|
| 19 |
-
VECTORSTORE_SOURCE_DIR = Path(__file__).resolve().parents[3] / "datasets" / "vectorstore"
|
| 20 |
-
REMOTE_PREFIX = "vectorstore/"
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def upload_directory(local_dir: Path, bucket, prefix: str):
|
| 24 |
-
"""Recursively upload a local directory to Firebase Storage prefix."""
|
| 25 |
-
uploaded = 0
|
| 26 |
-
skipped = 0
|
| 27 |
-
|
| 28 |
-
for root, dirs, files in os.walk(local_dir):
|
| 29 |
-
for filename in files:
|
| 30 |
-
local_path = Path(root) / filename
|
| 31 |
-
relative_path = local_path.relative_to(local_dir)
|
| 32 |
-
remote_path = f"{prefix}{relative_path.as_posix()}"
|
| 33 |
-
|
| 34 |
-
try:
|
| 35 |
-
blob = bucket.blob(remote_path)
|
| 36 |
-
blob.upload_from_filename(str(local_path))
|
| 37 |
-
logger.info("Uploaded: %s (%d bytes)", remote_path, local_path.stat().st_size)
|
| 38 |
-
uploaded += 1
|
| 39 |
-
except Exception as e:
|
| 40 |
-
logger.error("Failed to upload %s: %s", remote_path, e)
|
| 41 |
-
skipped += 1
|
| 42 |
-
|
| 43 |
-
return uploaded, skipped
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
if __name__ == "__main__":
|
| 47 |
-
import argparse
|
| 48 |
-
|
| 49 |
-
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
| 50 |
-
|
| 51 |
-
parser = argparse.ArgumentParser(description="Upload vectorstore to Firebase Storage")
|
| 52 |
-
parser.add_argument("--source", type=str, default=str(VECTORSTORE_SOURCE_DIR),
|
| 53 |
-
help="Local vectorstore directory")
|
| 54 |
-
parser.add_argument("--prefix", type=str, default=REMOTE_PREFIX,
|
| 55 |
-
help="Remote path prefix in Firebase Storage")
|
| 56 |
-
args = parser.parse_args()
|
| 57 |
-
|
| 58 |
-
source_dir = Path(args.source)
|
| 59 |
-
if not source_dir.exists():
|
| 60 |
-
logger.error("Source directory does not exist: %s", source_dir)
|
| 61 |
-
sys.exit(1)
|
| 62 |
-
|
| 63 |
-
_, bucket = _init_firebase_storage()
|
| 64 |
-
if bucket is None:
|
| 65 |
-
logger.error("Firebase Storage not available")
|
| 66 |
-
sys.exit(1)
|
| 67 |
-
|
| 68 |
-
logger.info("Uploading vectorstore from %s to gs://%s/%s",
|
| 69 |
-
source_dir, bucket.name, args.prefix)
|
| 70 |
-
uploaded, skipped = upload_directory(source_dir, bucket, args.prefix)
|
| 71 |
-
logger.info("Upload complete: %d uploaded, %d skipped", uploaded, skipped)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
services/__init__.py
CHANGED
|
@@ -1,44 +1 @@
|
|
| 1 |
"""Backend service helpers for inference, logging, and integrations."""
|
| 2 |
-
|
| 3 |
-
from .inference_client import (
|
| 4 |
-
create_default_client,
|
| 5 |
-
InferenceRequest,
|
| 6 |
-
InferenceClient,
|
| 7 |
-
is_sequential_model,
|
| 8 |
-
get_current_runtime_config,
|
| 9 |
-
get_model_for_task,
|
| 10 |
-
set_runtime_model_profile,
|
| 11 |
-
set_runtime_model_override,
|
| 12 |
-
reset_runtime_overrides,
|
| 13 |
-
model_supports_thinking,
|
| 14 |
-
_MODEL_PROFILES,
|
| 15 |
-
)
|
| 16 |
-
|
| 17 |
-
from .ai_client import (
|
| 18 |
-
get_deepseek_client,
|
| 19 |
-
CHAT_MODEL,
|
| 20 |
-
REASONER_MODEL,
|
| 21 |
-
APIError,
|
| 22 |
-
RateLimitError,
|
| 23 |
-
APITimeoutError,
|
| 24 |
-
)
|
| 25 |
-
|
| 26 |
-
__all__ = [
|
| 27 |
-
"create_default_client",
|
| 28 |
-
"InferenceRequest",
|
| 29 |
-
"InferenceClient",
|
| 30 |
-
"is_sequential_model",
|
| 31 |
-
"get_current_runtime_config",
|
| 32 |
-
"get_model_for_task",
|
| 33 |
-
"set_runtime_model_profile",
|
| 34 |
-
"set_runtime_model_override",
|
| 35 |
-
"reset_runtime_overrides",
|
| 36 |
-
"model_supports_thinking",
|
| 37 |
-
"_MODEL_PROFILES",
|
| 38 |
-
"get_deepseek_client",
|
| 39 |
-
"CHAT_MODEL",
|
| 40 |
-
"REASONER_MODEL",
|
| 41 |
-
"APIError",
|
| 42 |
-
"RateLimitError",
|
| 43 |
-
"APITimeoutError",
|
| 44 |
-
]
|
|
|
|
| 1 |
"""Backend service helpers for inference, logging, and integrations."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
services/ai_client.py
DELETED
|
@@ -1,28 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
from openai import OpenAI, APIError, RateLimitError, APITimeoutError
|
| 3 |
-
from functools import lru_cache
|
| 4 |
-
|
| 5 |
-
__all__ = [
|
| 6 |
-
"get_deepseek_client",
|
| 7 |
-
"CHAT_MODEL",
|
| 8 |
-
"REASONER_MODEL",
|
| 9 |
-
"DEEPSEEK_BASE_URL",
|
| 10 |
-
"APIError",
|
| 11 |
-
"RateLimitError",
|
| 12 |
-
"APITimeoutError",
|
| 13 |
-
]
|
| 14 |
-
|
| 15 |
-
DEEPSEEK_BASE_URL = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
|
| 16 |
-
CHAT_MODEL = os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
|
| 17 |
-
REASONER_MODEL = os.getenv("DEEPSEEK_REASONER_MODEL", "deepseek-reasoner")
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
@lru_cache(maxsize=1)
|
| 21 |
-
def get_deepseek_client() -> OpenAI:
|
| 22 |
-
api_key = os.getenv("DEEPSEEK_API_KEY")
|
| 23 |
-
if not api_key:
|
| 24 |
-
raise ValueError("DEEPSEEK_API_KEY environment variable not set")
|
| 25 |
-
return OpenAI(
|
| 26 |
-
api_key=api_key,
|
| 27 |
-
base_url=DEEPSEEK_BASE_URL,
|
| 28 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
services/curriculum_service.py
DELETED
|
@@ -1,232 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Curriculum Service - Firestore-backed curriculum data.
|
| 3 |
-
|
| 4 |
-
Fetches subjects, topics, and modules from Firestore.
|
| 5 |
-
Falls back to static data if Firestore is unavailable.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
import logging
|
| 9 |
-
import os
|
| 10 |
-
from typing import Any, Dict, List, Optional
|
| 11 |
-
|
| 12 |
-
logger = logging.getLogger(__name__)
|
| 13 |
-
|
| 14 |
-
# Static curriculum data as fallback
|
| 15 |
-
_STATIC_SUBJECTS = [
|
| 16 |
-
{
|
| 17 |
-
"id": "gen-math",
|
| 18 |
-
"code": "GEN MATH",
|
| 19 |
-
"name": "General Mathematics",
|
| 20 |
-
"gradeLevel": "Grade 11",
|
| 21 |
-
"semester": "1st Semester",
|
| 22 |
-
"color": "from-blue-500 to-cyan-500",
|
| 23 |
-
"pdfAvailable": True,
|
| 24 |
-
"topics": [
|
| 25 |
-
{"id": "gen-math-001", "name": "Patterns and Real-Life Relationships", "unit": "Patterns, Relations, and Functions"},
|
| 26 |
-
{"id": "gen-math-002", "name": "Functions as Mathematical Models", "unit": "Patterns, Relations, and Functions"},
|
| 27 |
-
{"id": "gen-math-003", "name": "Function Notation and Evaluation", "unit": "Patterns, Relations, and Functions"},
|
| 28 |
-
{"id": "gen-math-004", "name": "Domain and Range of Functions", "unit": "Patterns, Relations, and Functions"},
|
| 29 |
-
{"id": "gen-math-005", "name": "Operations on Functions", "unit": "Patterns, Relations, and Functions"},
|
| 30 |
-
{"id": "gen-math-006", "name": "Composite Functions", "unit": "Patterns, Relations, and Functions"},
|
| 31 |
-
{"id": "gen-math-007", "name": "Inverse Functions", "unit": "Patterns, Relations, and Functions"},
|
| 32 |
-
{"id": "gen-math-008", "name": "Graphs of Rational Functions", "unit": "Patterns, Relations, and Functions"},
|
| 33 |
-
{"id": "gen-math-009", "name": "Graphs of Exponential Functions", "unit": "Patterns, Relations, and Functions"},
|
| 34 |
-
{"id": "gen-math-010", "name": "Graphs of Logarithmic Functions", "unit": "Patterns, Relations, and Functions"},
|
| 35 |
-
{"id": "gen-math-011", "name": "Simple and Compound Interest", "unit": "Financial Mathematics"},
|
| 36 |
-
{"id": "gen-math-012", "name": "Simple and General Annuities", "unit": "Financial Mathematics"},
|
| 37 |
-
{"id": "gen-math-013", "name": "Present and Future Value", "unit": "Financial Mathematics"},
|
| 38 |
-
{"id": "gen-math-014", "name": "Loans, Amortization, and Sinking Funds", "unit": "Financial Mathematics"},
|
| 39 |
-
{"id": "gen-math-015", "name": "Stocks, Bonds, and Market Indices", "unit": "Financial Mathematics"},
|
| 40 |
-
{"id": "gen-math-016", "name": "Business Decision-Making with Mathematical Models", "unit": "Financial Mathematics"},
|
| 41 |
-
{"id": "gen-math-017", "name": "Propositions and Logical Connectives", "unit": "Logic and Mathematical Reasoning"},
|
| 42 |
-
{"id": "gen-math-018", "name": "Truth Values and Truth Tables", "unit": "Logic and Mathematical Reasoning"},
|
| 43 |
-
{"id": "gen-math-019", "name": "Logical Equivalence and Implication", "unit": "Logic and Mathematical Reasoning"},
|
| 44 |
-
{"id": "gen-math-020", "name": "Quantifiers and Negation", "unit": "Logic and Mathematical Reasoning"},
|
| 45 |
-
{"id": "gen-math-021", "name": "Validity of Arguments", "unit": "Logic and Mathematical Reasoning"},
|
| 46 |
-
]
|
| 47 |
-
},
|
| 48 |
-
{
|
| 49 |
-
"id": "stats-prob",
|
| 50 |
-
"code": "STAT&PROB",
|
| 51 |
-
"name": "Statistics and Probability",
|
| 52 |
-
"gradeLevel": "Grade 11",
|
| 53 |
-
"semester": "2nd Semester",
|
| 54 |
-
"color": "from-sky-500 to-cyan-500",
|
| 55 |
-
"pdfAvailable": True,
|
| 56 |
-
"topics": [
|
| 57 |
-
{"id": "stat-001", "name": "Random Variables", "unit": "Random Variables"},
|
| 58 |
-
{"id": "stat-002", "name": "Discrete Probability Distributions", "unit": "Random Variables"},
|
| 59 |
-
{"id": "stat-003", "name": "Mean and Variance of Discrete RV", "unit": "Random Variables"},
|
| 60 |
-
{"id": "stat-004", "name": "Normal Distribution", "unit": "Normal Distribution"},
|
| 61 |
-
{"id": "stat-005", "name": "Standard Normal Distribution and Z-scores", "unit": "Normal Distribution"},
|
| 62 |
-
{"id": "stat-006", "name": "Areas Under the Normal Curve", "unit": "Normal Distribution"},
|
| 63 |
-
{"id": "stat-007", "name": "Sampling Distributions", "unit": "Sampling and Estimation"},
|
| 64 |
-
{"id": "stat-008", "name": "Central Limit Theorem", "unit": "Sampling and Estimation"},
|
| 65 |
-
{"id": "stat-009", "name": "Point Estimation", "unit": "Sampling and Estimation"},
|
| 66 |
-
{"id": "stat-010", "name": "Confidence Intervals", "unit": "Sampling and Estimation"},
|
| 67 |
-
{"id": "stat-011", "name": "Hypothesis Testing Concepts", "unit": "Hypothesis Testing"},
|
| 68 |
-
{"id": "stat-012", "name": "T-test", "unit": "Hypothesis Testing"},
|
| 69 |
-
{"id": "stat-013", "name": "Z-test", "unit": "Hypothesis Testing"},
|
| 70 |
-
{"id": "stat-014", "name": "Correlation and Regression", "unit": "Correlation and Regression"},
|
| 71 |
-
]
|
| 72 |
-
},
|
| 73 |
-
{
|
| 74 |
-
"id": "pre-calc",
|
| 75 |
-
"code": "PRE-CALC",
|
| 76 |
-
"name": "Pre-Calculus",
|
| 77 |
-
"gradeLevel": "Grade 12",
|
| 78 |
-
"semester": "1st Semester",
|
| 79 |
-
"color": "from-orange-500 to-red-500",
|
| 80 |
-
"pdfAvailable": False,
|
| 81 |
-
"topics": [
|
| 82 |
-
{"id": "pre-calc-001", "name": "Conic Sections - Parabola", "unit": "Analytic Geometry"},
|
| 83 |
-
{"id": "pre-calc-002", "name": "Conic Sections - Ellipse", "unit": "Analytic Geometry"},
|
| 84 |
-
{"id": "pre-calc-003", "name": "Conic Sections - Hyperbola", "unit": "Analytic Geometry"},
|
| 85 |
-
{"id": "pre-calc-004", "name": "Conic Sections - Circle", "unit": "Analytic Geometry"},
|
| 86 |
-
{"id": "pre-calc-005", "name": "Systems of Nonlinear Equations", "unit": "Analytic Geometry"},
|
| 87 |
-
{"id": "pre-calc-006", "name": "Sequences and Series", "unit": "Series and Induction"},
|
| 88 |
-
{"id": "pre-calc-007", "name": "Arithmetic Sequences", "unit": "Series and Induction"},
|
| 89 |
-
{"id": "pre-calc-008", "name": "Geometric Sequences", "unit": "Series and Induction"},
|
| 90 |
-
{"id": "pre-calc-009", "name": "Mathematical Induction", "unit": "Series and Induction"},
|
| 91 |
-
{"id": "pre-calc-010", "name": "Binomial Theorem", "unit": "Series and Induction"},
|
| 92 |
-
{"id": "pre-calc-011", "name": "Angles and Unit Circle", "unit": "Trigonometry"},
|
| 93 |
-
{"id": "pre-calc-012", "name": "Trigonometric Functions", "unit": "Trigonometry"},
|
| 94 |
-
{"id": "pre-calc-013", "name": "Trigonometric Identities", "unit": "Trigonometry"},
|
| 95 |
-
{"id": "pre-calc-014", "name": "Sum and Difference Formulas", "unit": "Trigonometry"},
|
| 96 |
-
{"id": "pre-calc-015", "name": "Inverse Trigonometric Functions", "unit": "Trigonometry"},
|
| 97 |
-
{"id": "pre-calc-016", "name": "Polar Coordinates", "unit": "Trigonometry"},
|
| 98 |
-
]
|
| 99 |
-
},
|
| 100 |
-
{
|
| 101 |
-
"id": "basic-calc",
|
| 102 |
-
"code": "BASIC CALC",
|
| 103 |
-
"name": "Basic Calculus",
|
| 104 |
-
"gradeLevel": "Grade 12",
|
| 105 |
-
"semester": "2nd Semester",
|
| 106 |
-
"color": "from-green-500 to-teal-500",
|
| 107 |
-
"pdfAvailable": True,
|
| 108 |
-
"topics": [
|
| 109 |
-
{"id": "calc-001", "name": "Limits of Functions", "unit": "Limits"},
|
| 110 |
-
{"id": "calc-002", "name": "Limit Theorems", "unit": "Limits"},
|
| 111 |
-
{"id": "calc-003", "name": "One-Sided Limits", "unit": "Limits"},
|
| 112 |
-
{"id": "calc-004", "name": "Infinite Limits and Limits at Infinity", "unit": "Limits"},
|
| 113 |
-
{"id": "calc-005", "name": "Continuity of Functions", "unit": "Limits"},
|
| 114 |
-
{"id": "calc-006", "name": "Definition of the Derivative", "unit": "Derivatives"},
|
| 115 |
-
{"id": "calc-007", "name": "Differentiation Rules", "unit": "Derivatives"},
|
| 116 |
-
{"id": "calc-008", "name": "Chain Rule", "unit": "Derivatives"},
|
| 117 |
-
{"id": "calc-009", "name": "Implicit Differentiation", "unit": "Derivatives"},
|
| 118 |
-
{"id": "calc-010", "name": "Higher-Order Derivatives", "unit": "Derivatives"},
|
| 119 |
-
{"id": "calc-011", "name": "Related Rates", "unit": "Derivatives"},
|
| 120 |
-
{"id": "calc-012", "name": "Extrema and the First Derivative Test", "unit": "Derivatives"},
|
| 121 |
-
{"id": "calc-013", "name": "Concavity and the Second Derivative Test", "unit": "Derivatives"},
|
| 122 |
-
{"id": "calc-014", "name": "Optimization Problems", "unit": "Derivatives"},
|
| 123 |
-
{"id": "calc-015", "name": "Antiderivatives and Indefinite Integrals", "unit": "Integration"},
|
| 124 |
-
{"id": "calc-016", "name": "Definite Integrals and the FTC", "unit": "Integration"},
|
| 125 |
-
{"id": "calc-017", "name": "Integration by Substitution", "unit": "Integration"},
|
| 126 |
-
{"id": "calc-018", "name": "Area Under a Curve", "unit": "Integration"},
|
| 127 |
-
]
|
| 128 |
-
},
|
| 129 |
-
]
|
| 130 |
-
|
| 131 |
-
_firestore_db = None
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
def _get_firestore_db():
|
| 135 |
-
"""Initialize Firestore client."""
|
| 136 |
-
global _firestore_db
|
| 137 |
-
if _firestore_db is not None:
|
| 138 |
-
return _firestore_db
|
| 139 |
-
|
| 140 |
-
try:
|
| 141 |
-
import firebase_admin
|
| 142 |
-
from firebase_admin import firestore
|
| 143 |
-
if not firebase_admin._apps:
|
| 144 |
-
# Try service account from env or default credentials
|
| 145 |
-
import json
|
| 146 |
-
svc_account = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON")
|
| 147 |
-
if svc_account:
|
| 148 |
-
sa_creds = json.loads(svc_account)
|
| 149 |
-
firebase_admin.initialize_app(firebase_admin.Certificate(sa_creds))
|
| 150 |
-
else:
|
| 151 |
-
firebase_admin.initialize_app()
|
| 152 |
-
_firestore_db = firestore.client()
|
| 153 |
-
return _firestore_db
|
| 154 |
-
except Exception as e:
|
| 155 |
-
logger.warning(f"Could not initialize Firestore: {e}")
|
| 156 |
-
return None
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
def get_subjects(grade_level: Optional[str] = None) -> List[Dict[str, Any]]:
|
| 160 |
-
"""
|
| 161 |
-
Fetch all subjects from Firestore.
|
| 162 |
-
Falls back to static data if Firestore unavailable.
|
| 163 |
-
Defaults to Grade 11 (SHS) if no grade specified.
|
| 164 |
-
"""
|
| 165 |
-
# Default to Grade 11 (SHS) - only serve Grade 11 students for now
|
| 166 |
-
if grade_level is None:
|
| 167 |
-
grade_level = "Grade 11"
|
| 168 |
-
|
| 169 |
-
db = _get_firestore_db()
|
| 170 |
-
|
| 171 |
-
if db is not None:
|
| 172 |
-
try:
|
| 173 |
-
subjects_ref = db.collection("subjects")
|
| 174 |
-
if grade_level:
|
| 175 |
-
subjects_ref = subjects_ref.where("gradeLevel", "==", grade_level)
|
| 176 |
-
|
| 177 |
-
docs = subjects_ref.stream()
|
| 178 |
-
subjects = []
|
| 179 |
-
for doc in docs:
|
| 180 |
-
data = doc.to_dict()
|
| 181 |
-
if data:
|
| 182 |
-
data["id"] = doc.id
|
| 183 |
-
subjects.append(data)
|
| 184 |
-
|
| 185 |
-
if subjects:
|
| 186 |
-
logger.info(f"Loaded {len(subjects)} subjects from Firestore")
|
| 187 |
-
return subjects
|
| 188 |
-
except Exception as e:
|
| 189 |
-
logger.warning(f"Firestore fetch failed, using static data: {e}")
|
| 190 |
-
|
| 191 |
-
# Static fallback
|
| 192 |
-
if grade_level:
|
| 193 |
-
return [s for s in _STATIC_SUBJECTS if s.get("gradeLevel") == grade_level]
|
| 194 |
-
return list(_STATIC_SUBJECTS)
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
def get_subject(subject_id: str) -> Optional[Dict[str, Any]]:
|
| 198 |
-
"""Fetch a single subject by ID."""
|
| 199 |
-
db = _get_firestore_db()
|
| 200 |
-
|
| 201 |
-
if db is not None:
|
| 202 |
-
try:
|
| 203 |
-
doc = db.collection("subjects").document(subject_id).get()
|
| 204 |
-
if doc.exists:
|
| 205 |
-
data = doc.to_dict()
|
| 206 |
-
data["id"] = doc.id
|
| 207 |
-
return data
|
| 208 |
-
except Exception as e:
|
| 209 |
-
logger.warning(f"Firestore fetch failed for {subject_id}: {e}")
|
| 210 |
-
|
| 211 |
-
# Static fallback
|
| 212 |
-
for subject in _STATIC_SUBJECTS:
|
| 213 |
-
if subject["id"] == subject_id:
|
| 214 |
-
return dict(subject)
|
| 215 |
-
return None
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
def get_topics(subject_id: str) -> List[Dict[str, Any]]:
|
| 219 |
-
"""Fetch all topics for a subject."""
|
| 220 |
-
subject = get_subject(subject_id)
|
| 221 |
-
if subject:
|
| 222 |
-
return subject.get("topics", [])
|
| 223 |
-
return []
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
def get_topic(subject_id: str, topic_id: str) -> Optional[Dict[str, Any]]:
|
| 227 |
-
"""Fetch a single topic."""
|
| 228 |
-
topics = get_topics(subject_id)
|
| 229 |
-
for topic in topics:
|
| 230 |
-
if topic["id"] == topic_id:
|
| 231 |
-
return topic
|
| 232 |
-
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
services/inference_client.py
CHANGED
|
@@ -10,198 +10,13 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
| 10 |
|
| 11 |
import requests
|
| 12 |
import yaml
|
| 13 |
-
from
|
| 14 |
|
| 15 |
-
from .ai_client import get_deepseek_client, CHAT_MODEL, REASONER_MODEL, DEEPSEEK_BASE_URL
|
| 16 |
from .logging_utils import configure_structured_logging, log_model_call
|
| 17 |
|
| 18 |
LOGGER = configure_structured_logging("mathpulse.inference")
|
| 19 |
TEMP_CHAT_MODEL_OVERRIDE_ENV = "INFERENCE_CHAT_MODEL_TEMP_OVERRIDE"
|
| 20 |
|
| 21 |
-
# ── Model Profiles ────────────────────────────────────────────────────────────
|
| 22 |
-
# A profile sets multiple env defaults in one shot.
|
| 23 |
-
# Individual env vars (DEEPSEEK_MODEL, DEEPSEEK_REASONER_MODEL, etc.) still override.
|
| 24 |
-
# Usage: MODEL_PROFILE=dev or MODEL_PROFILE=prod or MODEL_PROFILE=budget
|
| 25 |
-
# Profiles can also be applied at runtime via the admin panel without restart.
|
| 26 |
-
|
| 27 |
-
_MODEL_PROFILES: dict[str, dict[str, str]] = {
|
| 28 |
-
"dev": {
|
| 29 |
-
"INFERENCE_MODEL_ID": CHAT_MODEL,
|
| 30 |
-
"INFERENCE_CHAT_MODEL_ID": CHAT_MODEL,
|
| 31 |
-
"HF_QUIZ_MODEL_ID": CHAT_MODEL,
|
| 32 |
-
"HF_RAG_MODEL_ID": CHAT_MODEL,
|
| 33 |
-
"INFERENCE_LOCK_MODEL_ID": CHAT_MODEL,
|
| 34 |
-
},
|
| 35 |
-
"prod": {
|
| 36 |
-
"INFERENCE_MODEL_ID": CHAT_MODEL,
|
| 37 |
-
"INFERENCE_CHAT_MODEL_ID": CHAT_MODEL,
|
| 38 |
-
"HF_QUIZ_MODEL_ID": CHAT_MODEL,
|
| 39 |
-
"HF_RAG_MODEL_ID": REASONER_MODEL,
|
| 40 |
-
"INFERENCE_LOCK_MODEL_ID": CHAT_MODEL,
|
| 41 |
-
},
|
| 42 |
-
"budget": {
|
| 43 |
-
"INFERENCE_MODEL_ID": CHAT_MODEL,
|
| 44 |
-
"INFERENCE_CHAT_MODEL_ID": CHAT_MODEL,
|
| 45 |
-
"HF_QUIZ_MODEL_ID": CHAT_MODEL,
|
| 46 |
-
"HF_RAG_MODEL_ID": CHAT_MODEL,
|
| 47 |
-
"INFERENCE_LOCK_MODEL_ID": CHAT_MODEL,
|
| 48 |
-
},
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
# ── Runtime Override Store ────────────────────────────────────────────────────
|
| 52 |
-
# Mutated at runtime by the admin panel via /api/admin/model-config.
|
| 53 |
-
# Priority: above env vars, below INFERENCE_ENFORCE_LOCK_MODEL.
|
| 54 |
-
# Persisted to Firestore so backend cold-restarts restore the last admin-set config.
|
| 55 |
-
|
| 56 |
-
_RUNTIME_OVERRIDES: dict[str, str] = {}
|
| 57 |
-
_RUNTIME_PROFILE: str = ""
|
| 58 |
-
|
| 59 |
-
_FS_COLLECTION = "system_config"
|
| 60 |
-
_FS_DOC = "active_model_config"
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def _save_runtime_config_to_firestore() -> None:
|
| 64 |
-
try:
|
| 65 |
-
from firebase_admin import firestore as fs
|
| 66 |
-
|
| 67 |
-
db = fs.client()
|
| 68 |
-
db.collection(_FS_COLLECTION).document(_FS_DOC).set(
|
| 69 |
-
{
|
| 70 |
-
"profile": _RUNTIME_PROFILE,
|
| 71 |
-
"overrides": _RUNTIME_OVERRIDES,
|
| 72 |
-
"updatedAt": fs.SERVER_TIMESTAMP,
|
| 73 |
-
}
|
| 74 |
-
)
|
| 75 |
-
except Exception as e:
|
| 76 |
-
LOGGER.warning("Could not persist model config to Firestore: %s", e)
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def _load_runtime_config_from_firestore() -> None:
|
| 80 |
-
try:
|
| 81 |
-
from firebase_admin import firestore as fs
|
| 82 |
-
|
| 83 |
-
db = fs.client()
|
| 84 |
-
doc = db.collection(_FS_COLLECTION).document(_FS_DOC).get()
|
| 85 |
-
if not doc.exists:
|
| 86 |
-
return
|
| 87 |
-
data = doc.to_dict() or {}
|
| 88 |
-
profile = str(data.get("profile", "")).strip().lower()
|
| 89 |
-
overrides = data.get("overrides", {})
|
| 90 |
-
if profile and profile in _MODEL_PROFILES:
|
| 91 |
-
global _RUNTIME_PROFILE
|
| 92 |
-
_RUNTIME_PROFILE = profile
|
| 93 |
-
_RUNTIME_OVERRIDES.clear()
|
| 94 |
-
_RUNTIME_OVERRIDES.update(_MODEL_PROFILES[profile])
|
| 95 |
-
if isinstance(overrides, dict):
|
| 96 |
-
for key, value in overrides.items():
|
| 97 |
-
_RUNTIME_OVERRIDES[str(key)] = str(value)
|
| 98 |
-
LOGGER.info("Restored runtime model config from Firestore: profile=%s", profile)
|
| 99 |
-
except ImportError:
|
| 100 |
-
LOGGER.debug("Firebase not available (optional for DeepSeek-only)")
|
| 101 |
-
except Exception as e:
|
| 102 |
-
LOGGER.warning("Could not restore model config from Firestore: %s", e)
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
def _apply_model_profile() -> None:
|
| 106 |
-
profile_name = os.getenv("MODEL_PROFILE", "").strip().lower()
|
| 107 |
-
if not profile_name:
|
| 108 |
-
return
|
| 109 |
-
profile = _MODEL_PROFILES.get(profile_name)
|
| 110 |
-
if profile is None:
|
| 111 |
-
LOGGER.warning("MODEL_PROFILE='%s' is not a known profile.", profile_name)
|
| 112 |
-
return
|
| 113 |
-
for key, value in profile.items():
|
| 114 |
-
if not os.environ.get(key):
|
| 115 |
-
os.environ[key] = value
|
| 116 |
-
LOGGER.info("Startup model profile applied: %s", profile_name)
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
_apply_model_profile()
|
| 120 |
-
_load_runtime_config_from_firestore()
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
def set_runtime_model_profile(profile_name: str) -> None:
|
| 124 |
-
"""Apply a named profile at runtime without restarting the process."""
|
| 125 |
-
global _RUNTIME_PROFILE, _RUNTIME_OVERRIDES
|
| 126 |
-
normalized = profile_name.strip().lower()
|
| 127 |
-
profile = _MODEL_PROFILES.get(normalized)
|
| 128 |
-
if not profile:
|
| 129 |
-
raise ValueError(
|
| 130 |
-
f"Unknown profile: '{profile_name}'. Valid values: {list(_MODEL_PROFILES.keys())}"
|
| 131 |
-
)
|
| 132 |
-
_RUNTIME_PROFILE = normalized
|
| 133 |
-
_RUNTIME_OVERRIDES.clear()
|
| 134 |
-
_RUNTIME_OVERRIDES.update(profile)
|
| 135 |
-
LOGGER.info("Runtime model profile switched to: %s", profile_name)
|
| 136 |
-
_save_runtime_config_to_firestore()
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
def set_runtime_model_override(key: str, value: str) -> None:
|
| 140 |
-
"""Set a single model env key at runtime."""
|
| 141 |
-
_RUNTIME_OVERRIDES[key] = value
|
| 142 |
-
LOGGER.info("Runtime model override set: %s = %s", key, value)
|
| 143 |
-
_save_runtime_config_to_firestore()
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
def reset_runtime_overrides() -> None:
|
| 147 |
-
"""Clear all runtime overrides."""
|
| 148 |
-
global _RUNTIME_PROFILE
|
| 149 |
-
_RUNTIME_OVERRIDES.clear()
|
| 150 |
-
_RUNTIME_PROFILE = ""
|
| 151 |
-
LOGGER.info("Runtime model overrides cleared.")
|
| 152 |
-
_save_runtime_config_to_firestore()
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
def get_current_runtime_config() -> dict:
|
| 156 |
-
resolved: dict[str, str] = {}
|
| 157 |
-
for key in {
|
| 158 |
-
"INFERENCE_MODEL_ID", "INFERENCE_CHAT_MODEL_ID",
|
| 159 |
-
"HF_QUIZ_MODEL_ID", "HF_RAG_MODEL_ID", "INFERENCE_LOCK_MODEL_ID",
|
| 160 |
-
}:
|
| 161 |
-
resolved[key] = _resolve_key(key)
|
| 162 |
-
return {
|
| 163 |
-
"profile": _RUNTIME_PROFILE,
|
| 164 |
-
"overrides": dict(_RUNTIME_OVERRIDES),
|
| 165 |
-
"resolved": resolved,
|
| 166 |
-
}
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
def _resolve_key(key: str) -> str:
|
| 170 |
-
if value := _RUNTIME_OVERRIDES.get(key):
|
| 171 |
-
return value
|
| 172 |
-
if _RUNTIME_PROFILE and _RUNTIME_PROFILE in _MODEL_PROFILES:
|
| 173 |
-
if value := _MODEL_PROFILES[_RUNTIME_PROFILE].get(key):
|
| 174 |
-
return value
|
| 175 |
-
return os.getenv(key, "")
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
def get_model_for_task(task_type: str) -> str:
|
| 179 |
-
task = (task_type or "default").strip().lower()
|
| 180 |
-
enforce_lock = os.getenv("INFERENCE_ENFORCE_LOCK_MODEL", "true").strip().lower() in {"1", "true", "yes", "on"}
|
| 181 |
-
if enforce_lock:
|
| 182 |
-
override = (
|
| 183 |
-
_RUNTIME_OVERRIDES.get("INFERENCE_LOCK_MODEL_ID")
|
| 184 |
-
or os.getenv("INFERENCE_LOCK_MODEL_ID")
|
| 185 |
-
or CHAT_MODEL
|
| 186 |
-
)
|
| 187 |
-
return override
|
| 188 |
-
task_key_map = {
|
| 189 |
-
"chat": "INFERENCE_CHAT_MODEL_ID",
|
| 190 |
-
"quiz_generation": "HF_QUIZ_MODEL_ID",
|
| 191 |
-
"rag_lesson": "HF_RAG_MODEL_ID",
|
| 192 |
-
"rag_problem": "HF_RAG_MODEL_ID",
|
| 193 |
-
"rag_analysis_context": "HF_RAG_MODEL_ID",
|
| 194 |
-
}
|
| 195 |
-
if env_key := task_key_map.get(task):
|
| 196 |
-
if resolved := _resolve_key(env_key):
|
| 197 |
-
return resolved
|
| 198 |
-
return _resolve_key("INFERENCE_MODEL_ID") or CHAT_MODEL
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
def model_supports_thinking(model_id: str = "") -> bool:
|
| 202 |
-
mid = (model_id or os.getenv("INFERENCE_MODEL_ID") or "").strip()
|
| 203 |
-
return mid == REASONER_MODEL
|
| 204 |
-
|
| 205 |
|
| 206 |
def _normalize_local_space_url(raw_url: str) -> str:
|
| 207 |
"""Accept either hf.space host or huggingface.co/spaces URL for local_space provider."""
|
|
@@ -209,6 +24,8 @@ def _normalize_local_space_url(raw_url: str) -> str:
|
|
| 209 |
if not cleaned:
|
| 210 |
return "http://127.0.0.1:7860"
|
| 211 |
|
|
|
|
|
|
|
| 212 |
match = re.match(r"^https?://huggingface\.co/spaces/([^/]+)/([^/]+)$", cleaned, re.IGNORECASE)
|
| 213 |
if match:
|
| 214 |
owner = match.group(1).strip().lower()
|
|
@@ -224,41 +41,38 @@ class InferenceRequest:
|
|
| 224 |
model: Optional[str] = None
|
| 225 |
task_type: str = "default"
|
| 226 |
request_tag: str = ""
|
| 227 |
-
max_new_tokens: int =
|
| 228 |
temperature: float = 0.2
|
| 229 |
top_p: float = 0.9
|
| 230 |
repetition_penalty: float = 1.15
|
| 231 |
timeout_sec: Optional[int] = None
|
| 232 |
-
enable_thinking: bool = False
|
| 233 |
|
| 234 |
|
| 235 |
class InferenceClient:
|
| 236 |
-
def __init__(self
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
self._persist_throttle_sec = 30.0
|
| 240 |
-
|
| 241 |
config_paths = [
|
| 242 |
-
Path("./config/models.yaml"),
|
| 243 |
-
Path("/config/models.yaml"),
|
| 244 |
-
Path("/app/config/models.yaml"),
|
| 245 |
-
Path.cwd() / "config" / "models.yaml",
|
| 246 |
-
Path(__file__).resolve().parents[2] / "config" / "models.yaml",
|
| 247 |
]
|
| 248 |
-
|
| 249 |
config: Dict[str, object] = {}
|
| 250 |
config_path = None
|
| 251 |
-
|
| 252 |
for path in config_paths:
|
| 253 |
if path.exists():
|
| 254 |
config_path = path
|
| 255 |
with path.open("r", encoding="utf-8") as fh:
|
| 256 |
config = yaml.safe_load(fh) or {}
|
| 257 |
-
LOGGER.info(f"
|
| 258 |
break
|
| 259 |
-
|
| 260 |
if not config_path:
|
| 261 |
-
LOGGER.warning(f"
|
| 262 |
LOGGER.warning(f" CWD: {Path.cwd()}")
|
| 263 |
LOGGER.warning(f" Using hardcoded defaults")
|
| 264 |
|
|
@@ -270,43 +84,69 @@ class InferenceClient:
|
|
| 270 |
if isinstance(primary_cfg, dict):
|
| 271 |
primary = primary_cfg
|
| 272 |
|
| 273 |
-
self.provider = "
|
| 274 |
-
self.
|
| 275 |
-
self.
|
| 276 |
-
self.
|
| 277 |
-
self.
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
self.local_space_url = _normalize_local_space_url(
|
| 280 |
os.getenv("INFERENCE_LOCAL_SPACE_URL", "http://127.0.0.1:7860")
|
| 281 |
)
|
| 282 |
self.local_generate_path = os.getenv("INFERENCE_LOCAL_SPACE_GENERATE_PATH", "/gradio_api/call/generate")
|
|
|
|
|
|
|
| 283 |
|
| 284 |
-
self.
|
| 285 |
-
self.
|
| 286 |
|
| 287 |
-
default_model_fallback = str(primary.get("id") or
|
| 288 |
env_model_id = os.getenv("INFERENCE_MODEL_ID", "").strip()
|
| 289 |
self.default_model = env_model_id or default_model_fallback
|
| 290 |
-
|
| 291 |
default_max_tokens = str(primary.get("max_new_tokens") or 512)
|
| 292 |
self.default_max_new_tokens = int(os.getenv("INFERENCE_MAX_NEW_TOKENS", default_max_tokens))
|
| 293 |
-
|
| 294 |
default_temp = str(primary.get("temperature") or 0.2)
|
| 295 |
self.default_temperature = float(os.getenv("INFERENCE_TEMPERATURE", default_temp))
|
| 296 |
-
|
| 297 |
default_top_p = str(primary.get("top_p") or 0.9)
|
| 298 |
self.default_top_p = float(os.getenv("INFERENCE_TOP_P", default_top_p))
|
| 299 |
-
|
|
|
|
| 300 |
self.chat_model_override = os.getenv("INFERENCE_CHAT_MODEL_ID", "").strip()
|
| 301 |
self.chat_model_temp_override = os.getenv(TEMP_CHAT_MODEL_OVERRIDE_ENV, "").strip()
|
| 302 |
self.chat_strict_model_only = os.getenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true").strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
|
| 304 |
-
self.
|
| 305 |
self.local_timeout_sec = int(os.getenv("INFERENCE_LOCAL_SPACE_TIMEOUT_SEC", "90"))
|
| 306 |
self.max_retries = int(os.getenv("INFERENCE_MAX_RETRIES", "3"))
|
| 307 |
self.backoff_sec = float(os.getenv("INFERENCE_BACKOFF_SEC", "1.5"))
|
| 308 |
-
self.interactive_timeout_sec = int(os.getenv("INFERENCE_INTERACTIVE_TIMEOUT_SEC", str(self.
|
| 309 |
-
self.background_timeout_sec = int(os.getenv("INFERENCE_BACKGROUND_TIMEOUT_SEC", str(self.
|
| 310 |
self.interactive_max_retries = int(os.getenv("INFERENCE_INTERACTIVE_MAX_RETRIES", str(self.max_retries)))
|
| 311 |
self.background_max_retries = int(os.getenv("INFERENCE_BACKGROUND_MAX_RETRIES", str(self.max_retries)))
|
| 312 |
self.interactive_backoff_sec = float(os.getenv("INFERENCE_INTERACTIVE_BACKOFF_SEC", str(self.backoff_sec)))
|
|
@@ -327,6 +167,12 @@ class InferenceClient:
|
|
| 327 |
)
|
| 328 |
self.cpu_only_tasks = {v.strip().lower() for v in cpu_tasks_raw.split(",") if v.strip()}
|
| 329 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
interactive_tasks_raw = os.getenv(
|
| 331 |
"INFERENCE_INTERACTIVE_TASKS",
|
| 332 |
"chat,verify_solution,daily_insight",
|
|
@@ -338,20 +184,29 @@ class InferenceClient:
|
|
| 338 |
)
|
| 339 |
|
| 340 |
# Default task-to-model routing.
|
|
|
|
| 341 |
self.task_model_map: Dict[str, str] = {
|
| 342 |
-
"chat":
|
| 343 |
-
"verify_solution":
|
| 344 |
-
"lesson_generation":
|
| 345 |
-
"quiz_generation":
|
| 346 |
-
"learning_path":
|
| 347 |
-
"daily_insight":
|
| 348 |
-
"risk_classification":
|
| 349 |
-
"risk_narrative":
|
| 350 |
}
|
|
|
|
| 351 |
self.task_fallback_model_map: Dict[str, List[str]] = {
|
| 352 |
-
"chat": [
|
| 353 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
}
|
|
|
|
| 355 |
self.model_provider_map: Dict[str, str] = {}
|
| 356 |
self.task_provider_map: Dict[str, str] = {}
|
| 357 |
if isinstance(config, dict):
|
|
@@ -364,6 +219,7 @@ class InferenceClient:
|
|
| 364 |
for task, model in task_models.items()
|
| 365 |
if str(task).strip() and str(model).strip()
|
| 366 |
}
|
|
|
|
| 367 |
self.task_model_map.update(config_task_models)
|
| 368 |
task_fallback_models = routing_cfg.get("task_fallback_model_map", {})
|
| 369 |
if isinstance(task_fallback_models, dict):
|
|
@@ -395,7 +251,7 @@ class InferenceClient:
|
|
| 395 |
for task_key in list(self.task_model_map.keys()):
|
| 396 |
self.task_model_map[task_key] = env_model_id
|
| 397 |
LOGGER.info(
|
| 398 |
-
f"
|
| 399 |
)
|
| 400 |
LOGGER.info(
|
| 401 |
f" Task model mappings changed from: {original_map}"
|
|
@@ -404,27 +260,29 @@ class InferenceClient:
|
|
| 404 |
else:
|
| 405 |
env_override_note = ""
|
| 406 |
|
| 407 |
-
if self.
|
| 408 |
-
|
| 409 |
-
self.default_model = self.
|
| 410 |
for task_key in list(self.task_model_map.keys()):
|
| 411 |
-
self.task_model_map[task_key] = self.
|
| 412 |
self.fallback_models = []
|
| 413 |
self.task_fallback_model_map = {
|
| 414 |
task_key: [] for task_key in self.task_model_map.keys()
|
| 415 |
}
|
| 416 |
-
|
| 417 |
-
LOGGER.info(f"
|
| 418 |
-
LOGGER.info(f"
|
|
|
|
| 419 |
|
|
|
|
| 420 |
config_status = "from file" if config_path else "hardcoded defaults (no config file found)"
|
| 421 |
effective_chat_model_for_logs = self.chat_model_override or self.task_model_map.get("chat", self.default_model)
|
| 422 |
-
LOGGER.info(f"
|
| 423 |
LOGGER.info(f" Default model: {self.default_model}")
|
| 424 |
LOGGER.info(f" Chat model: {effective_chat_model_for_logs}")
|
| 425 |
LOGGER.info(f" Chat temp override ({TEMP_CHAT_MODEL_OVERRIDE_ENV}): {self.chat_model_temp_override or 'disabled'}")
|
| 426 |
LOGGER.info(f" Chat strict model lock: {self.chat_strict_model_only}")
|
| 427 |
-
LOGGER.info(f" Global
|
| 428 |
LOGGER.info(f" Verify solution model: {self.task_model_map.get('verify_solution', self.default_model)}")
|
| 429 |
LOGGER.info(f" Full task_model_map: {self.task_model_map}")
|
| 430 |
|
|
@@ -436,23 +294,18 @@ class InferenceClient:
|
|
| 436 |
"requests_error": 0,
|
| 437 |
"retries_total": 0,
|
| 438 |
"fallback_attempts": 0,
|
| 439 |
-
"latency_sum_ms": 0.0,
|
| 440 |
-
"latency_count": 0,
|
| 441 |
"route_counts": {},
|
| 442 |
"task_counts": {},
|
| 443 |
"provider_counts": {},
|
| 444 |
"status_code_counts": {},
|
| 445 |
}
|
| 446 |
|
| 447 |
-
self._load_persistent_metrics()
|
| 448 |
-
|
| 449 |
def _bump_metric(self, key: str, inc: int = 1) -> None:
|
| 450 |
with self._metrics_lock:
|
| 451 |
current = self._metrics.get(key) or 0
|
| 452 |
if not isinstance(current, int):
|
| 453 |
current = 0
|
| 454 |
self._metrics[key] = current + inc
|
| 455 |
-
self._persist_metrics()
|
| 456 |
|
| 457 |
def _bump_bucket(self, key: str, bucket: str, inc: int = 1) -> None:
|
| 458 |
with self._metrics_lock:
|
|
@@ -464,50 +317,6 @@ class InferenceClient:
|
|
| 464 |
if not isinstance(current, int):
|
| 465 |
current = 0
|
| 466 |
mapping[bucket] = current + inc
|
| 467 |
-
self._persist_metrics()
|
| 468 |
-
|
| 469 |
-
def _record_completion(self, *, latency_ms: float) -> None:
|
| 470 |
-
with self._metrics_lock:
|
| 471 |
-
self._metrics["latency_sum_ms"] = (self._metrics.get("latency_sum_ms") or 0.0) + latency_ms
|
| 472 |
-
self._metrics["latency_count"] = (self._metrics.get("latency_count") or 0) + 1
|
| 473 |
-
self._persist_metrics()
|
| 474 |
-
|
| 475 |
-
def _load_persistent_metrics(self) -> None:
|
| 476 |
-
if not self.firestore:
|
| 477 |
-
return
|
| 478 |
-
try:
|
| 479 |
-
doc_ref = self.firestore.collection("system_metrics").document("inference_stats")
|
| 480 |
-
doc = doc_ref.get()
|
| 481 |
-
if doc.exists:
|
| 482 |
-
data = doc.to_dict() or {}
|
| 483 |
-
with self._metrics_lock:
|
| 484 |
-
for k, v in data.items():
|
| 485 |
-
if k in self._metrics:
|
| 486 |
-
if isinstance(v, (int, float)):
|
| 487 |
-
self._metrics[k] = v
|
| 488 |
-
elif isinstance(v, dict) and isinstance(self._metrics[k], dict):
|
| 489 |
-
self._metrics[k].update(v)
|
| 490 |
-
LOGGER.info("??? Persistent inference metrics loaded from Firestore")
|
| 491 |
-
except Exception as e:
|
| 492 |
-
LOGGER.warning(f"?????? Failed to load persistent metrics: {e}")
|
| 493 |
-
|
| 494 |
-
def _persist_metrics(self, force: bool = False) -> None:
|
| 495 |
-
if not self.firestore:
|
| 496 |
-
return
|
| 497 |
-
|
| 498 |
-
now = time.time()
|
| 499 |
-
if not force and (now - self._last_persist_time < self._persist_throttle_sec):
|
| 500 |
-
return
|
| 501 |
-
|
| 502 |
-
try:
|
| 503 |
-
self._last_persist_time = now
|
| 504 |
-
doc_ref = self.firestore.collection("system_metrics").document("inference_stats")
|
| 505 |
-
with self._metrics_lock:
|
| 506 |
-
snapshot = dict(self._metrics)
|
| 507 |
-
|
| 508 |
-
doc_ref.set(snapshot, merge=True)
|
| 509 |
-
except Exception as e:
|
| 510 |
-
LOGGER.warning(f"?????? Failed to persist metrics: {e}")
|
| 511 |
|
| 512 |
def _record_attempt(self, *, task_type: str, provider: str, route: str, fallback_depth: int) -> None:
|
| 513 |
self._bump_metric("requests_total", 1)
|
|
@@ -519,10 +328,6 @@ class InferenceClient:
|
|
| 519 |
|
| 520 |
def snapshot_metrics(self) -> Dict[str, Any]:
|
| 521 |
with self._metrics_lock:
|
| 522 |
-
l_sum = self._metrics.get("latency_sum_ms") or 0.0
|
| 523 |
-
l_count = self._metrics.get("latency_count") or 0
|
| 524 |
-
avg_latency = round(l_sum / l_count, 2) if l_count > 0 else 0.0
|
| 525 |
-
|
| 526 |
snapshot = {
|
| 527 |
"uptime_sec": round(max(0.0, time.time() - self._metrics_started_at), 2),
|
| 528 |
"requests_total": self._metrics.get("requests_total") or 0,
|
|
@@ -530,9 +335,6 @@ class InferenceClient:
|
|
| 530 |
"requests_error": self._metrics.get("requests_error") or 0,
|
| 531 |
"retries_total": self._metrics.get("retries_total") or 0,
|
| 532 |
"fallback_attempts": self._metrics.get("fallback_attempts") or 0,
|
| 533 |
-
"avg_latency_ms": avg_latency,
|
| 534 |
-
"active_model": self.default_model,
|
| 535 |
-
"primary_provider": self.provider,
|
| 536 |
"route_counts": dict(self._metrics.get("route_counts") or {}),
|
| 537 |
"task_counts": dict(self._metrics.get("task_counts") or {}),
|
| 538 |
"provider_counts": dict(self._metrics.get("provider_counts") or {}),
|
|
@@ -544,18 +346,22 @@ class InferenceClient:
|
|
| 544 |
effective_task = (req.task_type or "default").strip().lower()
|
| 545 |
request_tag = req.request_tag.strip() or f"{effective_task}-{int(time.time() * 1000)}"
|
| 546 |
selected_model, model_selection_source = self._resolve_primary_model(req)
|
| 547 |
-
|
| 548 |
model_chain = self._model_chain_for_task(effective_task, selected_model)
|
| 549 |
last_error: Optional[Exception] = None
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
|
|
|
|
|
|
|
|
|
| 553 |
LOGGER.info(
|
| 554 |
-
f"
|
| 555 |
-
f"selected_model={model_base} (primary)"
|
| 556 |
)
|
| 557 |
LOGGER.info(f" fallback_chain={model_chain[1:] if len(model_chain) > 1 else 'none'}")
|
| 558 |
|
|
|
|
| 559 |
for fallback_depth, model_name in enumerate(model_chain):
|
| 560 |
request_for_model = InferenceRequest(
|
| 561 |
messages=req.messages,
|
|
@@ -568,19 +374,20 @@ class InferenceClient:
|
|
| 568 |
repetition_penalty=req.repetition_penalty,
|
| 569 |
timeout_sec=req.timeout_sec,
|
| 570 |
)
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
|
|
|
| 584 |
|
| 585 |
if last_error:
|
| 586 |
raise last_error
|
|
@@ -593,6 +400,10 @@ class InferenceClient:
|
|
| 593 |
effective_task = (req.task_type or "default").strip().lower()
|
| 594 |
runtime_chat_override = self._runtime_chat_model_override()
|
| 595 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 596 |
if effective_task == "chat" and runtime_chat_override:
|
| 597 |
selected_model = runtime_chat_override
|
| 598 |
model_selection_source = "chat_temp_override_env"
|
|
@@ -606,39 +417,107 @@ class InferenceClient:
|
|
| 606 |
selected_model = self.task_model_map.get(effective_task, self.default_model)
|
| 607 |
model_selection_source = "task_map"
|
| 608 |
|
| 609 |
-
if self.
|
| 610 |
-
|
| 611 |
if effective_task == "chat":
|
| 612 |
-
|
| 613 |
|
| 614 |
-
selected_base = (selected_model
|
| 615 |
-
lock_base = (
|
| 616 |
if selected_base != lock_base:
|
| 617 |
LOGGER.warning(
|
| 618 |
-
f"
|
| 619 |
)
|
| 620 |
-
selected_model =
|
| 621 |
-
model_selection_source = f"{model_selection_source}:
|
| 622 |
|
| 623 |
if effective_task == "chat" and self.chat_strict_model_only:
|
| 624 |
return selected_model, f"{model_selection_source}:chat_strict_model_only"
|
| 625 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 626 |
return selected_model, model_selection_source
|
| 627 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 628 |
def _model_chain_for_task(self, task_type: str, selected_model: str) -> List[str]:
|
| 629 |
normalized = (task_type or "default").strip().lower()
|
| 630 |
runtime_chat_override = self._runtime_chat_model_override() if normalized == "chat" else ""
|
| 631 |
-
|
| 632 |
|
| 633 |
-
if self.
|
| 634 |
if normalized == "chat":
|
| 635 |
-
locked_model = (
|
| 636 |
else:
|
| 637 |
-
locked_model = (self.
|
| 638 |
return [locked_model] if locked_model else []
|
| 639 |
|
| 640 |
if normalized == "chat" and self.chat_strict_model_only:
|
| 641 |
-
chat_model = (
|
| 642 |
return [chat_model] if chat_model else []
|
| 643 |
|
| 644 |
per_task_candidates = self.task_fallback_model_map.get(task_type, [])
|
|
@@ -658,6 +537,34 @@ class InferenceClient:
|
|
| 658 |
return deduped[:max_models]
|
| 659 |
return deduped
|
| 660 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
def _retry_profile(self, task_type: str) -> Tuple[int, float]:
|
| 662 |
normalized = (task_type or "default").strip().lower()
|
| 663 |
if normalized in self.interactive_tasks:
|
|
@@ -674,6 +581,20 @@ class InferenceClient:
|
|
| 674 |
return self.interactive_timeout_sec
|
| 675 |
return self.background_timeout_sec
|
| 676 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
def _messages_to_prompt(self, messages: List[Dict[str, str]]) -> str:
|
| 678 |
parts: List[str] = []
|
| 679 |
for msg in messages:
|
|
@@ -686,9 +607,9 @@ class InferenceClient:
|
|
| 686 |
prefix = "SYSTEM"
|
| 687 |
elif role == "assistant":
|
| 688 |
prefix = "ASSISTANT"
|
| 689 |
-
parts.append(f"{prefix}:\n{content}")
|
| 690 |
parts.append("ASSISTANT:")
|
| 691 |
-
return "\n\n".join(parts)
|
| 692 |
|
| 693 |
def _latest_user_message(self, messages: List[Dict[str, str]]) -> str:
|
| 694 |
for msg in reversed(messages):
|
|
@@ -698,223 +619,160 @@ class InferenceClient:
|
|
| 698 |
return content
|
| 699 |
return self._messages_to_prompt(messages)
|
| 700 |
|
| 701 |
-
def
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 713 |
)
|
| 714 |
-
|
| 715 |
-
timeout = self._timeout_for(req, "deepseek")
|
| 716 |
max_retries, backoff_sec = self._retry_profile(task_type)
|
|
|
|
| 717 |
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
"model": target_model,
|
| 723 |
-
"messages": req.messages,
|
| 724 |
-
"max_tokens": req.max_new_tokens or self.default_max_new_tokens,
|
| 725 |
-
}
|
| 726 |
-
|
| 727 |
-
if target_model == REASONER_MODEL:
|
| 728 |
-
params["max_tokens"] = req.max_new_tokens or 1024
|
| 729 |
-
else:
|
| 730 |
-
params["temperature"] = req.temperature
|
| 731 |
-
params["top_p"] = req.top_p
|
| 732 |
-
|
| 733 |
-
# Use JSON mode for quiz generation
|
| 734 |
-
if task_type == "quiz_generation" and target_model != REASONER_MODEL:
|
| 735 |
-
params["response_format"] = {"type": "json_object"}
|
| 736 |
|
| 737 |
-
|
| 738 |
-
self._record_attempt(
|
| 739 |
-
task_type=task_type,
|
| 740 |
-
provider="deepseek",
|
| 741 |
-
route=route,
|
| 742 |
-
fallback_depth=fallback_depth,
|
| 743 |
-
)
|
| 744 |
start = time.perf_counter()
|
| 745 |
try:
|
| 746 |
-
|
|
|
|
| 747 |
latency_ms = (time.perf_counter() - start) * 1000
|
| 748 |
-
|
| 749 |
-
content = response.choices[0].message.content or ""
|
| 750 |
-
reasoning = getattr(response.choices[0].message, "reasoning_content", None)
|
| 751 |
-
|
| 752 |
-
text = content.strip()
|
| 753 |
-
if reasoning:
|
| 754 |
-
text = f"{reasoning}\n{text}"
|
| 755 |
-
|
| 756 |
log_model_call(
|
| 757 |
LOGGER,
|
| 758 |
-
provider=
|
| 759 |
-
model=
|
| 760 |
-
endpoint=
|
| 761 |
latency_ms=latency_ms,
|
| 762 |
input_tokens=None,
|
| 763 |
output_tokens=None,
|
| 764 |
-
status="
|
|
|
|
|
|
|
| 765 |
task_type=task_type,
|
| 766 |
-
request_tag=
|
| 767 |
retry_attempt=attempt + 1,
|
| 768 |
fallback_depth=fallback_depth,
|
| 769 |
route=route,
|
| 770 |
)
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
)
|
| 777 |
-
|
| 778 |
-
self._bump_metric("requests_ok", 1)
|
| 779 |
-
return text
|
| 780 |
-
|
| 781 |
-
except RateLimitError:
|
| 782 |
-
latency_ms = (time.perf_counter() - start) * 1000
|
| 783 |
-
if attempt < max_retries - 1:
|
| 784 |
-
log_model_call(
|
| 785 |
-
LOGGER,
|
| 786 |
-
provider="deepseek",
|
| 787 |
-
model=target_model,
|
| 788 |
-
endpoint=self.ds_base_url,
|
| 789 |
-
latency_ms=latency_ms,
|
| 790 |
-
input_tokens=None,
|
| 791 |
-
output_tokens=None,
|
| 792 |
-
status="error",
|
| 793 |
-
error_class="RateLimitError",
|
| 794 |
-
error_message="rate limited",
|
| 795 |
-
task_type=task_type,
|
| 796 |
-
request_tag=req.request_tag,
|
| 797 |
-
retry_attempt=attempt + 1,
|
| 798 |
-
fallback_depth=fallback_depth,
|
| 799 |
-
route=route,
|
| 800 |
-
)
|
| 801 |
-
self._bump_metric("retries_total", 1)
|
| 802 |
-
time.sleep(backoff_sec * (attempt + 1) * random.uniform(0.9, 1.2))
|
| 803 |
-
continue
|
| 804 |
-
self._bump_metric("requests_error", 1)
|
| 805 |
-
raise RuntimeError("DeepSeek API rate limit reached. Please try again shortly.")
|
| 806 |
-
|
| 807 |
-
except APITimeoutError:
|
| 808 |
-
latency_ms = (time.perf_counter() - start) * 1000
|
| 809 |
-
if attempt < max_retries - 1:
|
| 810 |
-
log_model_call(
|
| 811 |
-
LOGGER,
|
| 812 |
-
provider="deepseek",
|
| 813 |
-
model=target_model,
|
| 814 |
-
endpoint=self.ds_base_url,
|
| 815 |
-
latency_ms=latency_ms,
|
| 816 |
-
input_tokens=None,
|
| 817 |
-
output_tokens=None,
|
| 818 |
-
status="error",
|
| 819 |
-
error_class="APITimeoutError",
|
| 820 |
-
error_message="timeout",
|
| 821 |
-
task_type=task_type,
|
| 822 |
-
request_tag=req.request_tag,
|
| 823 |
-
retry_attempt=attempt + 1,
|
| 824 |
-
fallback_depth=fallback_depth,
|
| 825 |
-
route=route,
|
| 826 |
-
)
|
| 827 |
-
self._bump_metric("retries_total", 1)
|
| 828 |
-
time.sleep(backoff_sec * (attempt + 1) * random.uniform(0.9, 1.2))
|
| 829 |
-
continue
|
| 830 |
-
self._bump_metric("requests_error", 1)
|
| 831 |
-
raise RuntimeError("DeepSeek API timed out. Please retry.")
|
| 832 |
-
|
| 833 |
-
except APIError as e:
|
| 834 |
-
latency_ms = (time.perf_counter() - start) * 1000
|
| 835 |
-
if attempt < max_retries - 1:
|
| 836 |
-
log_model_call(
|
| 837 |
-
LOGGER,
|
| 838 |
-
provider="deepseek",
|
| 839 |
-
model=target_model,
|
| 840 |
-
endpoint=self.ds_base_url,
|
| 841 |
-
latency_ms=latency_ms,
|
| 842 |
-
input_tokens=None,
|
| 843 |
-
output_tokens=None,
|
| 844 |
-
status="error",
|
| 845 |
-
error_class="APIError",
|
| 846 |
-
error_message=str(e)[:200],
|
| 847 |
-
task_type=task_type,
|
| 848 |
-
request_tag=req.request_tag,
|
| 849 |
-
retry_attempt=attempt + 1,
|
| 850 |
-
fallback_depth=fallback_depth,
|
| 851 |
-
route=route,
|
| 852 |
-
)
|
| 853 |
-
self._bump_metric("retries_total", 1)
|
| 854 |
-
time.sleep(backoff_sec * (attempt + 1) * random.uniform(0.9, 1.2))
|
| 855 |
-
continue
|
| 856 |
-
self._bump_metric("requests_error", 1)
|
| 857 |
-
raise RuntimeError(f"DeepSeek API error: {str(e)}")
|
| 858 |
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
self._bump_metric("requests_error", 1)
|
| 862 |
log_model_call(
|
| 863 |
LOGGER,
|
| 864 |
-
provider=
|
| 865 |
-
model=
|
| 866 |
-
endpoint=
|
| 867 |
latency_ms=latency_ms,
|
| 868 |
input_tokens=None,
|
| 869 |
output_tokens=None,
|
| 870 |
status="error",
|
| 871 |
-
error_class=
|
| 872 |
-
error_message=
|
| 873 |
task_type=task_type,
|
| 874 |
-
request_tag=
|
| 875 |
retry_attempt=attempt + 1,
|
| 876 |
fallback_depth=fallback_depth,
|
| 877 |
route=route,
|
| 878 |
)
|
| 879 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 880 |
|
| 881 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 882 |
|
| 883 |
-
def _call_local_space(self, req: InferenceRequest, *, provider: str, route: str, fallback_depth: int) -> str:
|
| 884 |
target_model = req.model or self.default_model
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
prompt = self._messages_to_prompt(req.messages)
|
| 888 |
-
payload: Dict[str, object] = {
|
| 889 |
-
"data": [
|
| 890 |
-
prompt,
|
| 891 |
-
[],
|
| 892 |
-
req.temperature,
|
| 893 |
-
req.top_p,
|
| 894 |
-
req.max_new_tokens,
|
| 895 |
-
]
|
| 896 |
-
}
|
| 897 |
-
headers = {"Content-Type": "application/json"}
|
| 898 |
-
|
| 899 |
timeout = self._timeout_for(req, provider)
|
| 900 |
-
|
| 901 |
-
self._record_attempt(
|
| 902 |
-
task_type=req.task_type,
|
| 903 |
-
provider=provider,
|
| 904 |
-
route=route,
|
| 905 |
-
fallback_depth=fallback_depth,
|
| 906 |
-
)
|
| 907 |
start = time.perf_counter()
|
| 908 |
-
|
| 909 |
try:
|
| 910 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 911 |
except Exception as exc:
|
| 912 |
latency_ms = (time.perf_counter() - start) * 1000
|
|
|
|
| 913 |
log_model_call(
|
| 914 |
LOGGER,
|
| 915 |
-
provider=
|
| 916 |
-
model=
|
| 917 |
-
endpoint=
|
| 918 |
latency_ms=latency_ms,
|
| 919 |
input_tokens=None,
|
| 920 |
output_tokens=None,
|
|
@@ -927,10 +785,182 @@ class InferenceClient:
|
|
| 927 |
fallback_depth=fallback_depth,
|
| 928 |
route=route,
|
| 929 |
)
|
| 930 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 931 |
raise
|
| 932 |
|
| 933 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 934 |
self._bump_bucket("status_code_counts", str(resp.status_code), 1)
|
| 935 |
|
| 936 |
if resp.status_code != 200:
|
|
@@ -969,7 +999,7 @@ class InferenceClient:
|
|
| 969 |
status="ok",
|
| 970 |
task_type=req.task_type,
|
| 971 |
request_tag=req.request_tag,
|
| 972 |
-
retry_attempt=
|
| 973 |
fallback_depth=fallback_depth,
|
| 974 |
route=route,
|
| 975 |
)
|
|
@@ -1010,39 +1040,32 @@ class InferenceClient:
|
|
| 1010 |
|
| 1011 |
def _clean_response_text(self, text: str) -> str:
|
| 1012 |
"""Strip JSON braces, template artifacts, and whitespace from response text."""
|
|
|
|
| 1013 |
text = text.strip()
|
| 1014 |
-
|
|
|
|
| 1015 |
if text.startswith("{") and text.endswith("}"):
|
| 1016 |
try:
|
|
|
|
| 1017 |
parsed = json.loads(text)
|
|
|
|
| 1018 |
if isinstance(parsed, dict):
|
| 1019 |
if "content" in parsed:
|
| 1020 |
text = str(parsed["content"]).strip()
|
| 1021 |
elif "text" in parsed:
|
| 1022 |
text = str(parsed["text"]).strip()
|
| 1023 |
except json.JSONDecodeError:
|
|
|
|
| 1024 |
text = text.strip("{}")
|
| 1025 |
-
|
|
|
|
| 1026 |
if text.startswith("```json") or text.startswith("```"):
|
| 1027 |
text = re.sub(r"^```(?:json)?", "", text).strip()
|
| 1028 |
if text.endswith("```"):
|
| 1029 |
text = text[:-3].strip()
|
| 1030 |
-
|
| 1031 |
return text.strip()
|
| 1032 |
|
| 1033 |
|
| 1034 |
-
def create_default_client(
|
| 1035 |
-
return InferenceClient(
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
def is_sequential_model(model_id: str = "") -> bool:
|
| 1039 |
-
mid = (model_id or os.getenv("INFERENCE_MODEL_ID") or "").strip()
|
| 1040 |
-
if not mid:
|
| 1041 |
-
return False
|
| 1042 |
-
if mid == REASONER_MODEL:
|
| 1043 |
-
return True
|
| 1044 |
-
if _RUNTIME_OVERRIDES:
|
| 1045 |
-
lock = _RUNTIME_OVERRIDES.get("INFERENCE_LOCK_MODEL_ID", "")
|
| 1046 |
-
if lock == REASONER_MODEL:
|
| 1047 |
-
return True
|
| 1048 |
-
return False
|
|
|
|
| 10 |
|
| 11 |
import requests
|
| 12 |
import yaml
|
| 13 |
+
from huggingface_hub import InferenceClient as HFInferenceClient
|
| 14 |
|
|
|
|
| 15 |
from .logging_utils import configure_structured_logging, log_model_call
|
| 16 |
|
| 17 |
LOGGER = configure_structured_logging("mathpulse.inference")
|
| 18 |
TEMP_CHAT_MODEL_OVERRIDE_ENV = "INFERENCE_CHAT_MODEL_TEMP_OVERRIDE"
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
def _normalize_local_space_url(raw_url: str) -> str:
|
| 22 |
"""Accept either hf.space host or huggingface.co/spaces URL for local_space provider."""
|
|
|
|
| 24 |
if not cleaned:
|
| 25 |
return "http://127.0.0.1:7860"
|
| 26 |
|
| 27 |
+
# Convert page URL format to runtime host format:
|
| 28 |
+
# https://huggingface.co/spaces/{owner}/{space} -> https://{owner}-{space}.hf.space
|
| 29 |
match = re.match(r"^https?://huggingface\.co/spaces/([^/]+)/([^/]+)$", cleaned, re.IGNORECASE)
|
| 30 |
if match:
|
| 31 |
owner = match.group(1).strip().lower()
|
|
|
|
| 41 |
model: Optional[str] = None
|
| 42 |
task_type: str = "default"
|
| 43 |
request_tag: str = ""
|
| 44 |
+
max_new_tokens: int = 512
|
| 45 |
temperature: float = 0.2
|
| 46 |
top_p: float = 0.9
|
| 47 |
repetition_penalty: float = 1.15
|
| 48 |
timeout_sec: Optional[int] = None
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
class InferenceClient:
|
| 52 |
+
def __init__(self) -> None:
|
| 53 |
+
# Try multiple config paths (HF Space, Docker, local development)
|
| 54 |
+
# The deploy script uploads config/ to the space root
|
|
|
|
|
|
|
| 55 |
config_paths = [
|
| 56 |
+
Path("./config/models.yaml"), # Current working directory (most reliable)
|
| 57 |
+
Path("/config/models.yaml"), # HF Space root
|
| 58 |
+
Path("/app/config/models.yaml"), # App directory
|
| 59 |
+
Path.cwd() / "config" / "models.yaml", # CWD with config subdir
|
| 60 |
+
Path(__file__).resolve().parents[2] / "config" / "models.yaml", # Package root
|
| 61 |
]
|
| 62 |
+
|
| 63 |
config: Dict[str, object] = {}
|
| 64 |
config_path = None
|
| 65 |
+
|
| 66 |
for path in config_paths:
|
| 67 |
if path.exists():
|
| 68 |
config_path = path
|
| 69 |
with path.open("r", encoding="utf-8") as fh:
|
| 70 |
config = yaml.safe_load(fh) or {}
|
| 71 |
+
LOGGER.info(f"✅ Loaded config from {config_path}")
|
| 72 |
break
|
| 73 |
+
|
| 74 |
if not config_path:
|
| 75 |
+
LOGGER.warning(f"⚠️ Config file not found. Checked: {[str(p) for p in config_paths]}")
|
| 76 |
LOGGER.warning(f" CWD: {Path.cwd()}")
|
| 77 |
LOGGER.warning(f" Using hardcoded defaults")
|
| 78 |
|
|
|
|
| 84 |
if isinstance(primary_cfg, dict):
|
| 85 |
primary = primary_cfg
|
| 86 |
|
| 87 |
+
self.provider = os.getenv("INFERENCE_PROVIDER", "hf_inference").strip().lower()
|
| 88 |
+
self.pro_provider = os.getenv("INFERENCE_PRO_PROVIDER", "hf_inference").strip().lower()
|
| 89 |
+
self.gpu_provider = os.getenv("INFERENCE_GPU_PROVIDER", "hf_inference").strip().lower()
|
| 90 |
+
self.cpu_provider = os.getenv("INFERENCE_CPU_PROVIDER", "hf_inference").strip().lower()
|
| 91 |
+
self.enable_provider_fallback = os.getenv("INFERENCE_ENABLE_PROVIDER_FALLBACK", "true").strip().lower() in {"1", "true", "yes", "on"}
|
| 92 |
+
self.pro_enabled = os.getenv("INFERENCE_PRO_ENABLED", "false").strip().lower() in {"1", "true", "yes", "on"}
|
| 93 |
+
self.hf_token = os.getenv(
|
| 94 |
+
"HF_TOKEN",
|
| 95 |
+
os.getenv("HUGGING_FACE_API_TOKEN", os.getenv("HUGGINGFACE_API_TOKEN", "")),
|
| 96 |
+
)
|
| 97 |
+
self.hf_base_url = os.getenv("INFERENCE_HF_BASE_URL", "https://router.huggingface.co/hf-inference/models")
|
| 98 |
+
self.hf_chat_url = os.getenv("INFERENCE_HF_CHAT_URL", "https://router.huggingface.co/v1/chat/completions")
|
| 99 |
+
|
| 100 |
+
# Featherless AI for Qwen math models (used as fallback when HF router fails)
|
| 101 |
+
self.featherless_api_key = os.getenv("FEATHERLESS_API_KEY", "")
|
| 102 |
+
self.featherless_chat_url = os.getenv("FEATHERLESS_CHAT_URL", "https://api.featherless.ai/openai/v1/chat/completions")
|
| 103 |
+
|
| 104 |
self.local_space_url = _normalize_local_space_url(
|
| 105 |
os.getenv("INFERENCE_LOCAL_SPACE_URL", "http://127.0.0.1:7860")
|
| 106 |
)
|
| 107 |
self.local_generate_path = os.getenv("INFERENCE_LOCAL_SPACE_GENERATE_PATH", "/gradio_api/call/generate")
|
| 108 |
+
self.pro_route_header_name = os.getenv("INFERENCE_PRO_ROUTE_HEADER_NAME", "")
|
| 109 |
+
self.pro_route_header_value = os.getenv("INFERENCE_PRO_ROUTE_HEADER_VALUE", "true")
|
| 110 |
|
| 111 |
+
self.enforce_qwen_only = os.getenv("INFERENCE_ENFORCE_QWEN_ONLY", "true").strip().lower() in {"1", "true", "yes", "on"}
|
| 112 |
+
self.qwen_lock_model = os.getenv("INFERENCE_QWEN_LOCK_MODEL", "Qwen/Qwen3-32B").strip() or "Qwen/Qwen3-32B"
|
| 113 |
|
| 114 |
+
default_model_fallback = str(primary.get("id") or "Qwen/Qwen3-32B")
|
| 115 |
env_model_id = os.getenv("INFERENCE_MODEL_ID", "").strip()
|
| 116 |
self.default_model = env_model_id or default_model_fallback
|
| 117 |
+
|
| 118 |
default_max_tokens = str(primary.get("max_new_tokens") or 512)
|
| 119 |
self.default_max_new_tokens = int(os.getenv("INFERENCE_MAX_NEW_TOKENS", default_max_tokens))
|
| 120 |
+
|
| 121 |
default_temp = str(primary.get("temperature") or 0.2)
|
| 122 |
self.default_temperature = float(os.getenv("INFERENCE_TEMPERATURE", default_temp))
|
| 123 |
+
|
| 124 |
default_top_p = str(primary.get("top_p") or 0.9)
|
| 125 |
self.default_top_p = float(os.getenv("INFERENCE_TOP_P", default_top_p))
|
| 126 |
+
|
| 127 |
+
# Task-specific model overrides via environment variables
|
| 128 |
self.chat_model_override = os.getenv("INFERENCE_CHAT_MODEL_ID", "").strip()
|
| 129 |
self.chat_model_temp_override = os.getenv(TEMP_CHAT_MODEL_OVERRIDE_ENV, "").strip()
|
| 130 |
self.chat_strict_model_only = os.getenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true").strip().lower() in {"1", "true", "yes", "on"}
|
| 131 |
+
self.chat_hard_model = os.getenv("INFERENCE_CHAT_HARD_MODEL_ID", "meta-llama/Meta-Llama-3-70B-Instruct").strip()
|
| 132 |
+
self.chat_hard_trigger_enabled = os.getenv("INFERENCE_CHAT_HARD_TRIGGER_ENABLED", "false").strip().lower() in {"1", "true", "yes", "on"}
|
| 133 |
+
self.chat_hard_prompt_chars = max(256, int(os.getenv("INFERENCE_CHAT_HARD_PROMPT_CHARS", "800")))
|
| 134 |
+
self.chat_hard_history_chars = max(
|
| 135 |
+
self.chat_hard_prompt_chars,
|
| 136 |
+
int(os.getenv("INFERENCE_CHAT_HARD_HISTORY_CHARS", "1800")),
|
| 137 |
+
)
|
| 138 |
+
hard_keywords_raw = os.getenv(
|
| 139 |
+
"INFERENCE_CHAT_HARD_KEYWORDS",
|
| 140 |
+
"step-by-step,show all steps,derive,proof,prove,rigorous,multi-step,word problem",
|
| 141 |
+
)
|
| 142 |
+
self.chat_hard_keywords = [kw.strip().lower() for kw in hard_keywords_raw.split(",") if kw.strip()]
|
| 143 |
|
| 144 |
+
self.hf_timeout_sec = int(os.getenv("INFERENCE_HF_TIMEOUT_SEC", "90"))
|
| 145 |
self.local_timeout_sec = int(os.getenv("INFERENCE_LOCAL_SPACE_TIMEOUT_SEC", "90"))
|
| 146 |
self.max_retries = int(os.getenv("INFERENCE_MAX_RETRIES", "3"))
|
| 147 |
self.backoff_sec = float(os.getenv("INFERENCE_BACKOFF_SEC", "1.5"))
|
| 148 |
+
self.interactive_timeout_sec = int(os.getenv("INFERENCE_INTERACTIVE_TIMEOUT_SEC", str(self.hf_timeout_sec)))
|
| 149 |
+
self.background_timeout_sec = int(os.getenv("INFERENCE_BACKGROUND_TIMEOUT_SEC", str(self.hf_timeout_sec)))
|
| 150 |
self.interactive_max_retries = int(os.getenv("INFERENCE_INTERACTIVE_MAX_RETRIES", str(self.max_retries)))
|
| 151 |
self.background_max_retries = int(os.getenv("INFERENCE_BACKGROUND_MAX_RETRIES", str(self.max_retries)))
|
| 152 |
self.interactive_backoff_sec = float(os.getenv("INFERENCE_INTERACTIVE_BACKOFF_SEC", str(self.backoff_sec)))
|
|
|
|
| 167 |
)
|
| 168 |
self.cpu_only_tasks = {v.strip().lower() for v in cpu_tasks_raw.split(",") if v.strip()}
|
| 169 |
|
| 170 |
+
pro_tasks_raw = os.getenv(
|
| 171 |
+
"INFERENCE_PRO_PRIORITY_TASKS",
|
| 172 |
+
"chat,quiz_generation,lesson_generation,learning_path,verify_solution",
|
| 173 |
+
)
|
| 174 |
+
self.pro_priority_tasks = {v.strip().lower() for v in pro_tasks_raw.split(",") if v.strip()}
|
| 175 |
+
|
| 176 |
interactive_tasks_raw = os.getenv(
|
| 177 |
"INFERENCE_INTERACTIVE_TASKS",
|
| 178 |
"chat,verify_solution,daily_insight",
|
|
|
|
| 184 |
)
|
| 185 |
|
| 186 |
# Default task-to-model routing.
|
| 187 |
+
# Keep all tasks pinned to Qwen3-32B when qwen-only lock is active.
|
| 188 |
self.task_model_map: Dict[str, str] = {
|
| 189 |
+
"chat": "Qwen/Qwen3-32B",
|
| 190 |
+
"verify_solution": "Qwen/Qwen3-32B",
|
| 191 |
+
"lesson_generation": "Qwen/Qwen3-32B",
|
| 192 |
+
"quiz_generation": "Qwen/Qwen3-32B",
|
| 193 |
+
"learning_path": "Qwen/Qwen3-32B",
|
| 194 |
+
"daily_insight": "Qwen/Qwen3-32B",
|
| 195 |
+
"risk_classification": "Qwen/Qwen3-32B",
|
| 196 |
+
"risk_narrative": "Qwen/Qwen3-32B",
|
| 197 |
}
|
| 198 |
+
# Fallback chains (only to other HF-supported models, no featherless-ai)
|
| 199 |
self.task_fallback_model_map: Dict[str, List[str]] = {
|
| 200 |
+
"chat": [
|
| 201 |
+
"meta-llama/Llama-3.1-8B-Instruct",
|
| 202 |
+
"google/gemma-2-2b-it",
|
| 203 |
+
],
|
| 204 |
+
"verify_solution": [
|
| 205 |
+
"meta-llama/Llama-3.1-8B-Instruct",
|
| 206 |
+
"google/gemma-2-2b-it",
|
| 207 |
+
],
|
| 208 |
}
|
| 209 |
+
# Model-to-provider mappings (not needed when using model:provider syntax directly)
|
| 210 |
self.model_provider_map: Dict[str, str] = {}
|
| 211 |
self.task_provider_map: Dict[str, str] = {}
|
| 212 |
if isinstance(config, dict):
|
|
|
|
| 219 |
for task, model in task_models.items()
|
| 220 |
if str(task).strip() and str(model).strip()
|
| 221 |
}
|
| 222 |
+
# Merge config models with defaults (config overrides defaults)
|
| 223 |
self.task_model_map.update(config_task_models)
|
| 224 |
task_fallback_models = routing_cfg.get("task_fallback_model_map", {})
|
| 225 |
if isinstance(task_fallback_models, dict):
|
|
|
|
| 251 |
for task_key in list(self.task_model_map.keys()):
|
| 252 |
self.task_model_map[task_key] = env_model_id
|
| 253 |
LOGGER.info(
|
| 254 |
+
f"🔄 INFERENCE_MODEL_ID env var override applied: {env_model_id}"
|
| 255 |
)
|
| 256 |
LOGGER.info(
|
| 257 |
f" Task model mappings changed from: {original_map}"
|
|
|
|
| 260 |
else:
|
| 261 |
env_override_note = ""
|
| 262 |
|
| 263 |
+
if self.enforce_qwen_only:
|
| 264 |
+
qwen_map_before = dict(self.task_model_map)
|
| 265 |
+
self.default_model = self.qwen_lock_model
|
| 266 |
for task_key in list(self.task_model_map.keys()):
|
| 267 |
+
self.task_model_map[task_key] = self.qwen_lock_model
|
| 268 |
self.fallback_models = []
|
| 269 |
self.task_fallback_model_map = {
|
| 270 |
task_key: [] for task_key in self.task_model_map.keys()
|
| 271 |
}
|
| 272 |
+
self.chat_hard_trigger_enabled = False
|
| 273 |
+
LOGGER.info(f"🔒 INFERENCE_ENFORCE_QWEN_ONLY enabled: locking all inference tasks to {self.qwen_lock_model}")
|
| 274 |
+
LOGGER.info(f" Cleared fallback models and hard-escalation path")
|
| 275 |
+
LOGGER.info(f" Task model mappings forced from: {qwen_map_before}")
|
| 276 |
|
| 277 |
+
# Log configuration loaded for debugging
|
| 278 |
config_status = "from file" if config_path else "hardcoded defaults (no config file found)"
|
| 279 |
effective_chat_model_for_logs = self.chat_model_override or self.task_model_map.get("chat", self.default_model)
|
| 280 |
+
LOGGER.info(f"✅ InferenceClient initialized {config_status}{env_override_note}")
|
| 281 |
LOGGER.info(f" Default model: {self.default_model}")
|
| 282 |
LOGGER.info(f" Chat model: {effective_chat_model_for_logs}")
|
| 283 |
LOGGER.info(f" Chat temp override ({TEMP_CHAT_MODEL_OVERRIDE_ENV}): {self.chat_model_temp_override or 'disabled'}")
|
| 284 |
LOGGER.info(f" Chat strict model lock: {self.chat_strict_model_only}")
|
| 285 |
+
LOGGER.info(f" Global Qwen-only lock: {self.enforce_qwen_only}")
|
| 286 |
LOGGER.info(f" Verify solution model: {self.task_model_map.get('verify_solution', self.default_model)}")
|
| 287 |
LOGGER.info(f" Full task_model_map: {self.task_model_map}")
|
| 288 |
|
|
|
|
| 294 |
"requests_error": 0,
|
| 295 |
"retries_total": 0,
|
| 296 |
"fallback_attempts": 0,
|
|
|
|
|
|
|
| 297 |
"route_counts": {},
|
| 298 |
"task_counts": {},
|
| 299 |
"provider_counts": {},
|
| 300 |
"status_code_counts": {},
|
| 301 |
}
|
| 302 |
|
|
|
|
|
|
|
| 303 |
def _bump_metric(self, key: str, inc: int = 1) -> None:
|
| 304 |
with self._metrics_lock:
|
| 305 |
current = self._metrics.get(key) or 0
|
| 306 |
if not isinstance(current, int):
|
| 307 |
current = 0
|
| 308 |
self._metrics[key] = current + inc
|
|
|
|
| 309 |
|
| 310 |
def _bump_bucket(self, key: str, bucket: str, inc: int = 1) -> None:
|
| 311 |
with self._metrics_lock:
|
|
|
|
| 317 |
if not isinstance(current, int):
|
| 318 |
current = 0
|
| 319 |
mapping[bucket] = current + inc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
|
| 321 |
def _record_attempt(self, *, task_type: str, provider: str, route: str, fallback_depth: int) -> None:
|
| 322 |
self._bump_metric("requests_total", 1)
|
|
|
|
| 328 |
|
| 329 |
def snapshot_metrics(self) -> Dict[str, Any]:
|
| 330 |
with self._metrics_lock:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
snapshot = {
|
| 332 |
"uptime_sec": round(max(0.0, time.time() - self._metrics_started_at), 2),
|
| 333 |
"requests_total": self._metrics.get("requests_total") or 0,
|
|
|
|
| 335 |
"requests_error": self._metrics.get("requests_error") or 0,
|
| 336 |
"retries_total": self._metrics.get("retries_total") or 0,
|
| 337 |
"fallback_attempts": self._metrics.get("fallback_attempts") or 0,
|
|
|
|
|
|
|
|
|
|
| 338 |
"route_counts": dict(self._metrics.get("route_counts") or {}),
|
| 339 |
"task_counts": dict(self._metrics.get("task_counts") or {}),
|
| 340 |
"provider_counts": dict(self._metrics.get("provider_counts") or {}),
|
|
|
|
| 346 |
effective_task = (req.task_type or "default").strip().lower()
|
| 347 |
request_tag = req.request_tag.strip() or f"{effective_task}-{int(time.time() * 1000)}"
|
| 348 |
selected_model, model_selection_source = self._resolve_primary_model(req)
|
| 349 |
+
|
| 350 |
model_chain = self._model_chain_for_task(effective_task, selected_model)
|
| 351 |
last_error: Optional[Exception] = None
|
| 352 |
+
provider_chain = self._provider_chain_for_task(req.task_type)
|
| 353 |
+
|
| 354 |
+
# Normalize model name (remove any provider suffix since we use hf_inference router)
|
| 355 |
+
model_base = selected_model.split(":")[0] if ":" in selected_model else selected_model
|
| 356 |
+
|
| 357 |
+
# Log model selection for debugging - confirm which model will actually be used
|
| 358 |
LOGGER.info(
|
| 359 |
+
f"🎯 request_tag={request_tag} task={effective_task} source={model_selection_source} "
|
| 360 |
+
f"selected_model={model_base} (primary) provider_chain={provider_chain}"
|
| 361 |
)
|
| 362 |
LOGGER.info(f" fallback_chain={model_chain[1:] if len(model_chain) > 1 else 'none'}")
|
| 363 |
|
| 364 |
+
|
| 365 |
for fallback_depth, model_name in enumerate(model_chain):
|
| 366 |
request_for_model = InferenceRequest(
|
| 367 |
messages=req.messages,
|
|
|
|
| 374 |
repetition_penalty=req.repetition_penalty,
|
| 375 |
timeout_sec=req.timeout_sec,
|
| 376 |
)
|
| 377 |
+
|
| 378 |
+
for provider in provider_chain:
|
| 379 |
+
try:
|
| 380 |
+
result = self._generate_with_provider(request_for_model, provider, fallback_depth)
|
| 381 |
+
if fallback_depth > 0:
|
| 382 |
+
LOGGER.info(f"✅ Fallback succeeded at depth={fallback_depth} model={model_name} provider={provider}")
|
| 383 |
+
return result
|
| 384 |
+
except Exception as exc:
|
| 385 |
+
last_error = exc
|
| 386 |
+
fallback_hint = f" (depth {fallback_depth})" if fallback_depth > 0 else ""
|
| 387 |
+
LOGGER.warning(
|
| 388 |
+
f"⚠️ Attempt failed{fallback_hint}: task={request_for_model.task_type} "
|
| 389 |
+
f"provider={provider} model={model_name} error={exc.__class__.__name__}: {str(exc)[:100]}"
|
| 390 |
+
)
|
| 391 |
|
| 392 |
if last_error:
|
| 393 |
raise last_error
|
|
|
|
| 400 |
effective_task = (req.task_type or "default").strip().lower()
|
| 401 |
runtime_chat_override = self._runtime_chat_model_override()
|
| 402 |
|
| 403 |
+
def _base_model(model_name: str) -> str:
|
| 404 |
+
return (model_name or "").split(":", 1)[0].strip()
|
| 405 |
+
|
| 406 |
+
# Check explicit request model first, then chat override env, then task map/default.
|
| 407 |
if effective_task == "chat" and runtime_chat_override:
|
| 408 |
selected_model = runtime_chat_override
|
| 409 |
model_selection_source = "chat_temp_override_env"
|
|
|
|
| 417 |
selected_model = self.task_model_map.get(effective_task, self.default_model)
|
| 418 |
model_selection_source = "task_map"
|
| 419 |
|
| 420 |
+
if self.enforce_qwen_only:
|
| 421 |
+
effective_qwen_lock_model = self.qwen_lock_model
|
| 422 |
if effective_task == "chat":
|
| 423 |
+
effective_qwen_lock_model = runtime_chat_override or self.chat_model_override or self.qwen_lock_model
|
| 424 |
|
| 425 |
+
selected_base = _base_model(selected_model)
|
| 426 |
+
lock_base = _base_model(effective_qwen_lock_model)
|
| 427 |
if selected_base != lock_base:
|
| 428 |
LOGGER.warning(
|
| 429 |
+
f"⚠️ Qwen-only lock replaced requested model {selected_model} with {effective_qwen_lock_model}"
|
| 430 |
)
|
| 431 |
+
selected_model = effective_qwen_lock_model
|
| 432 |
+
model_selection_source = f"{model_selection_source}:qwen_only"
|
| 433 |
|
| 434 |
if effective_task == "chat" and self.chat_strict_model_only:
|
| 435 |
return selected_model, f"{model_selection_source}:chat_strict_model_only"
|
| 436 |
|
| 437 |
+
if effective_task == "chat" and self.chat_hard_trigger_enabled and self.chat_hard_model:
|
| 438 |
+
should_escalate, reason = self._should_escalate_chat_to_hard_model(req.messages)
|
| 439 |
+
if should_escalate and selected_model != self.chat_hard_model:
|
| 440 |
+
return self.chat_hard_model, f"chat_hard_escalation:{reason}"
|
| 441 |
+
|
| 442 |
return selected_model, model_selection_source
|
| 443 |
|
| 444 |
+
def _should_escalate_chat_to_hard_model(self, messages: List[Dict[str, str]]) -> Tuple[bool, str]:
|
| 445 |
+
latest_user = self._latest_user_message(messages)
|
| 446 |
+
if not latest_user:
|
| 447 |
+
return False, "no_user_message"
|
| 448 |
+
|
| 449 |
+
latest_norm = latest_user.lower()
|
| 450 |
+
prompt_chars = len(latest_user)
|
| 451 |
+
history_chars = 0
|
| 452 |
+
for msg in messages:
|
| 453 |
+
content = (msg.get("content") or "") if isinstance(msg, dict) else ""
|
| 454 |
+
history_chars += len(content)
|
| 455 |
+
|
| 456 |
+
keyword_hit = ""
|
| 457 |
+
for kw in self.chat_hard_keywords:
|
| 458 |
+
if kw and kw in latest_norm:
|
| 459 |
+
keyword_hit = kw
|
| 460 |
+
break
|
| 461 |
+
|
| 462 |
+
math_marker_count = len(
|
| 463 |
+
re.findall(
|
| 464 |
+
r"(=|\bintegral\b|\bderivative\b|\bmatrix\b|\blimit\b|\bproof\b|\bderive\b|\bsolve\b)",
|
| 465 |
+
latest_norm,
|
| 466 |
+
)
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
long_prompt = prompt_chars >= self.chat_hard_prompt_chars
|
| 470 |
+
long_history = history_chars >= self.chat_hard_history_chars
|
| 471 |
+
immediate_hard_request = any(
|
| 472 |
+
phrase in latest_norm
|
| 473 |
+
for phrase in (
|
| 474 |
+
"show all steps",
|
| 475 |
+
"step-by-step",
|
| 476 |
+
"step by step",
|
| 477 |
+
"rigorous proof",
|
| 478 |
+
"formal proof",
|
| 479 |
+
)
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
# Escalate immediately for long step-by-step prompts or heavy math density.
|
| 483 |
+
escalate = bool(keyword_hit and immediate_hard_request)
|
| 484 |
+
if not escalate:
|
| 485 |
+
escalate = bool(keyword_hit and (long_prompt or long_history or math_marker_count >= 2))
|
| 486 |
+
if not escalate and long_prompt and math_marker_count >= 2:
|
| 487 |
+
escalate = True
|
| 488 |
+
if not escalate and long_history and math_marker_count >= 2:
|
| 489 |
+
escalate = True
|
| 490 |
+
|
| 491 |
+
if not escalate:
|
| 492 |
+
return False, "normal"
|
| 493 |
+
|
| 494 |
+
reasons: List[str] = []
|
| 495 |
+
if long_prompt:
|
| 496 |
+
reasons.append(f"prompt_chars={prompt_chars}")
|
| 497 |
+
if long_history:
|
| 498 |
+
reasons.append(f"history_chars={history_chars}")
|
| 499 |
+
if keyword_hit:
|
| 500 |
+
reasons.append(f"keyword={keyword_hit}")
|
| 501 |
+
if immediate_hard_request:
|
| 502 |
+
reasons.append("immediate_hard_request")
|
| 503 |
+
if math_marker_count >= 2:
|
| 504 |
+
reasons.append(f"math_markers={math_marker_count}")
|
| 505 |
+
return True, ",".join(reasons) if reasons else "hard_prompt"
|
| 506 |
+
|
| 507 |
def _model_chain_for_task(self, task_type: str, selected_model: str) -> List[str]:
|
| 508 |
normalized = (task_type or "default").strip().lower()
|
| 509 |
runtime_chat_override = self._runtime_chat_model_override() if normalized == "chat" else ""
|
| 510 |
+
chat_qwen_lock_model = runtime_chat_override or (self.chat_model_override if normalized == "chat" else "")
|
| 511 |
|
| 512 |
+
if self.enforce_qwen_only:
|
| 513 |
if normalized == "chat":
|
| 514 |
+
locked_model = (chat_qwen_lock_model or self.qwen_lock_model or "").strip()
|
| 515 |
else:
|
| 516 |
+
locked_model = (self.qwen_lock_model or "").strip()
|
| 517 |
return [locked_model] if locked_model else []
|
| 518 |
|
| 519 |
if normalized == "chat" and self.chat_strict_model_only:
|
| 520 |
+
chat_model = (chat_qwen_lock_model or selected_model or "").strip()
|
| 521 |
return [chat_model] if chat_model else []
|
| 522 |
|
| 523 |
per_task_candidates = self.task_fallback_model_map.get(task_type, [])
|
|
|
|
| 537 |
return deduped[:max_models]
|
| 538 |
return deduped
|
| 539 |
|
| 540 |
+
def _provider_chain_for_task(self, task_type: str) -> List[str]:
|
| 541 |
+
normalized = (task_type or "default").strip().lower()
|
| 542 |
+
forced_provider = self.task_provider_map.get(normalized)
|
| 543 |
+
if forced_provider:
|
| 544 |
+
return [forced_provider]
|
| 545 |
+
|
| 546 |
+
if normalized in self.cpu_only_tasks:
|
| 547 |
+
return [self.cpu_provider]
|
| 548 |
+
|
| 549 |
+
if self.pro_enabled and normalized in self.pro_priority_tasks:
|
| 550 |
+
chain = [self.pro_provider]
|
| 551 |
+
if self.enable_provider_fallback and self.gpu_provider not in chain:
|
| 552 |
+
chain.append(self.gpu_provider)
|
| 553 |
+
if self.enable_provider_fallback and self.provider not in chain:
|
| 554 |
+
chain.append(self.provider)
|
| 555 |
+
return chain
|
| 556 |
+
|
| 557 |
+
if normalized in self.gpu_required_tasks:
|
| 558 |
+
chain = [self.gpu_provider]
|
| 559 |
+
if self.enable_provider_fallback and self.cpu_provider != self.gpu_provider:
|
| 560 |
+
chain.append(self.cpu_provider)
|
| 561 |
+
return chain
|
| 562 |
+
|
| 563 |
+
chain = [self.provider]
|
| 564 |
+
if self.enable_provider_fallback and self.cpu_provider not in chain:
|
| 565 |
+
chain.append(self.cpu_provider)
|
| 566 |
+
return chain
|
| 567 |
+
|
| 568 |
def _retry_profile(self, task_type: str) -> Tuple[int, float]:
|
| 569 |
normalized = (task_type or "default").strip().lower()
|
| 570 |
if normalized in self.interactive_tasks:
|
|
|
|
| 581 |
return self.interactive_timeout_sec
|
| 582 |
return self.background_timeout_sec
|
| 583 |
|
| 584 |
+
def _resolve_route_label(self, provider: str, task_type: str) -> str:
|
| 585 |
+
normalized = (task_type or "default").strip().lower()
|
| 586 |
+
if self.pro_enabled and normalized in self.pro_priority_tasks and provider == self.pro_provider:
|
| 587 |
+
return "pro-priority"
|
| 588 |
+
return "standard"
|
| 589 |
+
|
| 590 |
+
def _generate_with_provider(self, req: InferenceRequest, provider: str, fallback_depth: int) -> str:
|
| 591 |
+
route = self._resolve_route_label(provider, req.task_type)
|
| 592 |
+
if provider == "local_space":
|
| 593 |
+
return self._call_local_space(req, provider=provider, route=route, fallback_depth=fallback_depth)
|
| 594 |
+
|
| 595 |
+
# All models use HF inference router directly (including Qwen/Qwen3-32B)
|
| 596 |
+
return self._call_hf_inference(req, provider=provider, route=route, fallback_depth=fallback_depth)
|
| 597 |
+
|
| 598 |
def _messages_to_prompt(self, messages: List[Dict[str, str]]) -> str:
|
| 599 |
parts: List[str] = []
|
| 600 |
for msg in messages:
|
|
|
|
| 607 |
prefix = "SYSTEM"
|
| 608 |
elif role == "assistant":
|
| 609 |
prefix = "ASSISTANT"
|
| 610 |
+
parts.append(f"{prefix}:\\n{content}")
|
| 611 |
parts.append("ASSISTANT:")
|
| 612 |
+
return "\\n\\n".join(parts)
|
| 613 |
|
| 614 |
def _latest_user_message(self, messages: List[Dict[str, str]]) -> str:
|
| 615 |
for msg in reversed(messages):
|
|
|
|
| 619 |
return content
|
| 620 |
return self._messages_to_prompt(messages)
|
| 621 |
|
| 622 |
+
def _post_with_retry(
|
| 623 |
+
self,
|
| 624 |
+
url: str,
|
| 625 |
+
*,
|
| 626 |
+
headers: Dict[str, str],
|
| 627 |
+
payload: Dict[str, object],
|
| 628 |
+
timeout: int,
|
| 629 |
+
provider: str,
|
| 630 |
+
model: str,
|
| 631 |
+
task_type: str,
|
| 632 |
+
request_tag: str,
|
| 633 |
+
fallback_depth: int,
|
| 634 |
+
route: str,
|
| 635 |
+
) -> Tuple[requests.Response, float, int]:
|
| 636 |
+
self._record_attempt(
|
| 637 |
+
task_type=task_type,
|
| 638 |
+
provider=provider,
|
| 639 |
+
route=route,
|
| 640 |
+
fallback_depth=fallback_depth,
|
| 641 |
)
|
|
|
|
|
|
|
| 642 |
max_retries, backoff_sec = self._retry_profile(task_type)
|
| 643 |
+
attempt = 0
|
| 644 |
|
| 645 |
+
def _retry_sleep(retry_attempt: int) -> None:
|
| 646 |
+
# Small jitter reduces synchronized retry storms during transient provider issues.
|
| 647 |
+
jitter_factor = random.uniform(0.9, 1.2)
|
| 648 |
+
time.sleep(backoff_sec * retry_attempt * jitter_factor)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
|
| 650 |
+
while True:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 651 |
start = time.perf_counter()
|
| 652 |
try:
|
| 653 |
+
resp = requests.post(url, headers=headers, json=payload, timeout=timeout)
|
| 654 |
+
except Exception as exc:
|
| 655 |
latency_ms = (time.perf_counter() - start) * 1000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
log_model_call(
|
| 657 |
LOGGER,
|
| 658 |
+
provider=provider,
|
| 659 |
+
model=model,
|
| 660 |
+
endpoint=url,
|
| 661 |
latency_ms=latency_ms,
|
| 662 |
input_tokens=None,
|
| 663 |
output_tokens=None,
|
| 664 |
+
status="error",
|
| 665 |
+
error_class=exc.__class__.__name__,
|
| 666 |
+
error_message=str(exc),
|
| 667 |
task_type=task_type,
|
| 668 |
+
request_tag=request_tag,
|
| 669 |
retry_attempt=attempt + 1,
|
| 670 |
fallback_depth=fallback_depth,
|
| 671 |
route=route,
|
| 672 |
)
|
| 673 |
+
if attempt >= max_retries - 1:
|
| 674 |
+
self._bump_metric("requests_error", 1)
|
| 675 |
+
raise
|
| 676 |
+
attempt += 1
|
| 677 |
+
self._bump_metric("retries_total", 1)
|
| 678 |
+
_retry_sleep(attempt)
|
| 679 |
+
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 680 |
|
| 681 |
+
latency_ms = (time.perf_counter() - start) * 1000
|
| 682 |
+
if resp.status_code in {408, 429, 500, 502, 503, 504} and attempt < max_retries - 1:
|
|
|
|
| 683 |
log_model_call(
|
| 684 |
LOGGER,
|
| 685 |
+
provider=provider,
|
| 686 |
+
model=model,
|
| 687 |
+
endpoint=url,
|
| 688 |
latency_ms=latency_ms,
|
| 689 |
input_tokens=None,
|
| 690 |
output_tokens=None,
|
| 691 |
status="error",
|
| 692 |
+
error_class="HTTPRetry",
|
| 693 |
+
error_message=f"status={resp.status_code}",
|
| 694 |
task_type=task_type,
|
| 695 |
+
request_tag=request_tag,
|
| 696 |
retry_attempt=attempt + 1,
|
| 697 |
fallback_depth=fallback_depth,
|
| 698 |
route=route,
|
| 699 |
)
|
| 700 |
+
attempt += 1
|
| 701 |
+
self._bump_metric("retries_total", 1)
|
| 702 |
+
_retry_sleep(attempt)
|
| 703 |
+
continue
|
| 704 |
+
return resp, latency_ms, attempt + 1
|
| 705 |
|
| 706 |
+
def _call_hf_inference_direct(self, req: InferenceRequest, *, provider: str, route: str, fallback_depth: int) -> str:
|
| 707 |
+
"""
|
| 708 |
+
Call Qwen models via Featherless AI provider.
|
| 709 |
+
Uses HF InferenceClient with provider="featherless-ai" for direct model access.
|
| 710 |
+
"""
|
| 711 |
+
if not self.hf_token:
|
| 712 |
+
raise RuntimeError("HF_TOKEN is not set")
|
| 713 |
|
|
|
|
| 714 |
target_model = req.model or self.default_model
|
| 715 |
+
target_model_base = target_model.split(":")[0] if ":" in target_model else target_model
|
| 716 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 717 |
timeout = self._timeout_for(req, provider)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 718 |
start = time.perf_counter()
|
| 719 |
+
|
| 720 |
try:
|
| 721 |
+
# Use HF InferenceClient with featherless-ai provider for Qwen models.
|
| 722 |
+
client = HFInferenceClient(
|
| 723 |
+
model=target_model_base,
|
| 724 |
+
token=self.hf_token,
|
| 725 |
+
provider="featherless-ai",
|
| 726 |
+
timeout=timeout
|
| 727 |
+
)
|
| 728 |
+
|
| 729 |
+
response = client.chat_completion(
|
| 730 |
+
messages=req.messages,
|
| 731 |
+
max_tokens=req.max_new_tokens or self.default_max_new_tokens,
|
| 732 |
+
temperature=req.temperature or self.default_temperature,
|
| 733 |
+
top_p=req.top_p or self.default_top_p,
|
| 734 |
+
)
|
| 735 |
+
latency_ms = (time.perf_counter() - start) * 1000
|
| 736 |
+
|
| 737 |
+
# Extract text from response
|
| 738 |
+
if hasattr(response, "choices") and response.choices:
|
| 739 |
+
content = response.choices[0].message.content or ""
|
| 740 |
+
text = content.strip()
|
| 741 |
+
else:
|
| 742 |
+
text = self._extract_text(response)
|
| 743 |
+
|
| 744 |
+
log_model_call(
|
| 745 |
+
LOGGER,
|
| 746 |
+
provider="featherless-ai",
|
| 747 |
+
model=target_model_base,
|
| 748 |
+
endpoint="featherless-ai_inference",
|
| 749 |
+
latency_ms=latency_ms,
|
| 750 |
+
input_tokens=None,
|
| 751 |
+
output_tokens=None,
|
| 752 |
+
status="ok",
|
| 753 |
+
task_type=req.task_type,
|
| 754 |
+
request_tag=req.request_tag,
|
| 755 |
+
retry_attempt=1,
|
| 756 |
+
fallback_depth=fallback_depth,
|
| 757 |
+
route=route,
|
| 758 |
+
)
|
| 759 |
+
self._record_attempt(
|
| 760 |
+
task_type=req.task_type,
|
| 761 |
+
provider="featherless-ai",
|
| 762 |
+
route=route,
|
| 763 |
+
fallback_depth=fallback_depth,
|
| 764 |
+
)
|
| 765 |
+
self._bump_metric("requests_ok", 1)
|
| 766 |
+
return text
|
| 767 |
+
|
| 768 |
except Exception as exc:
|
| 769 |
latency_ms = (time.perf_counter() - start) * 1000
|
| 770 |
+
self._bump_metric("requests_error", 1)
|
| 771 |
log_model_call(
|
| 772 |
LOGGER,
|
| 773 |
+
provider="featherless-ai",
|
| 774 |
+
model=target_model_base,
|
| 775 |
+
endpoint="featherless-ai_inference",
|
| 776 |
latency_ms=latency_ms,
|
| 777 |
input_tokens=None,
|
| 778 |
output_tokens=None,
|
|
|
|
| 785 |
fallback_depth=fallback_depth,
|
| 786 |
route=route,
|
| 787 |
)
|
| 788 |
+
LOGGER.warning(
|
| 789 |
+
"task=%s provider=featherless-ai model=%s fallback_depth=%s failed: %s",
|
| 790 |
+
req.task_type,
|
| 791 |
+
target_model_base,
|
| 792 |
+
fallback_depth,
|
| 793 |
+
exc,
|
| 794 |
+
)
|
| 795 |
raise
|
| 796 |
|
| 797 |
+
def _call_hf_inference(self, req: InferenceRequest, *, provider: str, route: str, fallback_depth: int) -> str:
|
| 798 |
+
if not self.hf_token:
|
| 799 |
+
raise RuntimeError("HF_TOKEN is not set")
|
| 800 |
+
|
| 801 |
+
target_model = req.model or self.default_model
|
| 802 |
+
chat_model = target_model if ":" in target_model else f"{target_model}:fastest"
|
| 803 |
+
url = self.hf_chat_url
|
| 804 |
+
|
| 805 |
+
# Log which model is actually being used
|
| 806 |
+
model_base = target_model.split(":")[0] if ":" in target_model else target_model
|
| 807 |
+
LOGGER.debug(
|
| 808 |
+
f"📌 Calling HF inference: task={req.task_type} model={model_base} "
|
| 809 |
+
f"route={route} depth={fallback_depth}"
|
| 810 |
+
)
|
| 811 |
+
|
| 812 |
+
payload: Dict[str, object] = {
|
| 813 |
+
"model": chat_model,
|
| 814 |
+
"messages": req.messages,
|
| 815 |
+
"stream": False,
|
| 816 |
+
"max_tokens": req.max_new_tokens or self.default_max_new_tokens,
|
| 817 |
+
"temperature": req.temperature,
|
| 818 |
+
"top_p": req.top_p,
|
| 819 |
+
}
|
| 820 |
+
headers = {
|
| 821 |
+
"Authorization": f"Bearer {self.hf_token}",
|
| 822 |
+
"Content-Type": "application/json",
|
| 823 |
+
"X-MathPulse-Task": (req.task_type or "default").strip().lower(),
|
| 824 |
+
}
|
| 825 |
+
if route == "pro-priority" and self.pro_route_header_name.strip():
|
| 826 |
+
headers[self.pro_route_header_name.strip()] = self.pro_route_header_value
|
| 827 |
+
|
| 828 |
+
timeout = self._timeout_for(req, provider)
|
| 829 |
+
|
| 830 |
+
resp, latency_ms, retry_attempt = self._post_with_retry(
|
| 831 |
+
url,
|
| 832 |
+
headers=headers,
|
| 833 |
+
payload=payload,
|
| 834 |
+
timeout=timeout,
|
| 835 |
+
provider=provider,
|
| 836 |
+
model=target_model,
|
| 837 |
+
task_type=req.task_type,
|
| 838 |
+
request_tag=req.request_tag,
|
| 839 |
+
fallback_depth=fallback_depth,
|
| 840 |
+
route=route,
|
| 841 |
+
)
|
| 842 |
+
self._bump_bucket("status_code_counts", str(resp.status_code), 1)
|
| 843 |
+
if resp.status_code != 200:
|
| 844 |
+
self._bump_metric("requests_error", 1)
|
| 845 |
+
raise RuntimeError(f"HF Inference error {resp.status_code}: {resp.text}")
|
| 846 |
+
|
| 847 |
+
data = resp.json()
|
| 848 |
+
text = self._extract_text(data)
|
| 849 |
+
|
| 850 |
+
# Log successful inference with actual model and response time
|
| 851 |
+
LOGGER.info(
|
| 852 |
+
f"✅ HF inference success: task={req.task_type} model={model_base} "
|
| 853 |
+
f"latency={latency_ms:.0f}ms tokens_out={len(text.split())}"
|
| 854 |
+
)
|
| 855 |
+
|
| 856 |
+
log_model_call(
|
| 857 |
+
LOGGER,
|
| 858 |
+
provider=provider,
|
| 859 |
+
model=target_model,
|
| 860 |
+
endpoint=url,
|
| 861 |
+
latency_ms=latency_ms,
|
| 862 |
+
input_tokens=None,
|
| 863 |
+
output_tokens=None,
|
| 864 |
+
status="ok",
|
| 865 |
+
task_type=req.task_type,
|
| 866 |
+
request_tag=req.request_tag,
|
| 867 |
+
retry_attempt=retry_attempt,
|
| 868 |
+
fallback_depth=fallback_depth,
|
| 869 |
+
route=route,
|
| 870 |
+
)
|
| 871 |
+
self._bump_metric("requests_ok", 1)
|
| 872 |
+
return text
|
| 873 |
+
|
| 874 |
+
def _call_featherless(self, req: InferenceRequest, *, provider: str, route: str, fallback_depth: int) -> str:
|
| 875 |
+
if not self.featherless_api_key:
|
| 876 |
+
raise RuntimeError("FEATHERLESS_API_KEY is not set")
|
| 877 |
+
|
| 878 |
+
target_model = req.model or self.default_model
|
| 879 |
+
url = self.featherless_chat_url
|
| 880 |
+
|
| 881 |
+
payload: Dict[str, object] = {
|
| 882 |
+
"model": target_model,
|
| 883 |
+
"messages": req.messages,
|
| 884 |
+
"stream": False,
|
| 885 |
+
"max_tokens": req.max_new_tokens or self.default_max_new_tokens,
|
| 886 |
+
"temperature": req.temperature,
|
| 887 |
+
"top_p": req.top_p,
|
| 888 |
+
}
|
| 889 |
+
headers = {
|
| 890 |
+
"Authorization": f"Bearer {self.featherless_api_key}",
|
| 891 |
+
"Content-Type": "application/json",
|
| 892 |
+
"X-MathPulse-Task": (req.task_type or "default").strip().lower(),
|
| 893 |
+
}
|
| 894 |
+
|
| 895 |
+
timeout = self._timeout_for(req, provider)
|
| 896 |
+
|
| 897 |
+
resp, latency_ms, retry_attempt = self._post_with_retry(
|
| 898 |
+
url,
|
| 899 |
+
headers=headers,
|
| 900 |
+
payload=payload,
|
| 901 |
+
timeout=timeout,
|
| 902 |
+
provider=provider,
|
| 903 |
+
model=target_model,
|
| 904 |
+
task_type=req.task_type,
|
| 905 |
+
request_tag=req.request_tag,
|
| 906 |
+
fallback_depth=fallback_depth,
|
| 907 |
+
route=route,
|
| 908 |
+
)
|
| 909 |
+
self._bump_bucket("status_code_counts", str(resp.status_code), 1)
|
| 910 |
+
if resp.status_code != 200:
|
| 911 |
+
self._bump_metric("requests_error", 1)
|
| 912 |
+
raise RuntimeError(f"Featherless API error {resp.status_code}: {resp.text}")
|
| 913 |
+
|
| 914 |
+
data = resp.json()
|
| 915 |
+
text = self._extract_text(data)
|
| 916 |
+
log_model_call(
|
| 917 |
+
LOGGER,
|
| 918 |
+
provider=provider,
|
| 919 |
+
model=target_model,
|
| 920 |
+
endpoint=url,
|
| 921 |
+
latency_ms=latency_ms,
|
| 922 |
+
input_tokens=None,
|
| 923 |
+
output_tokens=None,
|
| 924 |
+
status="ok",
|
| 925 |
+
task_type=req.task_type,
|
| 926 |
+
request_tag=req.request_tag,
|
| 927 |
+
retry_attempt=retry_attempt,
|
| 928 |
+
fallback_depth=fallback_depth,
|
| 929 |
+
route=route,
|
| 930 |
+
)
|
| 931 |
+
self._bump_metric("requests_ok", 1)
|
| 932 |
+
return text
|
| 933 |
+
|
| 934 |
+
def _call_local_space(self, req: InferenceRequest, *, provider: str, route: str, fallback_depth: int) -> str:
|
| 935 |
+
target_model = req.model or self.default_model
|
| 936 |
+
url = f"{self.local_space_url.rstrip('/')}{self.local_generate_path}"
|
| 937 |
+
|
| 938 |
+
prompt = self._messages_to_prompt(req.messages)
|
| 939 |
+
payload: Dict[str, object] = {
|
| 940 |
+
"data": [
|
| 941 |
+
prompt,
|
| 942 |
+
[],
|
| 943 |
+
req.temperature,
|
| 944 |
+
req.top_p,
|
| 945 |
+
req.max_new_tokens,
|
| 946 |
+
]
|
| 947 |
+
}
|
| 948 |
+
headers = {"Content-Type": "application/json"}
|
| 949 |
+
|
| 950 |
+
timeout = self._timeout_for(req, provider)
|
| 951 |
+
|
| 952 |
+
resp, latency_ms, retry_attempt = self._post_with_retry(
|
| 953 |
+
url,
|
| 954 |
+
headers=headers,
|
| 955 |
+
payload=payload,
|
| 956 |
+
timeout=timeout,
|
| 957 |
+
provider=provider,
|
| 958 |
+
model=target_model,
|
| 959 |
+
task_type=req.task_type,
|
| 960 |
+
request_tag=req.request_tag,
|
| 961 |
+
fallback_depth=fallback_depth,
|
| 962 |
+
route=route,
|
| 963 |
+
)
|
| 964 |
self._bump_bucket("status_code_counts", str(resp.status_code), 1)
|
| 965 |
|
| 966 |
if resp.status_code != 200:
|
|
|
|
| 999 |
status="ok",
|
| 1000 |
task_type=req.task_type,
|
| 1001 |
request_tag=req.request_tag,
|
| 1002 |
+
retry_attempt=retry_attempt,
|
| 1003 |
fallback_depth=fallback_depth,
|
| 1004 |
route=route,
|
| 1005 |
)
|
|
|
|
| 1040 |
|
| 1041 |
def _clean_response_text(self, text: str) -> str:
|
| 1042 |
"""Strip JSON braces, template artifacts, and whitespace from response text."""
|
| 1043 |
+
# Strip leading/trailing whitespace
|
| 1044 |
text = text.strip()
|
| 1045 |
+
|
| 1046 |
+
# Remove wrapping JSON braces or artifact markers
|
| 1047 |
if text.startswith("{") and text.endswith("}"):
|
| 1048 |
try:
|
| 1049 |
+
# Try to parse as JSON - if it fails, return as-is
|
| 1050 |
parsed = json.loads(text)
|
| 1051 |
+
# If it's a dict with a "content" or "text" field, use that
|
| 1052 |
if isinstance(parsed, dict):
|
| 1053 |
if "content" in parsed:
|
| 1054 |
text = str(parsed["content"]).strip()
|
| 1055 |
elif "text" in parsed:
|
| 1056 |
text = str(parsed["text"]).strip()
|
| 1057 |
except json.JSONDecodeError:
|
| 1058 |
+
# Not valid JSON, just clean up braces
|
| 1059 |
text = text.strip("{}")
|
| 1060 |
+
|
| 1061 |
+
# Remove any trailing artifact markers
|
| 1062 |
if text.startswith("```json") or text.startswith("```"):
|
| 1063 |
text = re.sub(r"^```(?:json)?", "", text).strip()
|
| 1064 |
if text.endswith("```"):
|
| 1065 |
text = text[:-3].strip()
|
| 1066 |
+
|
| 1067 |
return text.strip()
|
| 1068 |
|
| 1069 |
|
| 1070 |
+
def create_default_client() -> InferenceClient:
|
| 1071 |
+
return InferenceClient()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
services/question_bank_service.py
DELETED
|
@@ -1,123 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Question Bank Service for Quiz Battle.
|
| 3 |
-
|
| 4 |
-
Handles querying the question bank with random ordering,
|
| 5 |
-
caching session questions, and 24-hour debounce for variance results.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
import os
|
| 9 |
-
import random
|
| 10 |
-
from datetime import datetime, timezone, timedelta
|
| 11 |
-
from typing import List, Dict, Optional
|
| 12 |
-
|
| 13 |
-
from google.cloud import firestore
|
| 14 |
-
|
| 15 |
-
DEFAULT_FIREBASE_PROJECT = os.getenv("FIREBASE_AUTH_PROJECT_ID", "mathpulse-ai-2026")
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def _get_db() -> firestore.Client:
|
| 19 |
-
"""Get Firestore client."""
|
| 20 |
-
return firestore.Client(project=DEFAULT_FIREBASE_PROJECT)
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
async def get_questions_for_battle(
|
| 24 |
-
grade_level: int,
|
| 25 |
-
topic: str,
|
| 26 |
-
count: int = 10,
|
| 27 |
-
) -> List[Dict]:
|
| 28 |
-
"""
|
| 29 |
-
Fetch random questions from the question bank for a battle session.
|
| 30 |
-
|
| 31 |
-
Uses Firestore random_seed field for pseudo-random ordering.
|
| 32 |
-
If fewer than `count` questions exist, returns all available.
|
| 33 |
-
"""
|
| 34 |
-
db = _get_db()
|
| 35 |
-
collection_path = f"question_bank/{grade_level}/{topic}/questions"
|
| 36 |
-
collection_ref = db.collection(collection_path)
|
| 37 |
-
|
| 38 |
-
# Pseudo-random query using random_seed >= random threshold
|
| 39 |
-
threshold = random.random()
|
| 40 |
-
query = (
|
| 41 |
-
collection_ref
|
| 42 |
-
.where("random_seed", ">=", threshold)
|
| 43 |
-
.order_by("random_seed")
|
| 44 |
-
.limit(count)
|
| 45 |
-
)
|
| 46 |
-
docs = list(query.stream())
|
| 47 |
-
|
| 48 |
-
# If we didn't get enough, query from the start to fill shortfall
|
| 49 |
-
if len(docs) < count:
|
| 50 |
-
remaining = count - len(docs)
|
| 51 |
-
fallback_query = (
|
| 52 |
-
collection_ref
|
| 53 |
-
.where("random_seed", "<", threshold)
|
| 54 |
-
.order_by("random_seed")
|
| 55 |
-
.limit(remaining)
|
| 56 |
-
)
|
| 57 |
-
docs.extend(list(fallback_query.stream()))
|
| 58 |
-
|
| 59 |
-
questions = [doc.to_dict() for doc in docs]
|
| 60 |
-
# Ensure all required fields are present
|
| 61 |
-
valid_questions = []
|
| 62 |
-
for q in questions:
|
| 63 |
-
if q and all(k in q for k in ("question", "choices", "correct_answer", "difficulty")):
|
| 64 |
-
valid_questions.append(q)
|
| 65 |
-
|
| 66 |
-
return valid_questions
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
async def cache_session_questions(
|
| 70 |
-
session_id: str,
|
| 71 |
-
questions: List[Dict],
|
| 72 |
-
player_ids: List[str],
|
| 73 |
-
grade_level: int,
|
| 74 |
-
topic: str,
|
| 75 |
-
) -> None:
|
| 76 |
-
"""Cache varied questions for a battle session with 24-hour TTL."""
|
| 77 |
-
db = _get_db()
|
| 78 |
-
session_ref = db.collection("quiz_battle_sessions").document(session_id)
|
| 79 |
-
|
| 80 |
-
session_ref.set({
|
| 81 |
-
"player_ids": player_ids,
|
| 82 |
-
"grade_level": grade_level,
|
| 83 |
-
"topic": topic,
|
| 84 |
-
"created_at": firestore.SERVER_TIMESTAMP,
|
| 85 |
-
"variance_cached_until": datetime.now(timezone.utc) + timedelta(hours=24),
|
| 86 |
-
})
|
| 87 |
-
|
| 88 |
-
# Write questions to subcollection
|
| 89 |
-
batch = db.batch()
|
| 90 |
-
for idx, q in enumerate(questions):
|
| 91 |
-
q_ref = session_ref.collection("questions").document(str(idx))
|
| 92 |
-
batch.set(q_ref, q)
|
| 93 |
-
batch.commit()
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
async def get_cached_session(session_id: str) -> Optional[List[Dict]]:
|
| 97 |
-
"""
|
| 98 |
-
Check if a session has cached varied questions within 24 hours.
|
| 99 |
-
|
| 100 |
-
Returns the cached questions if valid, otherwise None.
|
| 101 |
-
"""
|
| 102 |
-
db = _get_db()
|
| 103 |
-
session_doc = db.collection("quiz_battle_sessions").document(session_id).get()
|
| 104 |
-
if not session_doc.exists:
|
| 105 |
-
return None
|
| 106 |
-
|
| 107 |
-
data = session_doc.to_dict()
|
| 108 |
-
cached_until = data.get("variance_cached_until")
|
| 109 |
-
if cached_until:
|
| 110 |
-
if isinstance(cached_until, datetime):
|
| 111 |
-
if cached_until.tzinfo is None:
|
| 112 |
-
cached_until = cached_until.replace(tzinfo=timezone.utc)
|
| 113 |
-
elif hasattr(cached_until, 'timestamp'):
|
| 114 |
-
# Firestore Timestamp object
|
| 115 |
-
cached_until = datetime.fromtimestamp(cached_until.timestamp(), tz=timezone.utc)
|
| 116 |
-
|
| 117 |
-
if cached_until > datetime.now(timezone.utc):
|
| 118 |
-
# Return cached questions
|
| 119 |
-
q_docs = db.collection("quiz_battle_sessions").document(session_id).collection("questions").stream()
|
| 120 |
-
questions = [doc.to_dict() for doc in q_docs]
|
| 121 |
-
return questions if questions else None
|
| 122 |
-
|
| 123 |
-
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
services/user_provisioning_service.py
CHANGED
|
@@ -185,6 +185,7 @@ class UserProvisioningService:
|
|
| 185 |
"level": 1,
|
| 186 |
"currentXP": 0,
|
| 187 |
"totalXP": 0,
|
|
|
|
| 188 |
"atRiskSubjects": [],
|
| 189 |
"hasTakenDiagnostic": False,
|
| 190 |
}
|
|
|
|
| 185 |
"level": 1,
|
| 186 |
"currentXP": 0,
|
| 187 |
"totalXP": 0,
|
| 188 |
+
"streak": 0,
|
| 189 |
"atRiskSubjects": [],
|
| 190 |
"hasTakenDiagnostic": False,
|
| 191 |
}
|
services/variance_engine.py
DELETED
|
@@ -1,115 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Variance Engine for Quiz Battle Questions.
|
| 3 |
-
|
| 4 |
-
Applies per-session variance techniques via DeepSeek,
|
| 5 |
-
with pure-Python fallback for choice shuffling.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
import json
|
| 9 |
-
import random
|
| 10 |
-
import re
|
| 11 |
-
from typing import List, Dict
|
| 12 |
-
|
| 13 |
-
from services.ai_client import get_deepseek_client, CHAT_MODEL
|
| 14 |
-
from services.question_bank_service import get_cached_session, cache_session_questions
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
def _fallback_shuffle(questions: List[Dict], seed: int) -> List[Dict]:
|
| 18 |
-
"""
|
| 19 |
-
Pure-Python fallback: shuffle choices deterministically.
|
| 20 |
-
"""
|
| 21 |
-
rng = random.Random(seed)
|
| 22 |
-
for q in questions:
|
| 23 |
-
choices = q["choices"].copy()
|
| 24 |
-
correct_letter = q["correct_answer"]
|
| 25 |
-
correct_index = ord(correct_letter) - ord("A")
|
| 26 |
-
correct_text = choices[correct_index]
|
| 27 |
-
rng.shuffle(choices)
|
| 28 |
-
q["choices"] = choices
|
| 29 |
-
q["correct_answer"] = chr(ord("A") + choices.index(correct_text))
|
| 30 |
-
q["variance_applied"] = ["choice_shuffle"]
|
| 31 |
-
return questions
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
async def apply_variance(questions: List[Dict], session_id: str) -> List[Dict]:
|
| 35 |
-
"""
|
| 36 |
-
Apply per-session variance to a list of questions.
|
| 37 |
-
|
| 38 |
-
1. Check 24h Firestore cache first
|
| 39 |
-
2. Call DeepSeek with variance prompt
|
| 40 |
-
3. Parse JSON response
|
| 41 |
-
4. Fall back to pure-Python shuffle if DeepSeek fails
|
| 42 |
-
5. Cache result for 24 hours
|
| 43 |
-
"""
|
| 44 |
-
# 1. Check cache
|
| 45 |
-
cached = await get_cached_session(session_id)
|
| 46 |
-
if cached:
|
| 47 |
-
return cached
|
| 48 |
-
|
| 49 |
-
# 2. Generate deterministic seed from session_id
|
| 50 |
-
seed = hash(session_id) % (2**32)
|
| 51 |
-
|
| 52 |
-
# 3. Call DeepSeek
|
| 53 |
-
client = get_deepseek_client()
|
| 54 |
-
system_prompt = (
|
| 55 |
-
"You are a math quiz variance engine for MathPulse AI, an educational platform for "
|
| 56 |
-
"Filipino high school students following the DepEd K-12 curriculum. "
|
| 57 |
-
"Your job is to make quiz questions feel fresh each session WITHOUT changing the "
|
| 58 |
-
"correct answer or difficulty level."
|
| 59 |
-
)
|
| 60 |
-
|
| 61 |
-
user_prompt = f"""Given these {len(questions)} quiz battle questions as JSON:
|
| 62 |
-
{json.dumps(questions, indent=2)}
|
| 63 |
-
|
| 64 |
-
Apply the following variance techniques. Use session_seed={seed} for deterministic but varied output:
|
| 65 |
-
|
| 66 |
-
PARAPHRASE (30% chance per question): Reword the question stem using different phrasing, synonyms, or sentence structure. Do NOT change the math or the answer.
|
| 67 |
-
|
| 68 |
-
CHOICE SHUFFLE (always): Randomize the order of answer choices A/B/C/D. Update "correct_answer" to reflect the new position.
|
| 69 |
-
|
| 70 |
-
DISTRACTOR REFRESH (20% chance per question): Replace 1-2 wrong choices with new plausible-but-incorrect distractors that represent common student misconceptions for this topic. Keep the correct answer unchanged.
|
| 71 |
-
|
| 72 |
-
CONTEXT SWAP (10% chance per question): Replace real-world context variables (names, objects, currencies) with Filipino-localized equivalents (e.g., "pesos", "jeepney", "barangay") to increase cultural relevance.
|
| 73 |
-
|
| 74 |
-
NUMERIC SCALING (10% chance, only for computation problems): Scale numbers by a small integer factor (2x or 3x) so the method remains the same but the answer changes. Recompute the correct answer and all distractors accordingly.
|
| 75 |
-
|
| 76 |
-
Return the full modified questions array as valid JSON only. Keep all original fields.
|
| 77 |
-
Add a "variance_applied": ["paraphrase", "distractor_refresh", ...] field per question.
|
| 78 |
-
Do NOT change "topic", "difficulty", "grade_level", or "source_chunk_id"."""
|
| 79 |
-
|
| 80 |
-
try:
|
| 81 |
-
response = client.chat.completions.create(
|
| 82 |
-
model=CHAT_MODEL,
|
| 83 |
-
messages=[
|
| 84 |
-
{"role": "system", "content": system_prompt},
|
| 85 |
-
{"role": "user", "content": user_prompt},
|
| 86 |
-
],
|
| 87 |
-
temperature=0.5,
|
| 88 |
-
max_tokens=4000,
|
| 89 |
-
)
|
| 90 |
-
content = response.choices[0].message.content.strip()
|
| 91 |
-
# Strip markdown code fences
|
| 92 |
-
content = re.sub(r"^```json\s*", "", content)
|
| 93 |
-
content = re.sub(r"\s*```$", "", content)
|
| 94 |
-
varied_questions = json.loads(content)
|
| 95 |
-
|
| 96 |
-
if not isinstance(varied_questions, list) or len(varied_questions) != len(questions):
|
| 97 |
-
raise ValueError("Invalid response format from DeepSeek")
|
| 98 |
-
|
| 99 |
-
# Validate required fields
|
| 100 |
-
for q in varied_questions:
|
| 101 |
-
if not all(k in q for k in ("question", "choices", "correct_answer", "variance_applied")):
|
| 102 |
-
raise ValueError("Missing required fields in varied question")
|
| 103 |
-
|
| 104 |
-
except Exception as e:
|
| 105 |
-
print(f"[variance_engine] DeepSeek variance failed, falling back to shuffle: {e}")
|
| 106 |
-
varied_questions = _fallback_shuffle(questions, seed)
|
| 107 |
-
|
| 108 |
-
# 4. Cache for 24 hours
|
| 109 |
-
# Extract player_ids, grade_level, topic from original questions if available
|
| 110 |
-
player_ids = []
|
| 111 |
-
grade_level = questions[0].get("grade_level", 11) if questions else 11
|
| 112 |
-
topic = questions[0].get("topic", "general_mathematics") if questions else "general_mathematics"
|
| 113 |
-
await cache_session_questions(session_id, varied_questions, player_ids, grade_level, topic)
|
| 114 |
-
|
| 115 |
-
return varied_questions
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
services/youtube_service.py
DELETED
|
@@ -1,1017 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Smart YouTube Video Search Service for MathPulse AI.
|
| 3 |
-
Uses YouTube Data API v3 (googleapiclient.discovery) to find relevant
|
| 4 |
-
educational math videos, enriched with RAG curriculum context and DeepSeek
|
| 5 |
-
query generation for contextual fallback when exact matches don't exist.
|
| 6 |
-
Results are cached in Firestore video_cache/{lessonId} with 7-day TTL.
|
| 7 |
-
"""
|
| 8 |
-
|
| 9 |
-
from __future__ import annotations
|
| 10 |
-
|
| 11 |
-
import hashlib
|
| 12 |
-
import json
|
| 13 |
-
import logging
|
| 14 |
-
import os
|
| 15 |
-
import re
|
| 16 |
-
from datetime import datetime, timezone
|
| 17 |
-
from typing import Dict, List, Optional
|
| 18 |
-
|
| 19 |
-
logger = logging.getLogger("mathpulse.youtube")
|
| 20 |
-
|
| 21 |
-
YOUTUBE_API_KEY = os.getenv("YOUTUBE_API_KEY", "").strip()
|
| 22 |
-
|
| 23 |
-
# Known educational channel keywords and exact names for post-filtering
|
| 24 |
-
_EDUCATIONAL_CHANNEL_KEYWORDS = [
|
| 25 |
-
"khan", "math", "academy", "education", "teacher", "professor",
|
| 26 |
-
"tutorial", "lesson", "school", "university", "college", "deped",
|
| 27 |
-
"philippines", "filipino", "pinoy", "stem", "learning", "study",
|
| 28 |
-
"organic chemistry tutor", "patrickjmt", "3blue1brown", "numberphile",
|
| 29 |
-
"math antics", "bright side", "crashcourse", "ted-ed", "ted ed",
|
| 30 |
-
"nancy pi", "professor leonard", "mit", "stanford", "harvard",
|
| 31 |
-
"mashup math", "mathcoach", "mathologer", "stand-up maths",
|
| 32 |
-
"eddie woo", "black pen red pen", "michel van biezen", "brian mclogan",
|
| 33 |
-
"mathbff", "krista king", "mathMeeting", "mathbyfives", "yourteacher",
|
| 34 |
-
"virtual nerd", "study.com", "coursera", "edx", "brilliant",
|
| 35 |
-
"filipino math", "tagalog math", "pinoy teacher", "math philippines",
|
| 36 |
-
"shs math", "senior high school math", "grade 11 math", "grade 12 math",
|
| 37 |
-
"general mathematics", "business math", "statistics", "probability",
|
| 38 |
-
"finite math", "precalculus", "calculus", "algebra", "geometry",
|
| 39 |
-
"trigonometry", "functions", "equations", "problem solving",
|
| 40 |
-
]
|
| 41 |
-
|
| 42 |
-
_EDUCATIONAL_CHANNEL_EXACT = {
|
| 43 |
-
"khan academy", "patrickjmt", "3blue1brown", "numberphile",
|
| 44 |
-
"math antics", "the organic chemistry tutor", "professor leonard",
|
| 45 |
-
"nancy pi", "ted-ed", "crashcourse", "bright side",
|
| 46 |
-
"mit opencourseware", "stanford", "harvard", "mashup math",
|
| 47 |
-
"mathcoach", "mathologer", "stand-up maths", "eddie woo",
|
| 48 |
-
"black pen red pen", "michel van biezen", "brian mclogan",
|
| 49 |
-
"mathbff", "krista king", "mathmeeting", "mathbyfives", "yourteacher",
|
| 50 |
-
"virtual nerd", "study.com", "coursera", "brilliant.org",
|
| 51 |
-
}
|
| 52 |
-
|
| 53 |
-
# Duration filters
|
| 54 |
-
_MIN_DURATION_SECONDS = 120 # 2 minutes (allow shorter tutorials)
|
| 55 |
-
_MAX_DURATION_SECONDS = 3600 # 60 minutes
|
| 56 |
-
_TARGET_MIN_SECONDS = 300 # 5 minutes (ideal)
|
| 57 |
-
_TARGET_MAX_SECONDS = 1200 # 20 minutes (ideal)
|
| 58 |
-
|
| 59 |
-
# Cache TTL in seconds (7 days)
|
| 60 |
-
_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60
|
| 61 |
-
|
| 62 |
-
# Guaranteed fallback videos by subject — these are well-known educational videos
|
| 63 |
-
# that are extremely likely to exist and be relevant. Used as nuclear option
|
| 64 |
-
# when YouTube API returns nothing for all search strategies.
|
| 65 |
-
_GUARANTEED_FALLBACK_VIDEOS = {
|
| 66 |
-
"default": [
|
| 67 |
-
{
|
| 68 |
-
"videoId": "p6j8HhfJ5Mc",
|
| 69 |
-
"title": "The Essence of Calculus",
|
| 70 |
-
"channelTitle": "3Blue1Brown",
|
| 71 |
-
"thumbnailUrl": "https://img.youtube.com/vi/p6j8HhfJ5Mc/hqdefault.jpg",
|
| 72 |
-
"durationSeconds": 1024,
|
| 73 |
-
"description": "A beautiful introduction to calculus concepts.",
|
| 74 |
-
},
|
| 75 |
-
{
|
| 76 |
-
"videoId": "fNk_zzaMoSs",
|
| 77 |
-
"title": "Introduction to Algebra",
|
| 78 |
-
"channelTitle": "Khan Academy",
|
| 79 |
-
"thumbnailUrl": "https://img.youtube.com/vi/fNk_zzaMoSs/hqdefault.jpg",
|
| 80 |
-
"durationSeconds": 720,
|
| 81 |
-
"description": "Fundamentals of algebraic thinking and equations.",
|
| 82 |
-
},
|
| 83 |
-
],
|
| 84 |
-
"general mathematics": [
|
| 85 |
-
{
|
| 86 |
-
"videoId": "fNk_zzaMoSs",
|
| 87 |
-
"title": "Introduction to Algebra",
|
| 88 |
-
"channelTitle": "Khan Academy",
|
| 89 |
-
"thumbnailUrl": "https://img.youtube.com/vi/fNk_zzaMoSs/hqdefault.jpg",
|
| 90 |
-
"durationSeconds": 720,
|
| 91 |
-
"description": "Fundamentals of algebraic thinking and equations.",
|
| 92 |
-
},
|
| 93 |
-
{
|
| 94 |
-
"videoId": "5I_1G5CNA5E",
|
| 95 |
-
"title": "Functions and Their Graphs",
|
| 96 |
-
"channelTitle": "Khan Academy",
|
| 97 |
-
"thumbnailUrl": "https://img.youtube.com/vi/5I_1G5CNA5E/hqdefault.jpg",
|
| 98 |
-
"durationSeconds": 685,
|
| 99 |
-
"description": "Understanding functions, domain, range, and graphing.",
|
| 100 |
-
},
|
| 101 |
-
],
|
| 102 |
-
"business math": [
|
| 103 |
-
{
|
| 104 |
-
"videoId": "Dc2V7_ur_yY",
|
| 105 |
-
"title": "Simple Interest and Compound Interest",
|
| 106 |
-
"channelTitle": "Khan Academy",
|
| 107 |
-
"thumbnailUrl": "https://img.youtube.com/vi/Dc2V7_ur_yY/hqdefault.jpg",
|
| 108 |
-
"durationSeconds": 780,
|
| 109 |
-
"description": "Understanding interest calculations for business applications.",
|
| 110 |
-
},
|
| 111 |
-
{
|
| 112 |
-
"videoId": "BFGj4mkHbHc",
|
| 113 |
-
"title": "Business Mathematics Tutorial",
|
| 114 |
-
"channelTitle": "Math Meeting",
|
| 115 |
-
"thumbnailUrl": "https://img.youtube.com/vi/BFGj4mkHbHc/hqdefault.jpg",
|
| 116 |
-
"durationSeconds": 890,
|
| 117 |
-
"description": "Essential business math concepts and problem solving.",
|
| 118 |
-
},
|
| 119 |
-
],
|
| 120 |
-
"statistics": [
|
| 121 |
-
{
|
| 122 |
-
"videoId": "qBigTkBLU6g",
|
| 123 |
-
"title": "Statistics Intro: Mean, Median, and Mode",
|
| 124 |
-
"channelTitle": "Khan Academy",
|
| 125 |
-
"thumbnailUrl": "https://img.youtube.com/vi/qBigTkBLU6g/hqdefault.jpg",
|
| 126 |
-
"durationSeconds": 512,
|
| 127 |
-
"description": "Introduction to measures of central tendency.",
|
| 128 |
-
},
|
| 129 |
-
{
|
| 130 |
-
"videoId": "oXdM3XVCzIM",
|
| 131 |
-
"title": "Standard Deviation Explained",
|
| 132 |
-
"channelTitle": "Khan Academy",
|
| 133 |
-
"thumbnailUrl": "https://img.youtube.com/vi/oXdM3XVCzIM/hqdefault.jpg",
|
| 134 |
-
"durationSeconds": 635,
|
| 135 |
-
"description": "Understanding variance and standard deviation.",
|
| 136 |
-
},
|
| 137 |
-
],
|
| 138 |
-
"probability": [
|
| 139 |
-
{
|
| 140 |
-
"videoId": "uzkc-qNVoOk",
|
| 141 |
-
"title": "Probability Explained",
|
| 142 |
-
"channelTitle": "Khan Academy",
|
| 143 |
-
"thumbnailUrl": "https://img.youtube.com/vi/uzkc-qNVoOk/hqdefault.jpg",
|
| 144 |
-
"durationSeconds": 480,
|
| 145 |
-
"description": "Introduction to probability concepts and calculations.",
|
| 146 |
-
},
|
| 147 |
-
{
|
| 148 |
-
"videoId": "SkidyvDkNYQ",
|
| 149 |
-
"title": "Probability of Independent Events",
|
| 150 |
-
"channelTitle": "Khan Academy",
|
| 151 |
-
"thumbnailUrl": "https://img.youtube.com/vi/SkidyvDkNYQ/hqdefault.jpg",
|
| 152 |
-
"durationSeconds": 520,
|
| 153 |
-
"description": "Calculating probabilities for independent and dependent events.",
|
| 154 |
-
},
|
| 155 |
-
],
|
| 156 |
-
"finite math": [
|
| 157 |
-
{
|
| 158 |
-
"videoId": "fNk_zzaMoSs",
|
| 159 |
-
"title": "Introduction to Algebra",
|
| 160 |
-
"channelTitle": "Khan Academy",
|
| 161 |
-
"thumbnailUrl": "https://img.youtube.com/vi/fNk_zzaMoSs/hqdefault.jpg",
|
| 162 |
-
"durationSeconds": 720,
|
| 163 |
-
"description": "Fundamentals of algebraic thinking and equations.",
|
| 164 |
-
},
|
| 165 |
-
{
|
| 166 |
-
"videoId": "5I_1G5CNA5E",
|
| 167 |
-
"title": "Functions and Their Graphs",
|
| 168 |
-
"channelTitle": "Khan Academy",
|
| 169 |
-
"thumbnailUrl": "https://img.youtube.com/vi/5I_1G5CNA5E/hqdefault.jpg",
|
| 170 |
-
"durationSeconds": 685,
|
| 171 |
-
"description": "Understanding functions, domain, range, and graphing.",
|
| 172 |
-
},
|
| 173 |
-
],
|
| 174 |
-
"calculus": [
|
| 175 |
-
{
|
| 176 |
-
"videoId": "p6j8HhfJ5Mc",
|
| 177 |
-
"title": "The Essence of Calculus",
|
| 178 |
-
"channelTitle": "3Blue1Brown",
|
| 179 |
-
"thumbnailUrl": "https://img.youtube.com/vi/p6j8HhfJ5Mc/hqdefault.jpg",
|
| 180 |
-
"durationSeconds": 1024,
|
| 181 |
-
"description": "A beautiful introduction to calculus concepts.",
|
| 182 |
-
},
|
| 183 |
-
{
|
| 184 |
-
"videoId": "WUvTyaaNkzM",
|
| 185 |
-
"title": "Limits and Continuity",
|
| 186 |
-
"channelTitle": "Khan Academy",
|
| 187 |
-
"thumbnailUrl": "https://img.youtube.com/vi/WUvTyaaNkzM/hqdefault.jpg",
|
| 188 |
-
"durationSeconds": 780,
|
| 189 |
-
"description": "Understanding limits and continuity in calculus.",
|
| 190 |
-
},
|
| 191 |
-
],
|
| 192 |
-
"algebra": [
|
| 193 |
-
{
|
| 194 |
-
"videoId": "fNk_zzaMoSs",
|
| 195 |
-
"title": "Introduction to Algebra",
|
| 196 |
-
"channelTitle": "Khan Academy",
|
| 197 |
-
"thumbnailUrl": "https://img.youtube.com/vi/fNk_zzaMoSs/hqdefault.jpg",
|
| 198 |
-
"durationSeconds": 720,
|
| 199 |
-
"description": "Fundamentals of algebraic thinking and equations.",
|
| 200 |
-
},
|
| 201 |
-
{
|
| 202 |
-
"videoId": "5I_1G5CNA5E",
|
| 203 |
-
"title": "Functions and Their Graphs",
|
| 204 |
-
"channelTitle": "Khan Academy",
|
| 205 |
-
"thumbnailUrl": "https://img.youtube.com/vi/5I_1G5CNA5E/hqdefault.jpg",
|
| 206 |
-
"durationSeconds": 685,
|
| 207 |
-
"description": "Understanding functions, domain, range, and graphing.",
|
| 208 |
-
},
|
| 209 |
-
],
|
| 210 |
-
"geometry": [
|
| 211 |
-
{
|
| 212 |
-
"videoId": "302eJ3TzJQU",
|
| 213 |
-
"title": "Geometry Introduction",
|
| 214 |
-
"channelTitle": "Khan Academy",
|
| 215 |
-
"thumbnailUrl": "https://img.youtube.com/vi/302eJ3TzJQU/hqdefault.jpg",
|
| 216 |
-
"durationSeconds": 540,
|
| 217 |
-
"description": "Basic geometry concepts and terminology.",
|
| 218 |
-
},
|
| 219 |
-
{
|
| 220 |
-
"videoId": "Jn0YxbqEjHk",
|
| 221 |
-
"title": "Trigonometry Introduction",
|
| 222 |
-
"channelTitle": "Khan Academy",
|
| 223 |
-
"thumbnailUrl": "https://img.youtube.com/vi/Jn0YxbqEjHk/hqdefault.jpg",
|
| 224 |
-
"durationSeconds": 680,
|
| 225 |
-
"description": "Introduction to trigonometric functions and identities.",
|
| 226 |
-
},
|
| 227 |
-
],
|
| 228 |
-
"trigonometry": [
|
| 229 |
-
{
|
| 230 |
-
"videoId": "Jn0YxbqEjHk",
|
| 231 |
-
"title": "Trigonometry Introduction",
|
| 232 |
-
"channelTitle": "Khan Academy",
|
| 233 |
-
"thumbnailUrl": "https://img.youtube.com/vi/Jn0YxbqEjHk/hqdefault.jpg",
|
| 234 |
-
"durationSeconds": 680,
|
| 235 |
-
"description": "Introduction to trigonometric functions and identities.",
|
| 236 |
-
},
|
| 237 |
-
{
|
| 238 |
-
"videoId": "PUB0TaZ7bhA",
|
| 239 |
-
"title": "Unit Circle Definition of Trig Functions",
|
| 240 |
-
"channelTitle": "Khan Academy",
|
| 241 |
-
"thumbnailUrl": "https://img.youtube.com/vi/PUB0TaZ7bhA/hqdefault.jpg",
|
| 242 |
-
"durationSeconds": 590,
|
| 243 |
-
"description": "Understanding sine and cosine on the unit circle.",
|
| 244 |
-
},
|
| 245 |
-
],
|
| 246 |
-
}
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
def _get_guaranteed_fallback_videos(subject: str = "", max_results: int = 3) -> List[Dict]:
|
| 250 |
-
"""Return guaranteed fallback videos when YouTube API returns nothing."""
|
| 251 |
-
subject_lower = subject.lower().strip()
|
| 252 |
-
|
| 253 |
-
# Try exact subject match
|
| 254 |
-
if subject_lower in _GUARANTEED_FALLBACK_VIDEOS:
|
| 255 |
-
videos = _GUARANTEED_FALLBACK_VIDEOS[subject_lower]
|
| 256 |
-
else:
|
| 257 |
-
# Try partial match
|
| 258 |
-
matched = False
|
| 259 |
-
for key, videos_list in _GUARANTEED_FALLBACK_VIDEOS.items():
|
| 260 |
-
if key != "default" and (key in subject_lower or subject_lower in key):
|
| 261 |
-
videos = videos_list
|
| 262 |
-
matched = True
|
| 263 |
-
break
|
| 264 |
-
if not matched:
|
| 265 |
-
videos = _GUARANTEED_FALLBACK_VIDEOS["default"]
|
| 266 |
-
|
| 267 |
-
return videos[:max_results]
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
def _build_youtube_client():
|
| 271 |
-
"""Lazy-init googleapiclient YouTube client. Returns None if no API key."""
|
| 272 |
-
if not YOUTUBE_API_KEY:
|
| 273 |
-
return None
|
| 274 |
-
try:
|
| 275 |
-
from googleapiclient.discovery import build
|
| 276 |
-
return build("youtube", "v3", developerKey=YOUTUBE_API_KEY, cache_discovery=False)
|
| 277 |
-
except Exception as exc:
|
| 278 |
-
logger.warning("Failed to build YouTube client: %s", exc)
|
| 279 |
-
return None
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
def _parse_iso8601_duration(duration: str) -> int:
|
| 283 |
-
"""Parse ISO 8601 duration string like 'PT5M30S' to seconds."""
|
| 284 |
-
if not duration:
|
| 285 |
-
return 0
|
| 286 |
-
hours_match = re.search(r"(\d+)H", duration)
|
| 287 |
-
minutes_match = re.search(r"(\d+)M", duration)
|
| 288 |
-
seconds_match = re.search(r"(\d+)S", duration)
|
| 289 |
-
hours = int(hours_match.group(1)) if hours_match else 0
|
| 290 |
-
minutes = int(minutes_match.group(1)) if minutes_match else 0
|
| 291 |
-
seconds = int(seconds_match.group(1)) if seconds_match else 0
|
| 292 |
-
return hours * 3600 + minutes * 60 + seconds
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
def _is_educational_channel(channel_title: str) -> bool:
|
| 296 |
-
"""Check if a channel appears to be educational."""
|
| 297 |
-
lowered = channel_title.lower().strip()
|
| 298 |
-
if lowered in _EDUCATIONAL_CHANNEL_EXACT:
|
| 299 |
-
return True
|
| 300 |
-
return any(kw in lowered for kw in _EDUCATIONAL_CHANNEL_KEYWORDS)
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
def _score_video_result(item: dict, query: str, topic: str, subject: str) -> float:
|
| 304 |
-
"""Score a video result for relevance. Higher is better."""
|
| 305 |
-
score = 0.0
|
| 306 |
-
title = (item.get("title") or "").lower()
|
| 307 |
-
description = (item.get("description") or "").lower()
|
| 308 |
-
channel = (item.get("channelTitle") or "").lower()
|
| 309 |
-
query_lower = query.lower()
|
| 310 |
-
topic_lower = topic.lower()
|
| 311 |
-
subject_lower = subject.lower() if subject else ""
|
| 312 |
-
|
| 313 |
-
# Topic relevance (highest weight)
|
| 314 |
-
topic_words = [w for w in topic_lower.split() if len(w) > 2]
|
| 315 |
-
for word in topic_words:
|
| 316 |
-
if word in title:
|
| 317 |
-
score += 4.0
|
| 318 |
-
if word in description:
|
| 319 |
-
score += 1.5
|
| 320 |
-
|
| 321 |
-
# Subject relevance
|
| 322 |
-
if subject_lower:
|
| 323 |
-
subject_words = [w for w in subject_lower.split() if len(w) > 2]
|
| 324 |
-
for word in subject_words:
|
| 325 |
-
if word in title:
|
| 326 |
-
score += 2.0
|
| 327 |
-
if word in description:
|
| 328 |
-
score += 0.5
|
| 329 |
-
|
| 330 |
-
# Query terms appear in title
|
| 331 |
-
for word in query_lower.split():
|
| 332 |
-
if len(word) > 2 and word in title:
|
| 333 |
-
score += 1.0
|
| 334 |
-
|
| 335 |
-
# Educational channel bonus
|
| 336 |
-
if _is_educational_channel(channel):
|
| 337 |
-
score += 3.0
|
| 338 |
-
|
| 339 |
-
# Math/education terms in title
|
| 340 |
-
math_terms = ["tutorial", "lesson", "explain", "math", "mathematics",
|
| 341 |
-
"solution", "problem", "example", "learn", "how to",
|
| 342 |
-
"introduction", "basics", "overview", "guide"]
|
| 343 |
-
for term in math_terms:
|
| 344 |
-
if term in title:
|
| 345 |
-
score += 1.5
|
| 346 |
-
|
| 347 |
-
# Duration scoring
|
| 348 |
-
duration = item.get("durationSeconds", 0)
|
| 349 |
-
if _TARGET_MIN_SECONDS <= duration <= _TARGET_MAX_SECONDS:
|
| 350 |
-
score += 2.0
|
| 351 |
-
elif _MIN_DURATION_SECONDS <= duration <= _MAX_DURATION_SECONDS:
|
| 352 |
-
score += 1.0
|
| 353 |
-
elif duration > 0:
|
| 354 |
-
score += 0.3 # Still count very short/long videos, just less
|
| 355 |
-
|
| 356 |
-
return score
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
def _extract_meaningful_keywords(chunks: List[dict]) -> List[str]:
|
| 360 |
-
"""Extract meaningful keywords from curriculum chunks."""
|
| 361 |
-
keywords: List[str] = []
|
| 362 |
-
for chunk in chunks[:3]:
|
| 363 |
-
content = str(chunk.get("content", "")).strip()
|
| 364 |
-
if not content:
|
| 365 |
-
continue
|
| 366 |
-
# Split into sentences and take first few
|
| 367 |
-
sentences = content.split('.')[:2]
|
| 368 |
-
for sentence in sentences:
|
| 369 |
-
# Extract important words (nouns, concepts) - heuristic approach
|
| 370 |
-
words = re.findall(r'\b[A-Za-z][a-z]{3,}\b', sentence)
|
| 371 |
-
# Filter out common stop words
|
| 372 |
-
stop_words = {
|
| 373 |
-
'this', 'that', 'with', 'from', 'they', 'have', 'will',
|
| 374 |
-
'would', 'there', 'their', 'what', 'said', 'each',
|
| 375 |
-
'which', 'about', 'could', 'other', 'after', 'first',
|
| 376 |
-
'these', 'think', 'where', 'being', 'every', 'great',
|
| 377 |
-
'might', 'shall', 'while', 'through', 'during', 'before',
|
| 378 |
-
'between', 'among', 'within', 'without', 'against',
|
| 379 |
-
'students', 'student', 'learning', 'learn', 'understand',
|
| 380 |
-
'objective', 'objectives', 'competency', 'competencies',
|
| 381 |
-
}
|
| 382 |
-
meaningful = [w.lower() for w in words if w.lower() not in stop_words]
|
| 383 |
-
keywords.extend(meaningful[:8])
|
| 384 |
-
|
| 385 |
-
# Deduplicate while preserving order
|
| 386 |
-
seen = set()
|
| 387 |
-
unique = []
|
| 388 |
-
for kw in keywords:
|
| 389 |
-
if kw not in seen and len(kw) > 3:
|
| 390 |
-
seen.add(kw)
|
| 391 |
-
unique.append(kw)
|
| 392 |
-
return unique[:12]
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
def _enrich_query_with_rag(topic: str, subject: str, lesson_context: str = "") -> str:
|
| 396 |
-
"""
|
| 397 |
-
Query the RAG vectorstore to extract curriculum keywords and enrich
|
| 398 |
-
the YouTube search query for higher relevance.
|
| 399 |
-
"""
|
| 400 |
-
enriched = topic
|
| 401 |
-
if subject:
|
| 402 |
-
enriched = f"{enriched} {subject}"
|
| 403 |
-
if lesson_context:
|
| 404 |
-
# Only add lesson context if it's not too similar to topic
|
| 405 |
-
if lesson_context.lower() not in topic.lower():
|
| 406 |
-
enriched = f"{enriched} {lesson_context}"
|
| 407 |
-
|
| 408 |
-
try:
|
| 409 |
-
from rag.curriculum_rag import retrieve_curriculum_context
|
| 410 |
-
chunks = retrieve_curriculum_context(
|
| 411 |
-
query=topic,
|
| 412 |
-
subject=subject if subject else None,
|
| 413 |
-
top_k=5,
|
| 414 |
-
)
|
| 415 |
-
if chunks:
|
| 416 |
-
keywords = _extract_meaningful_keywords(chunks)
|
| 417 |
-
if keywords:
|
| 418 |
-
keyword_str = " ".join(keywords[:10])
|
| 419 |
-
enriched = f"{enriched} {keyword_str}"
|
| 420 |
-
except Exception as exc:
|
| 421 |
-
logger.debug("RAG enrichment skipped: %s", exc)
|
| 422 |
-
|
| 423 |
-
# Append standard DepEd/Philippines math context
|
| 424 |
-
enriched = f"{enriched} DepEd Philippines mathematics tutorial"
|
| 425 |
-
return enriched[:300]
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
def _generate_search_queries_with_ai(
|
| 429 |
-
topic: str,
|
| 430 |
-
subject: str,
|
| 431 |
-
lesson_context: str,
|
| 432 |
-
grade_level: str,
|
| 433 |
-
) -> List[str]:
|
| 434 |
-
"""
|
| 435 |
-
Use DeepSeek to generate multiple targeted YouTube search queries.
|
| 436 |
-
Falls back to heuristic queries if AI is unavailable.
|
| 437 |
-
|
| 438 |
-
Returns a list of queries ordered from most specific to most general.
|
| 439 |
-
"""
|
| 440 |
-
try:
|
| 441 |
-
from services.inference_client import InferenceRequest, create_default_client
|
| 442 |
-
|
| 443 |
-
prompt = (
|
| 444 |
-
f"You are helping find educational YouTube videos for a Filipino senior high school math lesson.\n"
|
| 445 |
-
f"Topic: {topic}\n"
|
| 446 |
-
f"Subject: {subject}\n"
|
| 447 |
-
f"Context: {lesson_context or 'General mathematics lesson'}\n"
|
| 448 |
-
f"Grade: {grade_level or 'Grade 11-12'}\n\n"
|
| 449 |
-
f"Generate exactly 4 YouTube search queries that would find the most relevant educational videos.\n"
|
| 450 |
-
f"Rules:\n"
|
| 451 |
-
f"1. Query 1: Most specific - exact topic with 'tutorial' or 'lesson'\n"
|
| 452 |
-
f"2. Query 2: Slightly broader - related concepts or prerequisite topics\n"
|
| 453 |
-
f"3. Query 3: Even broader - the general subject area with key concepts\n"
|
| 454 |
-
f"4. Query 4: Last resort - basic subject + 'introduction' or 'basics'\n"
|
| 455 |
-
f"5. Each query should be 3-8 words\n"
|
| 456 |
-
f"6. Use terms that real educational channels would use\n"
|
| 457 |
-
f"7. If the exact topic is very specific/niche, include related more common topics\n\n"
|
| 458 |
-
f"Return ONLY a JSON array of 4 strings, nothing else:\n"
|
| 459 |
-
f'["query1", "query2", "query3", "query4"]'
|
| 460 |
-
)
|
| 461 |
-
|
| 462 |
-
client = create_default_client()
|
| 463 |
-
request = InferenceRequest(
|
| 464 |
-
messages=[
|
| 465 |
-
{"role": "system", "content": "You generate YouTube search queries. Return only JSON arrays."},
|
| 466 |
-
{"role": "user", "content": prompt},
|
| 467 |
-
],
|
| 468 |
-
task_type="lesson_generation",
|
| 469 |
-
max_new_tokens=200,
|
| 470 |
-
temperature=0.3,
|
| 471 |
-
top_p=0.9,
|
| 472 |
-
)
|
| 473 |
-
response = client.generate_from_messages(request)
|
| 474 |
-
|
| 475 |
-
# Parse JSON array from response
|
| 476 |
-
text = response.strip()
|
| 477 |
-
# Try to find JSON array
|
| 478 |
-
match = re.search(r'\[.*\]', text, re.DOTALL)
|
| 479 |
-
if match:
|
| 480 |
-
queries = json.loads(match.group())
|
| 481 |
-
if isinstance(queries, list) and len(queries) >= 2:
|
| 482 |
-
# Validate and clean queries
|
| 483 |
-
cleaned = []
|
| 484 |
-
for q in queries:
|
| 485 |
-
if isinstance(q, str) and len(q.strip()) > 3:
|
| 486 |
-
cleaned.append(q.strip()[:200])
|
| 487 |
-
if len(cleaned) >= 2:
|
| 488 |
-
logger.info("AI generated %d search queries", len(cleaned))
|
| 489 |
-
return cleaned
|
| 490 |
-
except Exception as exc:
|
| 491 |
-
logger.debug("AI query generation failed, using fallback: %s", exc)
|
| 492 |
-
|
| 493 |
-
# Fallback heuristic queries
|
| 494 |
-
return _generate_fallback_queries(topic, subject, lesson_context)
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
def _generate_fallback_queries(topic: str, subject: str, lesson_context: str) -> List[str]:
|
| 498 |
-
"""Generate fallback search queries when AI is unavailable."""
|
| 499 |
-
queries = [
|
| 500 |
-
f"{topic} {subject} tutorial lesson",
|
| 501 |
-
f"{topic} mathematics explained",
|
| 502 |
-
f"{subject} {topic} how to",
|
| 503 |
-
]
|
| 504 |
-
|
| 505 |
-
# Add broader queries
|
| 506 |
-
if lesson_context and lesson_context.lower() not in topic.lower():
|
| 507 |
-
queries.insert(1, f"{lesson_context} tutorial")
|
| 508 |
-
|
| 509 |
-
# Extract core concept from topic (e.g., "quadratic equations" -> "quadratic")
|
| 510 |
-
core_words = [w for w in topic.split() if len(w) > 3]
|
| 511 |
-
if core_words:
|
| 512 |
-
core = core_words[0]
|
| 513 |
-
queries.append(f"{core} math lesson introduction")
|
| 514 |
-
|
| 515 |
-
# Add subject-level query as last resort
|
| 516 |
-
queries.append(f"{subject} basics tutorial")
|
| 517 |
-
|
| 518 |
-
# Remove duplicates while preserving order
|
| 519 |
-
seen = set()
|
| 520 |
-
unique = []
|
| 521 |
-
for q in queries:
|
| 522 |
-
if q.lower() not in seen:
|
| 523 |
-
seen.add(q.lower())
|
| 524 |
-
unique.append(q)
|
| 525 |
-
|
| 526 |
-
return unique[:5]
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
def _find_related_topics_with_ai(topic: str, subject: str) -> List[str]:
|
| 530 |
-
"""
|
| 531 |
-
When exact topic has no videos, ask DeepSeek for related/similar topics
|
| 532 |
-
that are more likely to have educational video content.
|
| 533 |
-
"""
|
| 534 |
-
try:
|
| 535 |
-
from services.inference_client import InferenceRequest, create_default_client
|
| 536 |
-
|
| 537 |
-
prompt = (
|
| 538 |
-
f"The topic '{topic}' in {subject} has very few or no YouTube videos.\n"
|
| 539 |
-
f"Suggest 3 related, more commonly taught topics that would have educational videos.\n"
|
| 540 |
-
f"These should cover similar or prerequisite concepts.\n"
|
| 541 |
-
f"Return ONLY a JSON array of 3 short topic phrases (2-4 words each).\n"
|
| 542 |
-
f'["topic1", "topic2", "topic3"]'
|
| 543 |
-
)
|
| 544 |
-
|
| 545 |
-
client = create_default_client()
|
| 546 |
-
request = InferenceRequest(
|
| 547 |
-
messages=[
|
| 548 |
-
{"role": "system", "content": "You suggest related math topics. Return only JSON arrays."},
|
| 549 |
-
{"role": "user", "content": prompt},
|
| 550 |
-
],
|
| 551 |
-
task_type="lesson_generation",
|
| 552 |
-
max_new_tokens=150,
|
| 553 |
-
temperature=0.4,
|
| 554 |
-
top_p=0.9,
|
| 555 |
-
)
|
| 556 |
-
response = client.generate_from_messages(request)
|
| 557 |
-
|
| 558 |
-
text = response.strip()
|
| 559 |
-
match = re.search(r'\[.*\]', text, re.DOTALL)
|
| 560 |
-
if match:
|
| 561 |
-
topics = json.loads(match.group())
|
| 562 |
-
if isinstance(topics, list):
|
| 563 |
-
cleaned = [t.strip()[:100] for t in topics if isinstance(t, str) and len(t.strip()) > 2]
|
| 564 |
-
if cleaned:
|
| 565 |
-
logger.info("AI suggested %d related topics for '%s'", len(cleaned), topic)
|
| 566 |
-
return cleaned
|
| 567 |
-
except Exception as exc:
|
| 568 |
-
logger.debug("AI related topics failed: %s", exc)
|
| 569 |
-
|
| 570 |
-
# Fallback: generate simple related topics
|
| 571 |
-
return _generate_fallback_related_topics(topic, subject)
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
def _generate_fallback_related_topics(topic: str, subject: str) -> List[str]:
|
| 575 |
-
"""Generate simple related topic fallbacks."""
|
| 576 |
-
related = []
|
| 577 |
-
|
| 578 |
-
# Try subject + common subtopics
|
| 579 |
-
if "equation" in topic.lower():
|
| 580 |
-
related.extend([f"{subject} functions", f"{subject} graphing"])
|
| 581 |
-
elif "function" in topic.lower():
|
| 582 |
-
related.extend([f"{subject} equations", f"{subject} domain range"])
|
| 583 |
-
elif "probability" in topic.lower():
|
| 584 |
-
related.extend([f"{subject} statistics", "basic probability concepts"])
|
| 585 |
-
elif "statistics" in topic.lower():
|
| 586 |
-
related.extend([f"{subject} data analysis", "measures of central tendency"])
|
| 587 |
-
elif "geometry" in topic.lower() or "angle" in topic.lower():
|
| 588 |
-
related.extend([f"{subject} trigonometry", "basic geometry concepts"])
|
| 589 |
-
elif "calculus" in topic.lower() or "derivative" in topic.lower():
|
| 590 |
-
related.extend(["limits and continuity", f"{subject} functions"])
|
| 591 |
-
else:
|
| 592 |
-
related.extend([
|
| 593 |
-
f"{subject} fundamentals",
|
| 594 |
-
f"{subject} basic concepts",
|
| 595 |
-
f"{subject} introduction",
|
| 596 |
-
])
|
| 597 |
-
|
| 598 |
-
return related[:3]
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
def _execute_youtube_search(
|
| 602 |
-
client,
|
| 603 |
-
query: str,
|
| 604 |
-
max_results: int = 15,
|
| 605 |
-
video_duration: Optional[str] = "medium",
|
| 606 |
-
video_definition: Optional[str] = "high",
|
| 607 |
-
language: str = "en",
|
| 608 |
-
) -> List[dict]:
|
| 609 |
-
"""Execute a single YouTube search and return raw items with details."""
|
| 610 |
-
try:
|
| 611 |
-
search_params = {
|
| 612 |
-
"part": "snippet",
|
| 613 |
-
"q": query,
|
| 614 |
-
"type": "video",
|
| 615 |
-
"maxResults": max_results,
|
| 616 |
-
"relevanceLanguage": language,
|
| 617 |
-
"order": "relevance",
|
| 618 |
-
}
|
| 619 |
-
|
| 620 |
-
if video_duration:
|
| 621 |
-
search_params["videoDuration"] = video_duration
|
| 622 |
-
if video_definition:
|
| 623 |
-
search_params["videoDefinition"] = video_definition
|
| 624 |
-
|
| 625 |
-
search_response = client.search().list(**search_params).execute()
|
| 626 |
-
items = search_response.get("items", [])
|
| 627 |
-
|
| 628 |
-
if not items:
|
| 629 |
-
return []
|
| 630 |
-
|
| 631 |
-
# Get video details
|
| 632 |
-
video_ids = [item["id"]["videoId"] for item in items if item.get("id", {}).get("videoId")]
|
| 633 |
-
if not video_ids:
|
| 634 |
-
return []
|
| 635 |
-
|
| 636 |
-
details_response = client.videos().list(
|
| 637 |
-
part="contentDetails,statistics,snippet",
|
| 638 |
-
id=",".join(video_ids),
|
| 639 |
-
).execute()
|
| 640 |
-
|
| 641 |
-
details_map = {}
|
| 642 |
-
for detail in details_response.get("items", []):
|
| 643 |
-
vid = detail.get("id")
|
| 644 |
-
if vid:
|
| 645 |
-
details_map[vid] = detail
|
| 646 |
-
|
| 647 |
-
# Build enriched items
|
| 648 |
-
results = []
|
| 649 |
-
for item in items:
|
| 650 |
-
video_id = item.get("id", {}).get("videoId", "")
|
| 651 |
-
if not video_id:
|
| 652 |
-
continue
|
| 653 |
-
|
| 654 |
-
detail = details_map.get(video_id, {})
|
| 655 |
-
snippet = detail.get("snippet", item.get("snippet", {}))
|
| 656 |
-
content_details = detail.get("contentDetails", {})
|
| 657 |
-
|
| 658 |
-
duration = content_details.get("duration", "")
|
| 659 |
-
duration_secs = _parse_iso8601_duration(duration)
|
| 660 |
-
|
| 661 |
-
# Build thumbnail URL
|
| 662 |
-
thumbnail_url = f"https://img.youtube.com/vi/{video_id}/mqdefault.jpg"
|
| 663 |
-
thumbs = snippet.get("thumbnails", {})
|
| 664 |
-
if "high" in thumbs:
|
| 665 |
-
thumbnail_url = thumbs["high"]["url"]
|
| 666 |
-
elif "medium" in thumbs:
|
| 667 |
-
thumbnail_url = thumbs["medium"]["url"]
|
| 668 |
-
|
| 669 |
-
results.append({
|
| 670 |
-
"videoId": video_id,
|
| 671 |
-
"title": snippet.get("title", ""),
|
| 672 |
-
"channelTitle": snippet.get("channelTitle", ""),
|
| 673 |
-
"thumbnailUrl": thumbnail_url,
|
| 674 |
-
"durationSeconds": duration_secs,
|
| 675 |
-
"description": snippet.get("description", "")[:300],
|
| 676 |
-
})
|
| 677 |
-
|
| 678 |
-
return results
|
| 679 |
-
except Exception as exc:
|
| 680 |
-
logger.warning("YouTube search execution failed for query '%s': %s", query, exc)
|
| 681 |
-
return []
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
def _filter_and_score_results(
|
| 685 |
-
items: List[dict],
|
| 686 |
-
query: str,
|
| 687 |
-
topic: str,
|
| 688 |
-
subject: str,
|
| 689 |
-
require_educational: bool = True,
|
| 690 |
-
min_duration: int = 120,
|
| 691 |
-
max_duration: int = 3600,
|
| 692 |
-
) -> List[dict]:
|
| 693 |
-
"""Filter and score video results."""
|
| 694 |
-
results = []
|
| 695 |
-
for item in items:
|
| 696 |
-
duration_secs = item.get("durationSeconds", 0)
|
| 697 |
-
channel_title = item.get("channelTitle", "")
|
| 698 |
-
title = item.get("title", "")
|
| 699 |
-
|
| 700 |
-
# Duration filter
|
| 701 |
-
if duration_secs < min_duration or duration_secs > max_duration:
|
| 702 |
-
continue
|
| 703 |
-
|
| 704 |
-
# Educational channel filter
|
| 705 |
-
is_edu = _is_educational_channel(channel_title)
|
| 706 |
-
if require_educational and not is_edu:
|
| 707 |
-
# Allow if title strongly suggests math tutorial
|
| 708 |
-
lowered_title = title.lower()
|
| 709 |
-
if not any(term in lowered_title for term in [
|
| 710 |
-
"tutorial", "lesson", "math", "explain", "how to",
|
| 711 |
-
"introduction", "basics", "learn", "example", "problem"
|
| 712 |
-
]):
|
| 713 |
-
continue
|
| 714 |
-
|
| 715 |
-
# Score
|
| 716 |
-
score = _score_video_result(item, query, topic, subject)
|
| 717 |
-
item["_score"] = score
|
| 718 |
-
results.append(item)
|
| 719 |
-
|
| 720 |
-
results.sort(key=lambda x: x["_score"], reverse=True)
|
| 721 |
-
for r in results:
|
| 722 |
-
r.pop("_score", None)
|
| 723 |
-
|
| 724 |
-
return results
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
def _get_cache_key(topic: str, subject: str, grade_level: str) -> str:
|
| 728 |
-
"""Generate a deterministic Firestore document ID for caching."""
|
| 729 |
-
raw = f"{subject}|{topic}|{grade_level}"
|
| 730 |
-
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
def get_cached_videos(lesson_id: str) -> Optional[List[Dict]]:
|
| 734 |
-
"""Check Firestore video_cache/{lessonId} for cached results (TTL 7 days)."""
|
| 735 |
-
try:
|
| 736 |
-
import firebase_admin
|
| 737 |
-
from firebase_admin import firestore
|
| 738 |
-
if not firebase_admin._apps:
|
| 739 |
-
return None
|
| 740 |
-
|
| 741 |
-
db = firestore.client()
|
| 742 |
-
doc_ref = db.collection("video_cache").document(lesson_id)
|
| 743 |
-
doc = doc_ref.get()
|
| 744 |
-
if not doc.exists:
|
| 745 |
-
return None
|
| 746 |
-
|
| 747 |
-
data = doc.to_dict()
|
| 748 |
-
if not data:
|
| 749 |
-
return None
|
| 750 |
-
|
| 751 |
-
cached_at = data.get("cachedAt")
|
| 752 |
-
if cached_at:
|
| 753 |
-
if hasattr(cached_at, "timestamp"):
|
| 754 |
-
cached_epoch = cached_at.timestamp()
|
| 755 |
-
elif isinstance(cached_at, datetime):
|
| 756 |
-
cached_epoch = cached_at.timestamp()
|
| 757 |
-
else:
|
| 758 |
-
cached_epoch = float(cached_at)
|
| 759 |
-
now_epoch = datetime.now(timezone.utc).timestamp()
|
| 760 |
-
if (now_epoch - cached_epoch) > _CACHE_TTL_SECONDS:
|
| 761 |
-
logger.info("Video cache expired for lesson %s", lesson_id)
|
| 762 |
-
return None
|
| 763 |
-
|
| 764 |
-
videos = data.get("videos")
|
| 765 |
-
if isinstance(videos, list) and len(videos) > 0:
|
| 766 |
-
logger.info("Video cache hit for lesson %s (%d videos)", lesson_id, len(videos))
|
| 767 |
-
return videos
|
| 768 |
-
except Exception as exc:
|
| 769 |
-
logger.debug("Could not read video cache: %s", exc)
|
| 770 |
-
return None
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
def cache_videos(lesson_id: str, videos: List[Dict], topic: str) -> None:
|
| 774 |
-
"""Store search results in Firestore video_cache/{lessonId}."""
|
| 775 |
-
try:
|
| 776 |
-
import firebase_admin
|
| 777 |
-
from firebase_admin import firestore
|
| 778 |
-
if not firebase_admin._apps:
|
| 779 |
-
return
|
| 780 |
-
|
| 781 |
-
db = firestore.client()
|
| 782 |
-
db.collection("video_cache").document(lesson_id).set({
|
| 783 |
-
"videos": videos,
|
| 784 |
-
"cachedAt": firestore.SERVER_TIMESTAMP,
|
| 785 |
-
"topic": topic,
|
| 786 |
-
})
|
| 787 |
-
logger.info("Cached %d videos for lesson %s", len(videos), lesson_id)
|
| 788 |
-
except Exception as exc:
|
| 789 |
-
logger.warning("Could not cache videos in Firestore: %s", exc)
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
def search_youtube_videos(
|
| 793 |
-
topic: str,
|
| 794 |
-
subject: str = "",
|
| 795 |
-
lesson_context: str = "",
|
| 796 |
-
grade_level: str = "",
|
| 797 |
-
max_results: int = 3,
|
| 798 |
-
language: str = "en",
|
| 799 |
-
) -> List[Dict]:
|
| 800 |
-
"""
|
| 801 |
-
Search YouTube Data API v3 for relevant educational math videos.
|
| 802 |
-
|
| 803 |
-
Uses a multi-strategy approach to guarantee at least 1 result:
|
| 804 |
-
1. AI-generated targeted queries with strict filters
|
| 805 |
-
2. Fallback to heuristic queries with relaxed filters
|
| 806 |
-
3. Broader subject-level searches
|
| 807 |
-
4. Related topics suggested by AI
|
| 808 |
-
5. Emergency unfiltered search as last resort
|
| 809 |
-
|
| 810 |
-
Returns up to `max_results` videos.
|
| 811 |
-
"""
|
| 812 |
-
client = _build_youtube_client()
|
| 813 |
-
if client is None:
|
| 814 |
-
logger.warning("YOUTUBE_API_KEY not set. Video search disabled.")
|
| 815 |
-
return []
|
| 816 |
-
|
| 817 |
-
all_results: List[dict] = []
|
| 818 |
-
seen_video_ids = set()
|
| 819 |
-
|
| 820 |
-
# Generate search queries using AI + fallback
|
| 821 |
-
queries = _generate_search_queries_with_ai(topic, subject, lesson_context, grade_level)
|
| 822 |
-
logger.info("YouTube search queries: %s", queries)
|
| 823 |
-
|
| 824 |
-
# ─── Strategy 1: AI queries with standard filters ───────────────────────
|
| 825 |
-
for query in queries:
|
| 826 |
-
items = _execute_youtube_search(
|
| 827 |
-
client, query,
|
| 828 |
-
max_results=10,
|
| 829 |
-
video_duration="medium",
|
| 830 |
-
video_definition="high",
|
| 831 |
-
language=language,
|
| 832 |
-
)
|
| 833 |
-
filtered = _filter_and_score_results(
|
| 834 |
-
items, query, topic, subject,
|
| 835 |
-
require_educational=True,
|
| 836 |
-
min_duration=_MIN_DURATION_SECONDS,
|
| 837 |
-
max_duration=_MAX_DURATION_SECONDS,
|
| 838 |
-
)
|
| 839 |
-
for item in filtered:
|
| 840 |
-
vid = item["videoId"]
|
| 841 |
-
if vid not in seen_video_ids:
|
| 842 |
-
seen_video_ids.add(vid)
|
| 843 |
-
all_results.append(item)
|
| 844 |
-
|
| 845 |
-
if len(all_results) >= max_results:
|
| 846 |
-
break
|
| 847 |
-
|
| 848 |
-
# ─── Strategy 2: Same queries, relaxed filters ──────────────────────────
|
| 849 |
-
if len(all_results) < max_results:
|
| 850 |
-
for query in queries:
|
| 851 |
-
items = _execute_youtube_search(
|
| 852 |
-
client, query,
|
| 853 |
-
max_results=10,
|
| 854 |
-
video_duration=None, # Any duration
|
| 855 |
-
video_definition=None, # Any quality
|
| 856 |
-
language=language,
|
| 857 |
-
)
|
| 858 |
-
filtered = _filter_and_score_results(
|
| 859 |
-
items, query, topic, subject,
|
| 860 |
-
require_educational=False, # Less strict
|
| 861 |
-
min_duration=60, # Allow shorter
|
| 862 |
-
max_duration=7200, # Allow longer
|
| 863 |
-
)
|
| 864 |
-
for item in filtered:
|
| 865 |
-
vid = item["videoId"]
|
| 866 |
-
if vid not in seen_video_ids:
|
| 867 |
-
seen_video_ids.add(vid)
|
| 868 |
-
all_results.append(item)
|
| 869 |
-
|
| 870 |
-
if len(all_results) >= max_results:
|
| 871 |
-
break
|
| 872 |
-
|
| 873 |
-
# ─── Strategy 3: Broader subject-level searches ─────────────────────────
|
| 874 |
-
if len(all_results) < 1:
|
| 875 |
-
broad_queries = [
|
| 876 |
-
f"{subject} {topic.split()[0] if topic else ''} tutorial",
|
| 877 |
-
f"{subject} mathematics lesson",
|
| 878 |
-
f"{topic} explained simply",
|
| 879 |
-
]
|
| 880 |
-
for query in broad_queries:
|
| 881 |
-
if not query.strip():
|
| 882 |
-
continue
|
| 883 |
-
items = _execute_youtube_search(
|
| 884 |
-
client, query,
|
| 885 |
-
max_results=10,
|
| 886 |
-
video_duration=None,
|
| 887 |
-
video_definition=None,
|
| 888 |
-
language=language,
|
| 889 |
-
)
|
| 890 |
-
filtered = _filter_and_score_results(
|
| 891 |
-
items, query, topic, subject,
|
| 892 |
-
require_educational=False,
|
| 893 |
-
min_duration=60,
|
| 894 |
-
max_duration=7200,
|
| 895 |
-
)
|
| 896 |
-
for item in filtered:
|
| 897 |
-
vid = item["videoId"]
|
| 898 |
-
if vid not in seen_video_ids:
|
| 899 |
-
seen_video_ids.add(vid)
|
| 900 |
-
all_results.append(item)
|
| 901 |
-
|
| 902 |
-
if len(all_results) >= max_results:
|
| 903 |
-
break
|
| 904 |
-
|
| 905 |
-
# ─── Strategy 4: AI-suggested related topics ────────────────────────────
|
| 906 |
-
if len(all_results) < 1:
|
| 907 |
-
related_topics = _find_related_topics_with_ai(topic, subject)
|
| 908 |
-
for related_topic in related_topics:
|
| 909 |
-
query = f"{related_topic} tutorial"
|
| 910 |
-
items = _execute_youtube_search(
|
| 911 |
-
client, query,
|
| 912 |
-
max_results=8,
|
| 913 |
-
video_duration=None,
|
| 914 |
-
video_definition=None,
|
| 915 |
-
language=language,
|
| 916 |
-
)
|
| 917 |
-
filtered = _filter_and_score_results(
|
| 918 |
-
items, query, topic, subject,
|
| 919 |
-
require_educational=False,
|
| 920 |
-
min_duration=60,
|
| 921 |
-
max_duration=7200,
|
| 922 |
-
)
|
| 923 |
-
for item in filtered:
|
| 924 |
-
vid = item["videoId"]
|
| 925 |
-
if vid not in seen_video_ids:
|
| 926 |
-
seen_video_ids.add(vid)
|
| 927 |
-
all_results.append(item)
|
| 928 |
-
|
| 929 |
-
if len(all_results) >= max_results:
|
| 930 |
-
break
|
| 931 |
-
|
| 932 |
-
# ─── Strategy 5: Emergency unfiltered search ────────────────────────────
|
| 933 |
-
if len(all_results) < 1:
|
| 934 |
-
emergency_queries = [
|
| 935 |
-
topic,
|
| 936 |
-
f"{topic} math",
|
| 937 |
-
subject,
|
| 938 |
-
]
|
| 939 |
-
for query in emergency_queries:
|
| 940 |
-
if not query or not query.strip():
|
| 941 |
-
continue
|
| 942 |
-
items = _execute_youtube_search(
|
| 943 |
-
client, query,
|
| 944 |
-
max_results=5,
|
| 945 |
-
video_duration=None,
|
| 946 |
-
video_definition=None,
|
| 947 |
-
language=language,
|
| 948 |
-
)
|
| 949 |
-
# Accept ANY result in emergency mode
|
| 950 |
-
for item in items:
|
| 951 |
-
vid = item["videoId"]
|
| 952 |
-
if vid not in seen_video_ids:
|
| 953 |
-
seen_video_ids.add(vid)
|
| 954 |
-
all_results.append(item)
|
| 955 |
-
|
| 956 |
-
if len(all_results) >= 1:
|
| 957 |
-
break
|
| 958 |
-
|
| 959 |
-
# ─── Final: Return top results or guaranteed fallback ───────────────────
|
| 960 |
-
if not all_results:
|
| 961 |
-
logger.warning(
|
| 962 |
-
"All YouTube search strategies failed for topic: %s. Using guaranteed fallback videos.",
|
| 963 |
-
topic,
|
| 964 |
-
)
|
| 965 |
-
fallback = _get_guaranteed_fallback_videos(subject, max_results)
|
| 966 |
-
if fallback:
|
| 967 |
-
logger.info("Returning %d guaranteed fallback videos for subject: %s", len(fallback), subject)
|
| 968 |
-
return fallback
|
| 969 |
-
return []
|
| 970 |
-
|
| 971 |
-
# Re-score all collected results against the original topic
|
| 972 |
-
for item in all_results:
|
| 973 |
-
item["_score"] = _score_video_result(item, topic, topic, subject)
|
| 974 |
-
|
| 975 |
-
all_results.sort(key=lambda x: x["_score"], reverse=True)
|
| 976 |
-
for item in all_results:
|
| 977 |
-
item.pop("_score", None)
|
| 978 |
-
|
| 979 |
-
top_results = all_results[:max_results]
|
| 980 |
-
logger.info("YouTube search returned %d results (top %d) for topic: %s",
|
| 981 |
-
len(all_results), len(top_results), topic)
|
| 982 |
-
return top_results
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
def get_video_search_results(
|
| 986 |
-
topic: str,
|
| 987 |
-
subject: str = "",
|
| 988 |
-
lesson_context: str = "",
|
| 989 |
-
grade_level: str = "",
|
| 990 |
-
lesson_id: Optional[str] = None,
|
| 991 |
-
max_results: int = 3,
|
| 992 |
-
) -> Dict:
|
| 993 |
-
"""
|
| 994 |
-
High-level wrapper: check cache first, then search YouTube, then cache results.
|
| 995 |
-
|
| 996 |
-
Returns {"videos": [...], "cached": bool}.
|
| 997 |
-
"""
|
| 998 |
-
cache_key = lesson_id or _get_cache_key(topic, subject, grade_level)
|
| 999 |
-
|
| 1000 |
-
# Check cache first
|
| 1001 |
-
cached = get_cached_videos(cache_key)
|
| 1002 |
-
if cached is not None:
|
| 1003 |
-
return {"videos": cached, "cached": True}
|
| 1004 |
-
|
| 1005 |
-
# Search YouTube
|
| 1006 |
-
videos = search_youtube_videos(
|
| 1007 |
-
topic=topic,
|
| 1008 |
-
subject=subject,
|
| 1009 |
-
lesson_context=lesson_context,
|
| 1010 |
-
grade_level=grade_level,
|
| 1011 |
-
max_results=max_results,
|
| 1012 |
-
)
|
| 1013 |
-
|
| 1014 |
-
if videos:
|
| 1015 |
-
cache_videos(cache_key, videos, topic)
|
| 1016 |
-
|
| 1017 |
-
return {"videos": videos, "cached": False}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
startup.sh
CHANGED
|
@@ -11,33 +11,12 @@ fi
|
|
| 11 |
|
| 12 |
export CURRICULUM_DIR
|
| 13 |
export VECTORSTORE_DIR
|
| 14 |
-
export CURRICULUM_VECTORSTORE_DIR="${VECTORSTORE_DIR}"
|
| 15 |
-
|
| 16 |
-
echo "=========================================="
|
| 17 |
-
echo "MathPulse AI Startup"
|
| 18 |
-
echo "=========================================="
|
| 19 |
-
echo "VECTORSTORE_DIR=${VECTORSTORE_DIR}"
|
| 20 |
-
echo "CURRICULUM_VECTORSTORE_DIR=${CURRICULUM_VECTORSTORE_DIR}"
|
| 21 |
-
echo "CURRICULUM_SOURCE_REPO_ID set: $(if [ -n "${CURRICULUM_SOURCE_REPO_ID:-}" ]; then echo YES; else echo NO; fi)"
|
| 22 |
-
echo "FIREBASE_SERVICE_ACCOUNT_JSON set: $(if [ -n "${FIREBASE_SERVICE_ACCOUNT_JSON:-}" ]; then echo YES; else echo NO; fi)"
|
| 23 |
-
echo "FIREBASE_STORAGE_BUCKET=${FIREBASE_STORAGE_BUCKET:-not set}"
|
| 24 |
-
echo "=========================================="
|
| 25 |
|
| 26 |
mkdir -p "${CURRICULUM_DIR}" "${VECTORSTORE_DIR}"
|
| 27 |
|
| 28 |
-
_vectorstore_cache_dir="${VECTORSTORE_DIR}/.chroma"
|
| 29 |
-
if [ ! -d "${_vectorstore_cache_dir}" ]; then
|
| 30 |
-
mkdir -p "${_vectorstore_cache_dir}"
|
| 31 |
-
echo "INFO: Initialized ChromaDB cache dir at ${_vectorstore_cache_dir}"
|
| 32 |
-
fi
|
| 33 |
-
|
| 34 |
_ingest_script="/app/scripts/ingest_curriculum.py"
|
| 35 |
if [ -f "${_ingest_script}" ]; then
|
| 36 |
-
|
| 37 |
-
if [ -d "${CURRICULUM_DIR}" ] && find "${CURRICULUM_DIR}" -type f -name '*.pdf' -print -quit >/dev/null 2>&1; then
|
| 38 |
-
_has_pdfs=true
|
| 39 |
-
fi
|
| 40 |
-
if [ "${_has_pdfs}" = true ] || [ -n "${CURRICULUM_SOURCE_REPO_ID:-}" ]; then
|
| 41 |
echo "INFO: Running curriculum ingestion (optional)..."
|
| 42 |
python "${_ingest_script}" && echo "INFO: Curriculum ingestion completed" || echo "WARNING: Curriculum ingestion failed, continuing anyway"
|
| 43 |
else
|
|
@@ -47,27 +26,12 @@ else
|
|
| 47 |
echo "INFO: Curriculum ingestion script not found at ${_ingest_script}; skipping (curriculum is optional)"
|
| 48 |
fi
|
| 49 |
|
| 50 |
-
|
| 51 |
-
if [ -f "${
|
| 52 |
-
echo "INFO: Vectorstore files present before download:"
|
| 53 |
-
ls -la "${VECTORSTORE_DIR}/"
|
| 54 |
echo "INFO: Downloading vectorstore from Firebase Storage..."
|
| 55 |
-
python "${
|
| 56 |
-
if [ $_result -eq 0 ]; then
|
| 57 |
-
echo "INFO: Vectorstore download succeeded"
|
| 58 |
-
else
|
| 59 |
-
echo "WARNING: Vectorstore download failed, continuing anyway"
|
| 60 |
-
fi
|
| 61 |
-
echo "INFO: Vectorstore files present after download:"
|
| 62 |
-
ls -la "${VECTORSTORE_DIR}/"
|
| 63 |
-
_vectorstore_summary_file="${VECTORSTORE_DIR}/ingest_summary.json"
|
| 64 |
-
if [ -f "${_vectorstore_summary_file}" ]; then
|
| 65 |
-
echo "INFO: Vectorstore summary found at ${_vectorstore_summary_file}"
|
| 66 |
-
else
|
| 67 |
-
echo "WARNING: Vectorstore summary not found at ${_vectorstore_summary_file}"
|
| 68 |
-
fi
|
| 69 |
else
|
| 70 |
-
echo "INFO: Vectorstore download script not found at ${
|
| 71 |
fi
|
| 72 |
|
| 73 |
exec uvicorn main:app --host 0.0.0.0 --port 7860 --workers 1
|
|
|
|
| 11 |
|
| 12 |
export CURRICULUM_DIR
|
| 13 |
export VECTORSTORE_DIR
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
mkdir -p "${CURRICULUM_DIR}" "${VECTORSTORE_DIR}"
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
_ingest_script="/app/scripts/ingest_curriculum.py"
|
| 18 |
if [ -f "${_ingest_script}" ]; then
|
| 19 |
+
if [ -n "${CURRICULUM_SOURCE_REPO_ID:-}" ] || find "${CURRICULUM_DIR}" -type f -name '*.pdf' -print -quit >/dev/null 2>&1; then
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
echo "INFO: Running curriculum ingestion (optional)..."
|
| 21 |
python "${_ingest_script}" && echo "INFO: Curriculum ingestion completed" || echo "WARNING: Curriculum ingestion failed, continuing anyway"
|
| 22 |
else
|
|
|
|
| 26 |
echo "INFO: Curriculum ingestion script not found at ${_ingest_script}; skipping (curriculum is optional)"
|
| 27 |
fi
|
| 28 |
|
| 29 |
+
_download_script="/app/scripts/download_vectorstore_from_firebase.py"
|
| 30 |
+
if [ -f "${_download_script}" ]; then
|
|
|
|
|
|
|
| 31 |
echo "INFO: Downloading vectorstore from Firebase Storage..."
|
| 32 |
+
python "${_download_script}" && echo "INFO: Vectorstore download completed" || echo "WARNING: Vectorstore download failed, continuing anyway"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
else
|
| 34 |
+
echo "INFO: Vectorstore download script not found at ${_download_script}; skipping (vectorstore is optional)"
|
| 35 |
fi
|
| 36 |
|
| 37 |
exec uvicorn main:app --host 0.0.0.0 --port 7860 --workers 1
|
startup_validation.py
CHANGED
|
@@ -32,12 +32,7 @@ def validate_imports() -> None:
|
|
| 32 |
logger.info(" ✓ FastAPI, Uvicorn, Pydantic OK")
|
| 33 |
|
| 34 |
# Backend services (use ABSOLUTE imports like deployed code)
|
| 35 |
-
from services.inference_client import
|
| 36 |
-
InferenceClient, create_default_client, is_sequential_model,
|
| 37 |
-
get_current_runtime_config, get_model_for_task, model_supports_thinking,
|
| 38 |
-
set_runtime_model_profile, set_runtime_model_override, reset_runtime_overrides,
|
| 39 |
-
_MODEL_PROFILES,
|
| 40 |
-
) # noqa
|
| 41 |
logger.info(" ✓ InferenceClient imports OK")
|
| 42 |
|
| 43 |
from automation_engine import automation_engine # noqa
|
|
@@ -54,8 +49,8 @@ def validate_imports() -> None:
|
|
| 54 |
logger.warning(" ⚠ firebase_admin not available (OK if Firebase not needed)")
|
| 55 |
|
| 56 |
# ML & inference
|
| 57 |
-
from
|
| 58 |
-
logger.info(" ✓
|
| 59 |
|
| 60 |
logger.info("✅ All critical imports validated")
|
| 61 |
except ImportError as e:
|
|
@@ -78,37 +73,36 @@ def validate_environment() -> None:
|
|
| 78 |
"""Verify required environment variables are set."""
|
| 79 |
logger.info("🔍 Validating environment variables...")
|
| 80 |
|
| 81 |
-
# CRITICAL:
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
| 84 |
logger.warning(
|
| 85 |
-
"⚠ WARNING:
|
|
|
|
| 86 |
" AI inference will fail without this token.\n"
|
| 87 |
-
" Use:
|
| 88 |
)
|
| 89 |
else:
|
| 90 |
-
logger.info(" ✓
|
| 91 |
|
| 92 |
# Check inference provider config
|
| 93 |
-
inference_provider = os.getenv("INFERENCE_PROVIDER", "
|
| 94 |
logger.info(f" ✓ INFERENCE_PROVIDER: {inference_provider}")
|
| 95 |
|
| 96 |
# Check model IDs
|
| 97 |
-
chat_model = os.getenv("INFERENCE_CHAT_MODEL_ID") or os.getenv("INFERENCE_MODEL_ID") or "
|
| 98 |
logger.info(f" ✓ Chat model configured: {chat_model}")
|
| 99 |
|
| 100 |
chat_strict = os.getenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true").strip().lower() in {"1", "true", "yes", "on"}
|
| 101 |
chat_hard_trigger = os.getenv("INFERENCE_CHAT_HARD_TRIGGER_ENABLED", "false").strip().lower() in {"1", "true", "yes", "on"}
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
logger.info(f" ✓
|
| 105 |
-
logger.info(f" ✓
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
rag_model = os.getenv("HF_RAG_MODEL_ID", "").strip()
|
| 109 |
-
logger.info(f" ✓ MODEL_PROFILE: {model_profile or 'not set (using individual env vars)'}")
|
| 110 |
-
logger.info(f" ✓ HF_QUIZ_MODEL_ID: {quiz_model or 'not set (using defaults)'}")
|
| 111 |
-
logger.info(f" ✓ HF_RAG_MODEL_ID: {rag_model or 'not set (using defaults)'}")
|
| 112 |
if not chat_strict:
|
| 113 |
logger.warning(" ⚠ Chat strict model lock is disabled; chat may fallback to alternate models")
|
| 114 |
if chat_strict and chat_hard_trigger:
|
|
@@ -116,40 +110,9 @@ def validate_environment() -> None:
|
|
| 116 |
" ⚠ Chat hard trigger is enabled while strict chat lock is on; hard escalation will be bypassed"
|
| 117 |
)
|
| 118 |
|
| 119 |
-
_validate_embedding_model()
|
| 120 |
-
|
| 121 |
logger.info("✅ Environment variables OK")
|
| 122 |
|
| 123 |
|
| 124 |
-
EXPECTED_EMBEDDING_MODEL = "BAAI/bge-small-en-v1.5"
|
| 125 |
-
|
| 126 |
-
def _validate_embedding_model() -> None:
|
| 127 |
-
embedding_model = os.getenv("EMBEDDING_MODEL", "").strip()
|
| 128 |
-
if not embedding_model:
|
| 129 |
-
logger.warning(
|
| 130 |
-
"WARNING: EMBEDDING_MODEL env var is not set. "
|
| 131 |
-
f"Expected: {EXPECTED_EMBEDDING_MODEL}. "
|
| 132 |
-
"RAG retrieval will fail without an embedding model."
|
| 133 |
-
)
|
| 134 |
-
elif embedding_model != EXPECTED_EMBEDDING_MODEL:
|
| 135 |
-
logger.warning(
|
| 136 |
-
f"WARNING: EMBEDDING_MODEL is set to '{embedding_model}' — "
|
| 137 |
-
f"expected '{EXPECTED_EMBEDDING_MODEL}'. "
|
| 138 |
-
"Confirm this is intentional before deploying."
|
| 139 |
-
)
|
| 140 |
-
from services.ai_client import CHAT_MODEL, REASONER_MODEL # noqa
|
| 141 |
-
generation_model_ids = [
|
| 142 |
-
CHAT_MODEL, REASONER_MODEL,
|
| 143 |
-
]
|
| 144 |
-
if embedding_model in generation_model_ids:
|
| 145 |
-
logger.warning(
|
| 146 |
-
f"CRITICAL: EMBEDDING_MODEL is set to a generation model ('{embedding_model}'). "
|
| 147 |
-
"This will break RAG retrieval. Set it to 'BAAI/bge-small-en-v1.5'."
|
| 148 |
-
)
|
| 149 |
-
else:
|
| 150 |
-
logger.info(f" EMBEDDING_MODEL: {embedding_model or 'not set'}")
|
| 151 |
-
|
| 152 |
-
|
| 153 |
def validate_config_files() -> None:
|
| 154 |
"""Verify config files exist and are readable."""
|
| 155 |
logger.info("🔍 Validating configuration files...")
|
|
@@ -191,9 +154,7 @@ def validate_config_files() -> None:
|
|
| 191 |
)
|
| 192 |
|
| 193 |
logger.info(f" ✓ Using model config: {readable_model_config}")
|
| 194 |
-
|
| 195 |
-
_validate_model_config_fields(readable_model_config)
|
| 196 |
-
|
| 197 |
logger.info("✅ Configuration files OK")
|
| 198 |
|
| 199 |
|
|
@@ -297,40 +258,6 @@ def validate_inference_client_config() -> None:
|
|
| 297 |
) from e
|
| 298 |
|
| 299 |
|
| 300 |
-
def _validate_model_config_fields(config_path: str) -> None:
|
| 301 |
-
try:
|
| 302 |
-
import yaml
|
| 303 |
-
with open(config_path, "r", encoding="utf-8") as f:
|
| 304 |
-
config = yaml.safe_load(f) or {}
|
| 305 |
-
except Exception as e:
|
| 306 |
-
raise StartupError(f"❌ Cannot parse {config_path} as YAML: {e}") from e
|
| 307 |
-
|
| 308 |
-
models = config.get("models", {})
|
| 309 |
-
if not isinstance(models, dict):
|
| 310 |
-
raise StartupError(f"❌ {config_path}: 'models' section missing or invalid")
|
| 311 |
-
|
| 312 |
-
if "rag_primary" not in models:
|
| 313 |
-
raise StartupError(f"❌ {config_path}: missing 'models.rag_primary' field")
|
| 314 |
-
rag_primary = models["rag_primary"]
|
| 315 |
-
if isinstance(rag_primary, dict):
|
| 316 |
-
logger.info(f" ✓ rag_primary model: {rag_primary.get('id', 'UNSET')}")
|
| 317 |
-
else:
|
| 318 |
-
logger.warning(f" ⚠ rag_primary is not a dict, may cause issues")
|
| 319 |
-
|
| 320 |
-
capabilities = models.get("model_capabilities")
|
| 321 |
-
if not isinstance(capabilities, dict):
|
| 322 |
-
raise StartupError(f"❌ {config_path}: missing 'models.model_capabilities' section")
|
| 323 |
-
logger.info(f" ✓ model_capabilities: sequential_only={capabilities.get('sequential_only')}, supports_thinking={capabilities.get('supports_thinking')}")
|
| 324 |
-
|
| 325 |
-
tasks = config.get("routing", {}).get("task_model_map", {})
|
| 326 |
-
rag_tasks = {"rag_lesson", "rag_problem", "rag_analysis_context"}
|
| 327 |
-
missing_rag = rag_tasks - set(str(t).strip().lower() for t in tasks.keys())
|
| 328 |
-
if missing_rag:
|
| 329 |
-
raise StartupError(f"❌ {config_path}: missing RAG task mappings: {missing_rag}")
|
| 330 |
-
|
| 331 |
-
logger.info(f" ✓ All RAG task mappings present")
|
| 332 |
-
|
| 333 |
-
|
| 334 |
def run_all_validations() -> None:
|
| 335 |
"""Run comprehensive startup validation.
|
| 336 |
|
|
|
|
| 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
|
|
|
|
| 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:
|
|
|
|
| 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/Qwen3-32B"
|
| 96 |
logger.info(f" ✓ Chat model configured: {chat_model}")
|
| 97 |
|
| 98 |
chat_strict = os.getenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true").strip().lower() in {"1", "true", "yes", "on"}
|
| 99 |
chat_hard_trigger = os.getenv("INFERENCE_CHAT_HARD_TRIGGER_ENABLED", "false").strip().lower() in {"1", "true", "yes", "on"}
|
| 100 |
+
enforce_qwen_only = os.getenv("INFERENCE_ENFORCE_QWEN_ONLY", "true").strip().lower() in {"1", "true", "yes", "on"}
|
| 101 |
+
qwen_lock_model = os.getenv("INFERENCE_QWEN_LOCK_MODEL", "Qwen/Qwen3-32B").strip() or "Qwen/Qwen3-32B"
|
| 102 |
+
logger.info(f" ✓ INFERENCE_CHAT_STRICT_MODEL_ONLY: {chat_strict}")
|
| 103 |
+
logger.info(f" ✓ INFERENCE_CHAT_HARD_TRIGGER_ENABLED: {chat_hard_trigger}")
|
| 104 |
+
logger.info(f" ✓ INFERENCE_ENFORCE_QWEN_ONLY: {enforce_qwen_only}")
|
| 105 |
+
logger.info(f" ✓ INFERENCE_QWEN_LOCK_MODEL: {qwen_lock_model}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
if not chat_strict:
|
| 107 |
logger.warning(" ⚠ Chat strict model lock is disabled; chat may fallback to alternate models")
|
| 108 |
if chat_strict and chat_hard_trigger:
|
|
|
|
| 110 |
" ⚠ Chat hard trigger is enabled while strict chat lock is on; hard escalation will be bypassed"
|
| 111 |
)
|
| 112 |
|
|
|
|
|
|
|
| 113 |
logger.info("✅ Environment variables OK")
|
| 114 |
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
def validate_config_files() -> None:
|
| 117 |
"""Verify config files exist and are readable."""
|
| 118 |
logger.info("🔍 Validating configuration files...")
|
|
|
|
| 154 |
)
|
| 155 |
|
| 156 |
logger.info(f" ✓ Using model config: {readable_model_config}")
|
| 157 |
+
|
|
|
|
|
|
|
| 158 |
logger.info("✅ Configuration files OK")
|
| 159 |
|
| 160 |
|
|
|
|
| 258 |
) from e
|
| 259 |
|
| 260 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
def run_all_validations() -> None:
|
| 262 |
"""Run comprehensive startup validation.
|
| 263 |
|
test_full_rag.py
DELETED
|
@@ -1,75 +0,0 @@
|
|
| 1 |
-
import sys
|
| 2 |
-
import os
|
| 3 |
-
sys.path.insert(0, 'backend')
|
| 4 |
-
|
| 5 |
-
# Set required env vars
|
| 6 |
-
os.environ['DEEPSEEK_API_KEY'] = os.getenv('DEEPSEEK_API_KEY', '')
|
| 7 |
-
os.environ['DEEPSEEK_BASE_URL'] = os.getenv('DEEPSEEK_BASE_URL', 'https://api.deepseek.com')
|
| 8 |
-
|
| 9 |
-
from rag.curriculum_rag import retrieve_lesson_pdf_context, build_lesson_prompt
|
| 10 |
-
from services.inference_client import InferenceClient, InferenceRequest
|
| 11 |
-
|
| 12 |
-
# Test retrieval
|
| 13 |
-
print("Testing retrieval...")
|
| 14 |
-
try:
|
| 15 |
-
chunks, mode = retrieve_lesson_pdf_context(
|
| 16 |
-
topic="Represent real-life relationships as functions and interpret domain/range.",
|
| 17 |
-
subject="General Mathematics",
|
| 18 |
-
quarter=2,
|
| 19 |
-
lesson_title="Represent real-life relationships as functions and interpret domain/range.",
|
| 20 |
-
module_id="gen-math",
|
| 21 |
-
lesson_id="gm-q2-functions-graphs-l1",
|
| 22 |
-
competency_code="GM11-FG-1",
|
| 23 |
-
top_k=8,
|
| 24 |
-
)
|
| 25 |
-
print(f"Retrieved {len(chunks)} chunks, mode={mode}")
|
| 26 |
-
except Exception as e:
|
| 27 |
-
print(f"Retrieval ERROR: {type(e).__name__}: {e}")
|
| 28 |
-
import traceback
|
| 29 |
-
traceback.print_exc()
|
| 30 |
-
sys.exit(1)
|
| 31 |
-
|
| 32 |
-
# Test prompt building
|
| 33 |
-
print("\nTesting prompt building...")
|
| 34 |
-
try:
|
| 35 |
-
prompt = build_lesson_prompt(
|
| 36 |
-
lesson_title="Represent real-life relationships as functions and interpret domain/range.",
|
| 37 |
-
competency="Represent real-life relationships as functions and interpret domain/range.",
|
| 38 |
-
grade_level="Grade 11-12",
|
| 39 |
-
subject="General Mathematics",
|
| 40 |
-
quarter=2,
|
| 41 |
-
learner_level="Grade 11-12",
|
| 42 |
-
module_unit="n/a",
|
| 43 |
-
curriculum_chunks=chunks,
|
| 44 |
-
competency_code="GM11-FG-1",
|
| 45 |
-
)
|
| 46 |
-
print(f"Prompt length: {len(prompt)} chars")
|
| 47 |
-
print(f"Prompt preview: {prompt[:200]}...")
|
| 48 |
-
except Exception as e:
|
| 49 |
-
print(f"Prompt building ERROR: {type(e).__name__}: {e}")
|
| 50 |
-
import traceback
|
| 51 |
-
traceback.print_exc()
|
| 52 |
-
sys.exit(1)
|
| 53 |
-
|
| 54 |
-
# Test inference (optional - might cost money)
|
| 55 |
-
print("\nTesting inference...")
|
| 56 |
-
try:
|
| 57 |
-
client = InferenceClient()
|
| 58 |
-
req = InferenceRequest(
|
| 59 |
-
messages=[
|
| 60 |
-
{"role": "system", "content": "You are a precise DepEd-aligned curriculum assistant."},
|
| 61 |
-
{"role": "user", "content": prompt},
|
| 62 |
-
],
|
| 63 |
-
task_type="lesson_generation",
|
| 64 |
-
max_new_tokens=100, # Small for testing
|
| 65 |
-
temperature=0.2,
|
| 66 |
-
top_p=0.9,
|
| 67 |
-
enable_thinking=True,
|
| 68 |
-
)
|
| 69 |
-
result = client.generate_from_messages(req)
|
| 70 |
-
print(f"Inference result: {result[:200]}...")
|
| 71 |
-
print("SUCCESS!")
|
| 72 |
-
except Exception as e:
|
| 73 |
-
print(f"Inference ERROR: {type(e).__name__}: {e}")
|
| 74 |
-
import traceback
|
| 75 |
-
traceback.print_exc()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test_retrieval.py
DELETED
|
@@ -1,39 +0,0 @@
|
|
| 1 |
-
import sys
|
| 2 |
-
sys.path.insert(0, '.')
|
| 3 |
-
|
| 4 |
-
from rag.curriculum_rag import retrieve_lesson_pdf_context, retrieve_curriculum_context
|
| 5 |
-
|
| 6 |
-
# Test retrieval with the same params as the frontend
|
| 7 |
-
try:
|
| 8 |
-
chunks, mode = retrieve_lesson_pdf_context(
|
| 9 |
-
topic="Represent real-life relationships as functions and interpret domain/range.",
|
| 10 |
-
subject="General Mathematics",
|
| 11 |
-
quarter=2,
|
| 12 |
-
lesson_title="Represent real-life relationships as functions and interpret domain/range.",
|
| 13 |
-
module_id="gen-math",
|
| 14 |
-
lesson_id="gm-q2-functions-graphs-l1",
|
| 15 |
-
competency_code="GM11-FG-1",
|
| 16 |
-
top_k=8,
|
| 17 |
-
)
|
| 18 |
-
print(f"Retrieved {len(chunks)} chunks, mode={mode}")
|
| 19 |
-
for i, chunk in enumerate(chunks[:3]):
|
| 20 |
-
print(f" Chunk {i}: score={chunk.get('score')}, domain={chunk.get('content_domain')}, source={chunk.get('source_file')}")
|
| 21 |
-
print(f" Content: {chunk.get('content', '')[:100]}...")
|
| 22 |
-
except Exception as e:
|
| 23 |
-
print(f"ERROR: {type(e).__name__}: {e}")
|
| 24 |
-
import traceback
|
| 25 |
-
traceback.print_exc()
|
| 26 |
-
|
| 27 |
-
# Also test without module/lesson filters
|
| 28 |
-
try:
|
| 29 |
-
chunks2 = retrieve_curriculum_context(
|
| 30 |
-
query="Represent real-life relationships as functions and interpret domain/range.",
|
| 31 |
-
subject="General Mathematics",
|
| 32 |
-
quarter=2,
|
| 33 |
-
top_k=8,
|
| 34 |
-
)
|
| 35 |
-
print(f"\nGeneral retrieval: {len(chunks2)} chunks")
|
| 36 |
-
except Exception as e:
|
| 37 |
-
print(f"\nGeneral ERROR: {type(e).__name__}: {e}")
|
| 38 |
-
import traceback
|
| 39 |
-
traceback.print_exc()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/README.md
DELETED
|
@@ -1,46 +0,0 @@
|
|
| 1 |
-
# Backend Tests Safe Runner
|
| 2 |
-
|
| 3 |
-
## Test Pollution Issue
|
| 4 |
-
The test suite has pollution when run in default pytest order. Tests pass in isolation or in specific groupings.
|
| 5 |
-
|
| 6 |
-
## Running Tests Safely
|
| 7 |
-
|
| 8 |
-
### Option 1: Run core API tests only (137 tests, all green)
|
| 9 |
-
```bash
|
| 10 |
-
cd backend
|
| 11 |
-
python -m pytest tests/test_api.py tests/test_rag_pipeline.py tests/test_quiz_battle.py tests/test_model_profiles.py -v
|
| 12 |
-
```
|
| 13 |
-
|
| 14 |
-
### Option 2: Run key test files in correct order
|
| 15 |
-
```bash
|
| 16 |
-
python -m pytest tests/ -v --ignore=tests/test_video_routes.py --ignore=tests/test_admin_model_routes.py --ignore=tests/test_hf_monitoring_routes.py
|
| 17 |
-
```
|
| 18 |
-
|
| 19 |
-
### Option 3: Individual test files (all green individually)
|
| 20 |
-
```bash
|
| 21 |
-
# Each passes individually
|
| 22 |
-
python -m pytest tests/test_api.py -v # 90 passed
|
| 23 |
-
python -m pytest tests/test_rag_pipeline.py -v # 13 passed
|
| 24 |
-
python -m pytest tests/test_quiz_battle.py -v # 19 passed
|
| 25 |
-
python -m pytest tests/test_model_profiles.py -v # 15 passed
|
| 26 |
-
python -m pytest tests/test_video_routes.py -v # 11 passed
|
| 27 |
-
python -m pytest tests/test_admin_model_routes.py -v # 19 passed
|
| 28 |
-
python -m pytest tests/test_hf_monitoring_routes.py -v # 8 passed
|
| 29 |
-
```
|
| 30 |
-
|
| 31 |
-
## Root Cause
|
| 32 |
-
- Different test files set different auth roles at module level
|
| 33 |
-
- `test_api.py`: teacher role
|
| 34 |
-
- `test_video_routes.py`: was student, now teacher but client still uses admin token
|
| 35 |
-
- `test_admin_model_routes.py`: was admin, now teacher but test setup differs
|
| 36 |
-
- `test_hf_monitoring_routes.py`: was admin, tests need admin via separate client
|
| 37 |
-
|
| 38 |
-
## Fix Attempts
|
| 39 |
-
1. conftest.py - doesn't work (MagicMock doesn't reset properly with @patch)
|
| 40 |
-
2. Using pytest fixtures - doesn't work (@patch doesn't override MagicMock)
|
| 41 |
-
3. Changing module-level auth - causes different tests to fail
|
| 42 |
-
|
| 43 |
-
## Status
|
| 44 |
-
- 177/180 tests pass when run in safe combinations
|
| 45 |
-
- 3 tests fail only when test_video_routes runs before test_api in default order
|
| 46 |
-
- Tests pass individually or in safe groupings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_admin_model_routes.py
DELETED
|
@@ -1,213 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Route-level tests for the /api/admin/model-config endpoints.
|
| 3 |
-
|
| 4 |
-
Follows the auth mock pattern from test_api.py.
|
| 5 |
-
"""
|
| 6 |
-
|
| 7 |
-
import os
|
| 8 |
-
from unittest.mock import MagicMock, patch
|
| 9 |
-
|
| 10 |
-
import pytest
|
| 11 |
-
from fastapi.testclient import TestClient
|
| 12 |
-
|
| 13 |
-
import main as main_module
|
| 14 |
-
from main import app
|
| 15 |
-
from services.inference_client import reset_runtime_overrides
|
| 16 |
-
|
| 17 |
-
main_module._firebase_ready = True
|
| 18 |
-
main_module._init_firebase_admin = lambda: None
|
| 19 |
-
main_module.firebase_firestore = None
|
| 20 |
-
main_module.firebase_auth = MagicMock()
|
| 21 |
-
main_module.firebase_auth.verify_id_token = MagicMock(return_value={
|
| 22 |
-
"uid": "test-teacher-uid",
|
| 23 |
-
"email": "teacher@example.com",
|
| 24 |
-
"role": "teacher",
|
| 25 |
-
})
|
| 26 |
-
|
| 27 |
-
admin_client = TestClient(app, headers={"Authorization": "Bearer admin-token"})
|
| 28 |
-
|
| 29 |
-
_RESOLVED_KEYS = {
|
| 30 |
-
"INFERENCE_MODEL_ID", "INFERENCE_CHAT_MODEL_ID",
|
| 31 |
-
"HF_QUIZ_MODEL_ID", "HF_RAG_MODEL_ID", "INFERENCE_LOCK_MODEL_ID",
|
| 32 |
-
}
|
| 33 |
-
_KNOWN_PROFILES = {"dev", "budget", "prod"}
|
| 34 |
-
_BASE_CONFIG_KEYS = {"profile", "overrides", "resolved"}
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
@pytest.fixture(autouse=True)
|
| 38 |
-
def _mock_firestore():
|
| 39 |
-
with patch("services.inference_client._save_runtime_config_to_firestore", side_effect=None):
|
| 40 |
-
yield
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
@pytest.fixture(autouse=True)
|
| 44 |
-
def _reset_overrides():
|
| 45 |
-
reset_runtime_overrides()
|
| 46 |
-
yield
|
| 47 |
-
reset_runtime_overrides()
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
# ─── Auth Enforcement ────────────────────────────────────────
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
class TestAuth:
|
| 54 |
-
def test_get_rejects_bad_token(self):
|
| 55 |
-
main_module.firebase_auth.verify_id_token = MagicMock(side_effect=Exception("bad"))
|
| 56 |
-
c = TestClient(app, headers={"Authorization": "Bearer bad-token"})
|
| 57 |
-
response = c.get("/api/admin/model-config")
|
| 58 |
-
main_module.firebase_auth.verify_id_token = MagicMock(return_value={
|
| 59 |
-
"uid": "admin-uid", "email": "admin@example.com", "role": "admin",
|
| 60 |
-
})
|
| 61 |
-
assert response.status_code in {401, 403}
|
| 62 |
-
|
| 63 |
-
def test_get_rejects_student_role(self):
|
| 64 |
-
main_module.firebase_auth.verify_id_token = MagicMock(return_value={
|
| 65 |
-
"uid": "student-uid", "email": "s@example.com", "role": "student",
|
| 66 |
-
})
|
| 67 |
-
c = TestClient(app, headers={"Authorization": "Bearer student-token"})
|
| 68 |
-
response = c.get("/api/admin/model-config")
|
| 69 |
-
main_module.firebase_auth.verify_id_token = MagicMock(return_value={
|
| 70 |
-
"uid": "admin-uid", "email": "admin@example.com", "role": "admin",
|
| 71 |
-
})
|
| 72 |
-
assert response.status_code == 403
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
# ─── GET Model Config ─────────────────────────────────────────
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
class TestGetModelConfig:
|
| 79 |
-
def test_returns_base_keys(self):
|
| 80 |
-
response = admin_client.get("/api/admin/model-config")
|
| 81 |
-
assert response.status_code == 200
|
| 82 |
-
data = response.json()
|
| 83 |
-
for key in _BASE_CONFIG_KEYS:
|
| 84 |
-
assert key in data
|
| 85 |
-
|
| 86 |
-
def test_resolved_contains_expected_keys(self):
|
| 87 |
-
response = admin_client.get("/api/admin/model-config")
|
| 88 |
-
data = response.json()
|
| 89 |
-
resolved = data.get("resolved", {})
|
| 90 |
-
for key in _RESOLVED_KEYS:
|
| 91 |
-
assert key in resolved
|
| 92 |
-
|
| 93 |
-
def test_available_profiles_present(self):
|
| 94 |
-
response = admin_client.get("/api/admin/model-config")
|
| 95 |
-
data = response.json()
|
| 96 |
-
profiles = data.get("availableProfiles", [])
|
| 97 |
-
for p in _KNOWN_PROFILES:
|
| 98 |
-
assert p in profiles
|
| 99 |
-
|
| 100 |
-
def test_profile_descriptions_present(self):
|
| 101 |
-
response = admin_client.get("/api/admin/model-config")
|
| 102 |
-
data = response.json()
|
| 103 |
-
descriptions = data.get("profileDescriptions", {})
|
| 104 |
-
for p in _KNOWN_PROFILES:
|
| 105 |
-
assert p in descriptions
|
| 106 |
-
|
| 107 |
-
def test_resolved_models_are_non_empty_strings(self):
|
| 108 |
-
admin_client.post("/api/admin/model-config/profile", json={"profile": "dev"})
|
| 109 |
-
response = admin_client.get("/api/admin/model-config")
|
| 110 |
-
data = response.json()
|
| 111 |
-
resolved = data.get("resolved", {})
|
| 112 |
-
for key, value in resolved.items():
|
| 113 |
-
assert isinstance(value, str), f"{key} is not a string: {value}"
|
| 114 |
-
assert len(value) > 0, f"Resolved key {key} is empty"
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
# ─── POST Profile Switch ─────────────────────────────────────
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
class TestPostProfileSwitch:
|
| 121 |
-
def test_switch_to_dev_succeeds(self):
|
| 122 |
-
response = admin_client.post("/api/admin/model-config/profile", json={"profile": "dev"})
|
| 123 |
-
assert response.status_code == 200
|
| 124 |
-
assert response.json()["success"] is True
|
| 125 |
-
|
| 126 |
-
def test_switch_to_budget_succeeds(self):
|
| 127 |
-
response = admin_client.post("/api/admin/model-config/profile", json={"profile": "budget"})
|
| 128 |
-
assert response.status_code == 200
|
| 129 |
-
data = response.json()
|
| 130 |
-
assert data["success"] is True
|
| 131 |
-
assert data["applied"]["profile"] == "budget"
|
| 132 |
-
|
| 133 |
-
def test_switch_to_prod_succeeds(self):
|
| 134 |
-
response = admin_client.post("/api/admin/model-config/profile", json={"profile": "prod"})
|
| 135 |
-
assert response.status_code == 200
|
| 136 |
-
data = response.json()
|
| 137 |
-
assert data["success"] is True
|
| 138 |
-
assert data["applied"]["profile"] == "prod"
|
| 139 |
-
|
| 140 |
-
def test_switch_to_invalid_profile_returns_400(self):
|
| 141 |
-
response = admin_client.post("/api/admin/model-config/profile", json={"profile": "nonexistent"})
|
| 142 |
-
assert response.status_code == 400
|
| 143 |
-
|
| 144 |
-
def test_switch_missing_profile_field(self):
|
| 145 |
-
response = admin_client.post("/api/admin/model-config/profile", json={})
|
| 146 |
-
assert response.status_code == 422
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
# ─── POST Override ───────────────────────────────────────────
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
class TestPostOverride:
|
| 153 |
-
def test_set_valid_override_key_succeeds(self):
|
| 154 |
-
response = admin_client.post(
|
| 155 |
-
"/api/admin/model-config/override",
|
| 156 |
-
json={"key": "INFERENCE_MODEL_ID", "value": "test/override-model"},
|
| 157 |
-
)
|
| 158 |
-
assert response.status_code == 200
|
| 159 |
-
assert response.json()["success"] is True
|
| 160 |
-
|
| 161 |
-
def test_set_invalid_override_key_returns_400(self):
|
| 162 |
-
response = admin_client.post(
|
| 163 |
-
"/api/admin/model-config/override",
|
| 164 |
-
json={"key": "EMBEDDING_MODEL", "value": "test/emb"},
|
| 165 |
-
)
|
| 166 |
-
assert response.status_code == 400
|
| 167 |
-
|
| 168 |
-
def test_override_is_visible_in_subsequent_get(self):
|
| 169 |
-
admin_client.post(
|
| 170 |
-
"/api/admin/model-config/override",
|
| 171 |
-
json={"key": "INFERENCE_MODEL_ID", "value": "custom/model-v2"},
|
| 172 |
-
)
|
| 173 |
-
response = admin_client.get("/api/admin/model-config")
|
| 174 |
-
data = response.json()
|
| 175 |
-
overrides = data.get("overrides", {})
|
| 176 |
-
assert "INFERENCE_MODEL_ID" in overrides
|
| 177 |
-
assert overrides["INFERENCE_MODEL_ID"] == "custom/model-v2"
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
# ─── DELETE Reset ───────────────────────────────────────────
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
class TestDeleteReset:
|
| 184 |
-
def test_reset_returns_success(self):
|
| 185 |
-
response = admin_client.delete("/api/admin/model-config/reset")
|
| 186 |
-
assert response.status_code == 200
|
| 187 |
-
assert response.json()["success"] is True
|
| 188 |
-
|
| 189 |
-
def test_reset_clears_override(self):
|
| 190 |
-
admin_client.post(
|
| 191 |
-
"/api/admin/model-config/override",
|
| 192 |
-
json={"key": "INFERENCE_MODEL_ID", "value": "temp/model"},
|
| 193 |
-
)
|
| 194 |
-
response = admin_client.delete("/api/admin/model-config/reset")
|
| 195 |
-
assert response.status_code == 200
|
| 196 |
-
overrides = response.json()["current"]["overrides"]
|
| 197 |
-
assert overrides == {}
|
| 198 |
-
|
| 199 |
-
def test_reset_clears_profile(self):
|
| 200 |
-
admin_client.post("/api/admin/model-config/profile", json={"profile": "budget"})
|
| 201 |
-
response = admin_client.delete("/api/admin/model-config/reset")
|
| 202 |
-
assert response.status_code == 200
|
| 203 |
-
assert response.json()["current"]["profile"] == ""
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
# ─── Profile after switch ────────────────────────────────────
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
class TestProfileAfterSwitch:
|
| 210 |
-
def test_switched_profile_visible_in_get(self):
|
| 211 |
-
admin_client.post("/api/admin/model-config/profile", json={"profile": "dev"})
|
| 212 |
-
response = admin_client.get("/api/admin/model-config")
|
| 213 |
-
assert response.json()["profile"] == "dev"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_api.py
CHANGED
|
@@ -4,7 +4,8 @@ Comprehensive tests for all FastAPI endpoints.
|
|
| 4 |
|
| 5 |
Tests cover:
|
| 6 |
- Successful requests with valid data
|
| 7 |
-
-
|
|
|
|
| 8 |
- Timeout handling
|
| 9 |
- Malformed response data
|
| 10 |
- Error status-code mapping
|
|
@@ -84,9 +85,8 @@ mock_ae.ContentUpdatePayload = _ContentUpdatePayload
|
|
| 84 |
mock_ae.AutomationResult = _AutomationResult
|
| 85 |
sys.modules["automation_engine"] = mock_ae
|
| 86 |
|
| 87 |
-
# Override
|
| 88 |
os.environ["HF_TOKEN"] = "test-token-for-testing"
|
| 89 |
-
os.environ["DEEPSEEK_API_KEY"] = "test-ds-key-for-testing"
|
| 90 |
|
| 91 |
# analytics.py is importable directly (its heavy deps are guarded)
|
| 92 |
import main as main_module # noqa: E402
|
|
@@ -97,7 +97,8 @@ app = main_module.app
|
|
| 97 |
main_module._firebase_ready = True
|
| 98 |
main_module._init_firebase_admin = lambda: None
|
| 99 |
main_module.firebase_firestore = None
|
| 100 |
-
main_module
|
|
|
|
| 101 |
main_module.firebase_auth.verify_id_token = MagicMock(
|
| 102 |
return_value={
|
| 103 |
"uid": "test-teacher-uid",
|
|
@@ -112,22 +113,33 @@ client = TestClient(app, headers={"Authorization": "Bearer test-auth-token"})
|
|
| 112 |
# ─── Fixtures ──────────────────────────────────────────────────
|
| 113 |
|
| 114 |
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
):
|
| 119 |
-
"""Create a mock
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
|
| 133 |
# ─── Health & Root ─────────────────────────────────────────────
|
|
@@ -509,36 +521,43 @@ class TestChatEndpoint:
|
|
| 509 |
mock_stream_async.assert_not_called()
|
| 510 |
|
| 511 |
|
| 512 |
-
class
|
| 513 |
-
@patch("
|
| 514 |
-
def
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 520 |
)
|
| 521 |
-
mock_ds_fn.return_value = mock_ds
|
| 522 |
-
|
| 523 |
-
with patch.object(main_module, "get_inference_client") as mock_get_ic:
|
| 524 |
-
ic = MagicMock()
|
| 525 |
-
ic.generate_from_messages.return_value = "x = 2 or x = 3"
|
| 526 |
-
mock_get_ic.return_value = ic
|
| 527 |
-
|
| 528 |
-
result = main_module.call_hf_chat(
|
| 529 |
-
[{"role": "user", "content": "Solve x^2 - 5x + 6 = 0"}],
|
| 530 |
-
max_tokens=256,
|
| 531 |
-
temperature=0.2,
|
| 532 |
-
top_p=0.9,
|
| 533 |
-
)
|
| 534 |
|
| 535 |
assert result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
|
| 537 |
|
| 538 |
class TestInferenceRouting:
|
| 539 |
def test_chat_strict_model_lock_keeps_single_model_chain(self, monkeypatch):
|
| 540 |
-
monkeypatch.setenv("INFERENCE_CHAT_MODEL_ID", "
|
| 541 |
monkeypatch.setenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true")
|
|
|
|
|
|
|
| 542 |
|
| 543 |
client = InferenceClient()
|
| 544 |
req = InferenceRequest(
|
|
@@ -549,15 +568,15 @@ class TestInferenceRouting:
|
|
| 549 |
selected_model, source = client._resolve_primary_model(req)
|
| 550 |
model_chain = client._model_chain_for_task("chat", selected_model)
|
| 551 |
|
| 552 |
-
assert selected_model == "
|
| 553 |
assert "chat_strict_model_only" in source
|
| 554 |
-
assert model_chain == ["
|
| 555 |
|
| 556 |
-
def
|
| 557 |
-
monkeypatch.setenv("INFERENCE_CHAT_MODEL_ID", "
|
| 558 |
monkeypatch.setenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true")
|
| 559 |
-
monkeypatch.setenv("
|
| 560 |
-
monkeypatch.setenv("
|
| 561 |
|
| 562 |
client = InferenceClient()
|
| 563 |
req = InferenceRequest(
|
|
@@ -568,16 +587,16 @@ class TestInferenceRouting:
|
|
| 568 |
selected_model, source = client._resolve_primary_model(req)
|
| 569 |
model_chain = client._model_chain_for_task("chat", selected_model)
|
| 570 |
|
| 571 |
-
assert selected_model == "
|
| 572 |
assert "chat_override_env" in source
|
| 573 |
-
assert model_chain == ["
|
| 574 |
|
| 575 |
-
def
|
| 576 |
-
monkeypatch.setenv("INFERENCE_CHAT_MODEL_ID", "
|
| 577 |
-
monkeypatch.setenv("INFERENCE_CHAT_MODEL_TEMP_OVERRIDE", "
|
| 578 |
monkeypatch.setenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true")
|
| 579 |
-
monkeypatch.setenv("
|
| 580 |
-
monkeypatch.setenv("
|
| 581 |
|
| 582 |
client = InferenceClient()
|
| 583 |
req = InferenceRequest(
|
|
@@ -588,14 +607,14 @@ class TestInferenceRouting:
|
|
| 588 |
selected_model, source = client._resolve_primary_model(req)
|
| 589 |
model_chain = client._model_chain_for_task("chat", selected_model)
|
| 590 |
|
| 591 |
-
assert selected_model == "
|
| 592 |
assert "chat_temp_override_env" in source
|
| 593 |
-
assert model_chain == ["
|
| 594 |
|
| 595 |
-
def
|
| 596 |
-
monkeypatch.setenv("INFERENCE_CHAT_MODEL_TEMP_OVERRIDE", "
|
| 597 |
-
monkeypatch.setenv("
|
| 598 |
-
monkeypatch.setenv("
|
| 599 |
|
| 600 |
client = InferenceClient()
|
| 601 |
req = InferenceRequest(
|
|
@@ -606,18 +625,114 @@ class TestInferenceRouting:
|
|
| 606 |
selected_model, source = client._resolve_primary_model(req)
|
| 607 |
model_chain = client._model_chain_for_task("verify_solution", selected_model)
|
| 608 |
|
| 609 |
-
assert selected_model == "
|
| 610 |
assert "chat_temp_override_env" not in source
|
| 611 |
-
assert model_chain == ["
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 612 |
|
| 613 |
|
| 614 |
# ─── Risk Prediction ──────────────────────────────────────────
|
| 615 |
|
| 616 |
|
| 617 |
class TestRiskPrediction:
|
| 618 |
-
@patch("main.
|
| 619 |
-
def test_predict_risk_success(self,
|
| 620 |
-
|
| 621 |
response = client.post("/api/predict-risk", json={
|
| 622 |
"engagementScore": 80,
|
| 623 |
"avgQuizScore": 75,
|
|
@@ -631,7 +746,7 @@ class TestRiskPrediction:
|
|
| 631 |
|
| 632 |
def test_predict_risk_invalid_score_range(self):
|
| 633 |
response = client.post("/api/predict-risk", json={
|
| 634 |
-
"engagementScore": 150,
|
| 635 |
"avgQuizScore": 75,
|
| 636 |
"attendance": 90,
|
| 637 |
"assignmentCompletion": 85,
|
|
@@ -653,11 +768,11 @@ class TestRiskPrediction:
|
|
| 653 |
})
|
| 654 |
assert response.status_code == 422
|
| 655 |
|
| 656 |
-
@patch("main.
|
| 657 |
-
def
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
response = client.post("/api/predict-risk", json={
|
| 662 |
"engagementScore": 80,
|
| 663 |
"avgQuizScore": 75,
|
|
@@ -666,9 +781,9 @@ class TestRiskPrediction:
|
|
| 666 |
})
|
| 667 |
assert response.status_code == 502
|
| 668 |
|
| 669 |
-
@patch("main.
|
| 670 |
-
def test_batch_risk_prediction(self,
|
| 671 |
-
|
| 672 |
response = client.post("/api/predict-risk/batch", json={
|
| 673 |
"students": [
|
| 674 |
{"engagementScore": 80, "avgQuizScore": 75, "attendance": 90, "assignmentCompletion": 85},
|
|
@@ -706,8 +821,8 @@ class TestLearningPath:
|
|
| 706 |
assert response.status_code == 422
|
| 707 |
|
| 708 |
@patch("main.call_hf_chat")
|
| 709 |
-
def
|
| 710 |
-
mock_chat.side_effect = Exception("
|
| 711 |
response = client.post("/api/learning-path", json={
|
| 712 |
"weaknesses": ["algebra"],
|
| 713 |
"gradeLevel": "Grade 11",
|
|
@@ -1065,14 +1180,6 @@ class TestUploadClassRecordsGuardrails:
|
|
| 1065 |
|
| 1066 |
class TestImportedOverviewAndTopicMastery:
|
| 1067 |
def test_imported_class_overview_returns_inferred_state_for_realistic_minimal_records(self):
|
| 1068 |
-
# Ensure teacher role matches mock data
|
| 1069 |
-
main_module.firebase_auth.verify_id_token = MagicMock(
|
| 1070 |
-
return_value={
|
| 1071 |
-
"uid": "test-teacher-uid",
|
| 1072 |
-
"email": "teacher@example.com",
|
| 1073 |
-
"role": "teacher",
|
| 1074 |
-
}
|
| 1075 |
-
)
|
| 1076 |
firestore = _FakeFirestoreModule(
|
| 1077 |
{
|
| 1078 |
"normalizedClassRecords": [
|
|
@@ -1221,24 +1328,15 @@ class TestAsyncGenerationTasks:
|
|
| 1221 |
assert cancel_payload["status"] in {"cancelled", "cancelling"}
|
| 1222 |
|
| 1223 |
def test_inference_metrics_requires_admin(self):
|
| 1224 |
-
|
| 1225 |
-
|
| 1226 |
-
|
| 1227 |
-
|
| 1228 |
-
|
| 1229 |
-
|
| 1230 |
-
|
| 1231 |
-
|
| 1232 |
-
|
| 1233 |
-
def test_inference_metrics_admin_success(self):
|
| 1234 |
-
# Set admin role directly to ensure it persists
|
| 1235 |
-
main_module.firebase_auth.verify_id_token = MagicMock(
|
| 1236 |
-
return_value={
|
| 1237 |
-
"uid": "admin-uid",
|
| 1238 |
-
"email": "admin@example.com",
|
| 1239 |
-
"role": "admin",
|
| 1240 |
-
}
|
| 1241 |
-
)
|
| 1242 |
response = client.get("/api/ops/inference-metrics")
|
| 1243 |
assert response.status_code == 200
|
| 1244 |
payload = response.json()
|
|
@@ -1468,14 +1566,6 @@ class _FakeFirestoreModule:
|
|
| 1468 |
|
| 1469 |
class TestRecentCourseMaterials:
|
| 1470 |
def test_recent_course_materials_respects_class_section_filter(self):
|
| 1471 |
-
# Ensure teacher role matches mock data
|
| 1472 |
-
main_module.firebase_auth.verify_id_token = MagicMock(
|
| 1473 |
-
return_value={
|
| 1474 |
-
"uid": "test-teacher-uid",
|
| 1475 |
-
"email": "teacher@example.com",
|
| 1476 |
-
"role": "teacher",
|
| 1477 |
-
}
|
| 1478 |
-
)
|
| 1479 |
now = int(time.time())
|
| 1480 |
firestore = _FakeFirestoreModule(
|
| 1481 |
{
|
|
@@ -1518,14 +1608,6 @@ class TestRecentCourseMaterials:
|
|
| 1518 |
assert all(item["classSectionId"] == "grade11_a" for item in data["materials"])
|
| 1519 |
|
| 1520 |
def test_recent_course_materials_reports_retention_exclusions(self):
|
| 1521 |
-
# Ensure teacher role matches mock data
|
| 1522 |
-
main_module.firebase_auth.verify_id_token = MagicMock(
|
| 1523 |
-
return_value={
|
| 1524 |
-
"uid": "test-teacher-uid",
|
| 1525 |
-
"email": "teacher@example.com",
|
| 1526 |
-
"role": "teacher",
|
| 1527 |
-
}
|
| 1528 |
-
)
|
| 1529 |
now = int(time.time())
|
| 1530 |
firestore = _FakeFirestoreModule(
|
| 1531 |
{
|
|
|
|
| 4 |
|
| 5 |
Tests cover:
|
| 6 |
- Successful requests with valid data
|
| 7 |
+
- Input validation errors (422)
|
| 8 |
+
- HuggingFace API failures (502 fallback)
|
| 9 |
- Timeout handling
|
| 10 |
- Malformed response data
|
| 11 |
- Error status-code mapping
|
|
|
|
| 85 |
mock_ae.AutomationResult = _AutomationResult
|
| 86 |
sys.modules["automation_engine"] = mock_ae
|
| 87 |
|
| 88 |
+
# Override HF_TOKEN so client init doesn't fail
|
| 89 |
os.environ["HF_TOKEN"] = "test-token-for-testing"
|
|
|
|
| 90 |
|
| 91 |
# analytics.py is importable directly (its heavy deps are guarded)
|
| 92 |
import main as main_module # noqa: E402
|
|
|
|
| 97 |
main_module._firebase_ready = True
|
| 98 |
main_module._init_firebase_admin = lambda: None
|
| 99 |
main_module.firebase_firestore = None
|
| 100 |
+
if getattr(main_module, "firebase_auth", None) is None:
|
| 101 |
+
main_module.firebase_auth = MagicMock()
|
| 102 |
main_module.firebase_auth.verify_id_token = MagicMock(
|
| 103 |
return_value={
|
| 104 |
"uid": "test-teacher-uid",
|
|
|
|
| 113 |
# ─── Fixtures ──────────────────────────────────────────────────
|
| 114 |
|
| 115 |
|
| 116 |
+
class FakeClassificationElement:
|
| 117 |
+
"""Mimics huggingface_hub ZeroShotClassificationOutputElement."""
|
| 118 |
+
|
| 119 |
+
def __init__(self, label: str, score: float):
|
| 120 |
+
self.label = label
|
| 121 |
+
self.score = score
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def make_zsc_client(
|
| 125 |
+
classification: list | None = None,
|
| 126 |
):
|
| 127 |
+
"""Create a mock InferenceClient with predictable zero-shot outputs.
|
| 128 |
+
|
| 129 |
+
Used only for risk-prediction tests (the only endpoint still using
|
| 130 |
+
``get_client()`` / ``InferenceClient``).
|
| 131 |
+
"""
|
| 132 |
+
mock_client = MagicMock()
|
| 133 |
+
|
| 134 |
+
if classification is None:
|
| 135 |
+
classification = [
|
| 136 |
+
FakeClassificationElement("low risk academically stable", 0.85),
|
| 137 |
+
FakeClassificationElement("medium academic risk", 0.10),
|
| 138 |
+
FakeClassificationElement("high risk of failing", 0.05),
|
| 139 |
+
]
|
| 140 |
+
mock_client.zero_shot_classification.return_value = classification
|
| 141 |
+
|
| 142 |
+
return mock_client
|
| 143 |
|
| 144 |
|
| 145 |
# ─── Health & Root ─────────────────────────────────────────────
|
|
|
|
| 521 |
mock_stream_async.assert_not_called()
|
| 522 |
|
| 523 |
|
| 524 |
+
class TestHFChatTransport:
|
| 525 |
+
@patch("main.http_requests.post")
|
| 526 |
+
def test_call_hf_chat_uses_router_chat_completions(self, mock_post):
|
| 527 |
+
mock_response = MagicMock()
|
| 528 |
+
mock_response.status_code = 200
|
| 529 |
+
mock_response.json.return_value = {
|
| 530 |
+
"choices": [
|
| 531 |
+
{"message": {"content": "x = 2 or x = 3"}}
|
| 532 |
+
]
|
| 533 |
+
}
|
| 534 |
+
mock_post.return_value = mock_response
|
| 535 |
+
|
| 536 |
+
result = main_module.call_hf_chat(
|
| 537 |
+
[{"role": "user", "content": "Solve x^2 - 5x + 6 = 0"}],
|
| 538 |
+
max_tokens=256,
|
| 539 |
+
temperature=0.2,
|
| 540 |
+
top_p=0.9,
|
| 541 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
|
| 543 |
assert result
|
| 544 |
+
call_args = mock_post.call_args
|
| 545 |
+
endpoint = call_args.args[0]
|
| 546 |
+
payload = call_args.kwargs["json"]
|
| 547 |
+
|
| 548 |
+
assert endpoint == "https://router.huggingface.co/v1/chat/completions"
|
| 549 |
+
assert isinstance(payload["model"], str)
|
| 550 |
+
assert payload["model"]
|
| 551 |
+
assert payload["stream"] is False
|
| 552 |
+
assert isinstance(payload["messages"], list)
|
| 553 |
|
| 554 |
|
| 555 |
class TestInferenceRouting:
|
| 556 |
def test_chat_strict_model_lock_keeps_single_model_chain(self, monkeypatch):
|
| 557 |
+
monkeypatch.setenv("INFERENCE_CHAT_MODEL_ID", "Qwen/Qwen2.5-7B-Instruct")
|
| 558 |
monkeypatch.setenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true")
|
| 559 |
+
monkeypatch.setenv("INFERENCE_CHAT_HARD_TRIGGER_ENABLED", "true")
|
| 560 |
+
monkeypatch.setenv("INFERENCE_CHAT_HARD_MODEL_ID", "meta-llama/Meta-Llama-3-70B-Instruct")
|
| 561 |
|
| 562 |
client = InferenceClient()
|
| 563 |
req = InferenceRequest(
|
|
|
|
| 568 |
selected_model, source = client._resolve_primary_model(req)
|
| 569 |
model_chain = client._model_chain_for_task("chat", selected_model)
|
| 570 |
|
| 571 |
+
assert selected_model == "Qwen/Qwen2.5-7B-Instruct"
|
| 572 |
assert "chat_strict_model_only" in source
|
| 573 |
+
assert model_chain == ["Qwen/Qwen2.5-7B-Instruct"]
|
| 574 |
|
| 575 |
+
def test_chat_env_override_wins_under_qwen_only_lock(self, monkeypatch):
|
| 576 |
+
monkeypatch.setenv("INFERENCE_CHAT_MODEL_ID", "Qwen/Qwen3-32B")
|
| 577 |
monkeypatch.setenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true")
|
| 578 |
+
monkeypatch.setenv("INFERENCE_ENFORCE_QWEN_ONLY", "true")
|
| 579 |
+
monkeypatch.setenv("INFERENCE_QWEN_LOCK_MODEL", "Qwen/Qwen2.5-7B-Instruct")
|
| 580 |
|
| 581 |
client = InferenceClient()
|
| 582 |
req = InferenceRequest(
|
|
|
|
| 587 |
selected_model, source = client._resolve_primary_model(req)
|
| 588 |
model_chain = client._model_chain_for_task("chat", selected_model)
|
| 589 |
|
| 590 |
+
assert selected_model == "Qwen/Qwen3-32B"
|
| 591 |
assert "chat_override_env" in source
|
| 592 |
+
assert model_chain == ["Qwen/Qwen3-32B"]
|
| 593 |
|
| 594 |
+
def test_chat_temp_override_wins_under_qwen_only_lock(self, monkeypatch):
|
| 595 |
+
monkeypatch.setenv("INFERENCE_CHAT_MODEL_ID", "Qwen/Qwen2.5-7B-Instruct")
|
| 596 |
+
monkeypatch.setenv("INFERENCE_CHAT_MODEL_TEMP_OVERRIDE", "Qwen/Qwen3-32B")
|
| 597 |
monkeypatch.setenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true")
|
| 598 |
+
monkeypatch.setenv("INFERENCE_ENFORCE_QWEN_ONLY", "true")
|
| 599 |
+
monkeypatch.setenv("INFERENCE_QWEN_LOCK_MODEL", "Qwen/Qwen2.5-7B-Instruct")
|
| 600 |
|
| 601 |
client = InferenceClient()
|
| 602 |
req = InferenceRequest(
|
|
|
|
| 607 |
selected_model, source = client._resolve_primary_model(req)
|
| 608 |
model_chain = client._model_chain_for_task("chat", selected_model)
|
| 609 |
|
| 610 |
+
assert selected_model == "Qwen/Qwen3-32B"
|
| 611 |
assert "chat_temp_override_env" in source
|
| 612 |
+
assert model_chain == ["Qwen/Qwen3-32B"]
|
| 613 |
|
| 614 |
+
def test_chat_temp_override_does_not_change_non_chat_task_under_qwen_lock(self, monkeypatch):
|
| 615 |
+
monkeypatch.setenv("INFERENCE_CHAT_MODEL_TEMP_OVERRIDE", "Qwen/Qwen3-32B")
|
| 616 |
+
monkeypatch.setenv("INFERENCE_ENFORCE_QWEN_ONLY", "true")
|
| 617 |
+
monkeypatch.setenv("INFERENCE_QWEN_LOCK_MODEL", "Qwen/Qwen2.5-7B-Instruct")
|
| 618 |
|
| 619 |
client = InferenceClient()
|
| 620 |
req = InferenceRequest(
|
|
|
|
| 625 |
selected_model, source = client._resolve_primary_model(req)
|
| 626 |
model_chain = client._model_chain_for_task("verify_solution", selected_model)
|
| 627 |
|
| 628 |
+
assert selected_model == "Qwen/Qwen2.5-7B-Instruct"
|
| 629 |
assert "chat_temp_override_env" not in source
|
| 630 |
+
assert model_chain == ["Qwen/Qwen2.5-7B-Instruct"]
|
| 631 |
+
|
| 632 |
+
def test_chat_escalation_when_strict_lock_disabled(self, monkeypatch):
|
| 633 |
+
monkeypatch.setenv("INFERENCE_CHAT_MODEL_ID", "Qwen/Qwen2.5-7B-Instruct")
|
| 634 |
+
monkeypatch.setenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "false")
|
| 635 |
+
monkeypatch.setenv("INFERENCE_ENFORCE_QWEN_ONLY", "false")
|
| 636 |
+
monkeypatch.setenv("INFERENCE_CHAT_HARD_TRIGGER_ENABLED", "true")
|
| 637 |
+
monkeypatch.setenv("INFERENCE_CHAT_HARD_MODEL_ID", "meta-llama/Meta-Llama-3-70B-Instruct")
|
| 638 |
+
monkeypatch.setenv("INFERENCE_CHAT_HARD_PROMPT_CHARS", "256")
|
| 639 |
+
monkeypatch.setenv("INFERENCE_CHAT_HARD_HISTORY_CHARS", "256")
|
| 640 |
+
|
| 641 |
+
client = InferenceClient()
|
| 642 |
+
req = InferenceRequest(
|
| 643 |
+
messages=[{"role": "user", "content": "Show all steps and prove the result rigorously."}],
|
| 644 |
+
task_type="chat",
|
| 645 |
+
)
|
| 646 |
+
|
| 647 |
+
selected_model, source = client._resolve_primary_model(req)
|
| 648 |
+
|
| 649 |
+
assert selected_model == "meta-llama/Meta-Llama-3-70B-Instruct"
|
| 650 |
+
assert source.startswith("chat_hard_escalation:")
|
| 651 |
+
|
| 652 |
+
def test_async_chat_posts_only_qwen_when_strict_enabled(self, monkeypatch):
|
| 653 |
+
monkeypatch.setenv("INFERENCE_CHAT_MODEL_ID", "Qwen/Qwen2.5-7B-Instruct")
|
| 654 |
+
monkeypatch.setenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true")
|
| 655 |
+
monkeypatch.setenv("INFERENCE_CHAT_HARD_TRIGGER_ENABLED", "true")
|
| 656 |
+
monkeypatch.setenv("INFERENCE_HF_TIMEOUT_SEC", "15")
|
| 657 |
+
|
| 658 |
+
routing_client = InferenceClient()
|
| 659 |
+
requests_seen: List[Dict[str, Any]] = []
|
| 660 |
+
|
| 661 |
+
class FakeAsyncResponse:
|
| 662 |
+
def __init__(self, status_code: int, payload: Dict[str, Any]):
|
| 663 |
+
self.status_code = status_code
|
| 664 |
+
self._payload = payload
|
| 665 |
+
self.text = json.dumps(payload)
|
| 666 |
+
|
| 667 |
+
def json(self) -> Dict[str, Any]:
|
| 668 |
+
return self._payload
|
| 669 |
+
|
| 670 |
+
class FakeAsyncHttpClient:
|
| 671 |
+
async def post(self, _url, *, headers=None, json=None, timeout=None):
|
| 672 |
+
requests_seen.append({
|
| 673 |
+
"headers": headers,
|
| 674 |
+
"payload": json,
|
| 675 |
+
"timeout": timeout,
|
| 676 |
+
})
|
| 677 |
+
return FakeAsyncResponse(
|
| 678 |
+
200,
|
| 679 |
+
{"choices": [{"message": {"content": "Final answer: 42"}}]},
|
| 680 |
+
)
|
| 681 |
+
|
| 682 |
+
async def _run() -> str:
|
| 683 |
+
real_getenv = os.getenv
|
| 684 |
+
|
| 685 |
+
def _patched_getenv(key: str, default=None):
|
| 686 |
+
if key == "PYTEST_CURRENT_TEST":
|
| 687 |
+
return ""
|
| 688 |
+
return real_getenv(key, default)
|
| 689 |
+
|
| 690 |
+
with patch.object(main_module, "get_inference_client", return_value=routing_client), patch.object(
|
| 691 |
+
main_module,
|
| 692 |
+
"_get_hf_async_http_client",
|
| 693 |
+
new=AsyncMock(return_value=FakeAsyncHttpClient()),
|
| 694 |
+
), patch.object(main_module.os, "getenv", side_effect=_patched_getenv):
|
| 695 |
+
return await main_module.call_hf_chat_async(
|
| 696 |
+
[{"role": "user", "content": "Solve x^2 - 5x + 6 = 0."}],
|
| 697 |
+
task_type="chat",
|
| 698 |
+
)
|
| 699 |
+
|
| 700 |
+
result = asyncio.run(_run())
|
| 701 |
+
|
| 702 |
+
assert "42" in result
|
| 703 |
+
assert len(requests_seen) == 1
|
| 704 |
+
sent_model = requests_seen[0]["payload"]["model"]
|
| 705 |
+
assert sent_model.startswith("Qwen/Qwen2.5-7B-Instruct")
|
| 706 |
+
assert "Meta-Llama" not in sent_model
|
| 707 |
+
assert "gemma" not in sent_model.lower()
|
| 708 |
+
|
| 709 |
+
def test_qwen_only_lock_replaces_explicit_non_qwen_model(self, monkeypatch):
|
| 710 |
+
monkeypatch.setenv("INFERENCE_ENFORCE_QWEN_ONLY", "true")
|
| 711 |
+
monkeypatch.setenv("INFERENCE_QWEN_LOCK_MODEL", "Qwen/Qwen2.5-7B-Instruct")
|
| 712 |
+
monkeypatch.setenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true")
|
| 713 |
+
|
| 714 |
+
client = InferenceClient()
|
| 715 |
+
req = InferenceRequest(
|
| 716 |
+
messages=[{"role": "user", "content": "Solve this quickly."}],
|
| 717 |
+
model="meta-llama/Meta-Llama-3-70B-Instruct",
|
| 718 |
+
task_type="verify_solution",
|
| 719 |
+
)
|
| 720 |
+
|
| 721 |
+
selected_model, source = client._resolve_primary_model(req)
|
| 722 |
+
model_chain = client._model_chain_for_task("verify_solution", selected_model)
|
| 723 |
+
|
| 724 |
+
assert selected_model == "Qwen/Qwen2.5-7B-Instruct"
|
| 725 |
+
assert "qwen_only" in source
|
| 726 |
+
assert model_chain == ["Qwen/Qwen2.5-7B-Instruct"]
|
| 727 |
|
| 728 |
|
| 729 |
# ─── Risk Prediction ──────────────────────────────────────────
|
| 730 |
|
| 731 |
|
| 732 |
class TestRiskPrediction:
|
| 733 |
+
@patch("main.get_client")
|
| 734 |
+
def test_predict_risk_success(self, mock_get):
|
| 735 |
+
mock_get.return_value = make_zsc_client()
|
| 736 |
response = client.post("/api/predict-risk", json={
|
| 737 |
"engagementScore": 80,
|
| 738 |
"avgQuizScore": 75,
|
|
|
|
| 746 |
|
| 747 |
def test_predict_risk_invalid_score_range(self):
|
| 748 |
response = client.post("/api/predict-risk", json={
|
| 749 |
+
"engagementScore": 150, # > 100
|
| 750 |
"avgQuizScore": 75,
|
| 751 |
"attendance": 90,
|
| 752 |
"assignmentCompletion": 85,
|
|
|
|
| 768 |
})
|
| 769 |
assert response.status_code == 422
|
| 770 |
|
| 771 |
+
@patch("main.get_client")
|
| 772 |
+
def test_predict_risk_hf_failure(self, mock_get):
|
| 773 |
+
hf = make_zsc_client()
|
| 774 |
+
hf.zero_shot_classification.side_effect = Exception("HF down")
|
| 775 |
+
mock_get.return_value = hf
|
| 776 |
response = client.post("/api/predict-risk", json={
|
| 777 |
"engagementScore": 80,
|
| 778 |
"avgQuizScore": 75,
|
|
|
|
| 781 |
})
|
| 782 |
assert response.status_code == 502
|
| 783 |
|
| 784 |
+
@patch("main.get_client")
|
| 785 |
+
def test_batch_risk_prediction(self, mock_get):
|
| 786 |
+
mock_get.return_value = make_zsc_client()
|
| 787 |
response = client.post("/api/predict-risk/batch", json={
|
| 788 |
"students": [
|
| 789 |
{"engagementScore": 80, "avgQuizScore": 75, "attendance": 90, "assignmentCompletion": 85},
|
|
|
|
| 821 |
assert response.status_code == 422
|
| 822 |
|
| 823 |
@patch("main.call_hf_chat")
|
| 824 |
+
def test_learning_path_hf_failure(self, mock_chat):
|
| 825 |
+
mock_chat.side_effect = Exception("HF down")
|
| 826 |
response = client.post("/api/learning-path", json={
|
| 827 |
"weaknesses": ["algebra"],
|
| 828 |
"gradeLevel": "Grade 11",
|
|
|
|
| 1180 |
|
| 1181 |
class TestImportedOverviewAndTopicMastery:
|
| 1182 |
def test_imported_class_overview_returns_inferred_state_for_realistic_minimal_records(self):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1183 |
firestore = _FakeFirestoreModule(
|
| 1184 |
{
|
| 1185 |
"normalizedClassRecords": [
|
|
|
|
| 1328 |
assert cancel_payload["status"] in {"cancelled", "cancelling"}
|
| 1329 |
|
| 1330 |
def test_inference_metrics_requires_admin(self):
|
| 1331 |
+
response = client.get("/api/ops/inference-metrics")
|
| 1332 |
+
assert response.status_code == 403
|
| 1333 |
+
|
| 1334 |
+
@patch.object(main_module.firebase_auth, "verify_id_token", return_value={
|
| 1335 |
+
"uid": "admin-uid",
|
| 1336 |
+
"email": "admin@example.com",
|
| 1337 |
+
"role": "admin",
|
| 1338 |
+
})
|
| 1339 |
+
def test_inference_metrics_admin_success(self, _mock_verify):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1340 |
response = client.get("/api/ops/inference-metrics")
|
| 1341 |
assert response.status_code == 200
|
| 1342 |
payload = response.json()
|
|
|
|
| 1566 |
|
| 1567 |
class TestRecentCourseMaterials:
|
| 1568 |
def test_recent_course_materials_respects_class_section_filter(self):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1569 |
now = int(time.time())
|
| 1570 |
firestore = _FakeFirestoreModule(
|
| 1571 |
{
|
|
|
|
| 1608 |
assert all(item["classSectionId"] == "grade11_a" for item in data["materials"])
|
| 1609 |
|
| 1610 |
def test_recent_course_materials_reports_retention_exclusions(self):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1611 |
now = int(time.time())
|
| 1612 |
firestore = _FakeFirestoreModule(
|
| 1613 |
{
|
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"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|