Spaces:
Running
Running
github-actions[bot] commited on
Commit ยท
92bfe31
1
Parent(s): a8945eb
๐ Auto-deploy backend from GitHub (93e7c2a)
Browse filesThis view is limited to 50 files because it contains too many changes. ย See raw diff
- .deploy-trigger +1 -0
- .env.example +33 -0
- .gitattributes +0 -35
- analytics.py +6 -11
- config/env.sample +15 -10
- datasets/sample_curriculum.json +137 -0
- main.py +0 -0
- middleware/__init__.py +4 -0
- middleware/rate_limiter.py +184 -0
- pre_deploy_check.py +10 -2
- rag/__init__.py +9 -1
- rag/curriculum_rag.py +199 -48
- rag/firebase_storage_loader.py +0 -9
- rag/pdf_ingestion.py +368 -0
- rag/vectorstore_loader.py +11 -2
- requirements.txt +8 -1
- routes/admin_model_routes.py +67 -0
- routes/admin_routes.py +87 -0
- routes/diagnostic.py +797 -0
- routes/quiz_battle.py +205 -0
- routes/quiz_generation_routes.py +356 -0
- routes/rag_routes.py +298 -54
- routes/video_routes.py +102 -0
- scripts/download_vectorstore_from_firebase.py +74 -9
- scripts/ingest_curriculum.py +136 -221
- scripts/ingest_from_storage.py +285 -0
- scripts/migrate_grade12_to_grade11.py +107 -0
- scripts/register_firestore_metadata.py +183 -0
- scripts/seed_curriculum.py +64 -0
- scripts/upload_curriculum_pdfs.py +264 -0
- scripts/upload_lesson_modules.py +142 -0
- scripts/upload_vectorstore_to_firebase.py +71 -0
- services/__init__.py +43 -0
- services/ai_client.py +1 -13
- services/curriculum_service.py +7 -7
- services/inference_client.py +17 -197
- services/question_bank_service.py +123 -0
- services/user_provisioning_service.py +0 -1
- services/variance_engine.py +115 -0
- services/youtube_service.py +1017 -0
- startup.sh +41 -5
- startup_validation.py +23 -24
- test_full_rag.py +75 -0
- test_retrieval.py +39 -0
- tests/README.md +46 -0
- tests/test_admin_model_routes.py +213 -0
- tests/test_api.py +118 -200
- tests/test_hf_monitoring_routes.py +148 -0
- tests/test_model_profiles.py +184 -0
- tests/test_quiz_battle.py +223 -0
.deploy-trigger
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
๏ปฟ2026-04-29 21:37:27
|
.env.example
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
DELETED
|
@@ -1,35 +0,0 @@
|
|
| 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,7 +232,6 @@ 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 |
-
streak: Optional[int] = 0
|
| 236 |
xpGrowthRate: Optional[float] = 0.0
|
| 237 |
timeOnPlatform: Optional[float] = 0.0 # hours
|
| 238 |
# Optional trend data
|
|
@@ -810,7 +809,7 @@ def _build_risk_features(data: EnhancedRiskRequest) -> np.ndarray:
|
|
| 810 |
data.avgQuizScore,
|
| 811 |
data.attendance,
|
| 812 |
data.assignmentCompletion,
|
| 813 |
-
|
| 814 |
data.xpGrowthRate or 0.0,
|
| 815 |
data.timeOnPlatform or 0.0,
|
| 816 |
data.engagementTrend7d or 0.0,
|
|
@@ -871,12 +870,8 @@ def _rule_based_risk(data: EnhancedRiskRequest) -> EnhancedRiskPrediction:
|
|
| 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 |
|
|
@@ -1156,7 +1151,7 @@ async def train_risk_model(force_retrain: bool = False) -> RiskTrainResponse:
|
|
| 1156 |
data.get("avgQuizScore", 50),
|
| 1157 |
data.get("attendance", 80),
|
| 1158 |
data.get("assignmentCompletion", 60),
|
| 1159 |
-
|
| 1160 |
data.get("xpGrowthRate", 0),
|
| 1161 |
data.get("timeOnPlatform", 0),
|
| 1162 |
0.0, # engagementTrend7d
|
|
@@ -1260,7 +1255,7 @@ def _generate_synthetic_risk_data(n: int) -> Tuple[np.ndarray, np.ndarray]:
|
|
| 1260 |
quiz = np.random.normal(35, 12)
|
| 1261 |
attendance = np.random.normal(50, 15)
|
| 1262 |
completion = np.random.normal(35, 15)
|
| 1263 |
-
streak
|
| 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,7 +1267,7 @@ def _generate_synthetic_risk_data(n: int) -> Tuple[np.ndarray, np.ndarray]:
|
|
| 1272 |
quiz = np.random.normal(60, 10)
|
| 1273 |
attendance = np.random.normal(72, 10)
|
| 1274 |
completion = np.random.normal(60, 12)
|
| 1275 |
-
streak
|
| 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,7 +1279,7 @@ def _generate_synthetic_risk_data(n: int) -> Tuple[np.ndarray, np.ndarray]:
|
|
| 1284 |
quiz = np.random.normal(85, 8)
|
| 1285 |
attendance = np.random.normal(93, 5)
|
| 1286 |
completion = np.random.normal(88, 8)
|
| 1287 |
-
streak
|
| 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,7 +1292,7 @@ def _generate_synthetic_risk_data(n: int) -> Tuple[np.ndarray, np.ndarray]:
|
|
| 1297 |
max(0, min(100, quiz)),
|
| 1298 |
max(0, min(100, attendance)),
|
| 1299 |
max(0, min(100, completion)),
|
| 1300 |
-
|
| 1301 |
xp_growth,
|
| 1302 |
max(0, time_platform),
|
| 1303 |
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 |
xpGrowthRate: Optional[float] = 0.0
|
| 236 |
timeOnPlatform: Optional[float] = 0.0 # hours
|
| 237 |
# Optional trend data
|
|
|
|
| 809 |
data.avgQuizScore,
|
| 810 |
data.attendance,
|
| 811 |
data.assignmentCompletion,
|
| 812 |
+
0, # streak removed
|
| 813 |
data.xpGrowthRate or 0.0,
|
| 814 |
data.timeOnPlatform or 0.0,
|
| 815 |
data.engagementTrend7d or 0.0,
|
|
|
|
| 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 |
|
|
|
|
| 1151 |
data.get("avgQuizScore", 50),
|
| 1152 |
data.get("attendance", 80),
|
| 1153 |
data.get("assignmentCompletion", 60),
|
| 1154 |
+
0, # streak removed
|
| 1155 |
data.get("xpGrowthRate", 0),
|
| 1156 |
data.get("timeOnPlatform", 0),
|
| 1157 |
0.0, # engagementTrend7d
|
|
|
|
| 1255 |
quiz = np.random.normal(35, 12)
|
| 1256 |
attendance = np.random.normal(50, 15)
|
| 1257 |
completion = np.random.normal(35, 15)
|
| 1258 |
+
# streak removed
|
| 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 |
quiz = np.random.normal(60, 10)
|
| 1268 |
attendance = np.random.normal(72, 10)
|
| 1269 |
completion = np.random.normal(60, 12)
|
| 1270 |
+
# streak removed
|
| 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 |
quiz = np.random.normal(85, 8)
|
| 1280 |
attendance = np.random.normal(93, 5)
|
| 1281 |
completion = np.random.normal(88, 8)
|
| 1282 |
+
# streak removed
|
| 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 |
max(0, min(100, quiz)),
|
| 1293 |
max(0, min(100, attendance)),
|
| 1294 |
max(0, min(100, completion)),
|
| 1295 |
+
0, # streak removed
|
| 1296 |
xp_growth,
|
| 1297 |
max(0, time_platform),
|
| 1298 |
trend,
|
config/env.sample
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# Inference provider selection
|
| 2 |
# CI trigger marker: keep this file touchable to force backend deploy workflow runs when needed.
|
| 3 |
INFERENCE_PROVIDER=deepseek
|
| 4 |
INFERENCE_PRO_ENABLED=true
|
| 5 |
-
INFERENCE_PRO_PROVIDER=
|
| 6 |
-
INFERENCE_GPU_PROVIDER=
|
| 7 |
-
INFERENCE_CPU_PROVIDER=
|
| 8 |
INFERENCE_ENABLE_PROVIDER_FALLBACK=true
|
| 9 |
INFERENCE_PRO_PRIORITY_TASKS=chat,verify_solution
|
| 10 |
INFERENCE_PRO_ROUTE_HEADER_NAME=
|
|
@@ -24,15 +30,14 @@ INFERENCE_LOCAL_SPACE_URL=http://127.0.0.1:7860
|
|
| 24 |
INFERENCE_LOCAL_SPACE_GENERATE_PATH=/gradio_api/call/generate
|
| 25 |
INFERENCE_LOCAL_SPACE_TIMEOUT_SEC=180
|
| 26 |
|
| 27 |
-
#
|
| 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 |
-
|
| 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,13 +69,13 @@ APP_BRAND_AVATAR_URL=
|
|
| 64 |
|
| 65 |
# model defaults
|
| 66 |
# Global default model for all tasks.
|
| 67 |
-
INFERENCE_MODEL_ID=
|
| 68 |
INFERENCE_ENFORCE_QWEN_ONLY=true
|
| 69 |
-
INFERENCE_QWEN_LOCK_MODEL=
|
| 70 |
INFERENCE_MAX_NEW_TOKENS=8192
|
| 71 |
INFERENCE_TEMPERATURE=0.2
|
| 72 |
INFERENCE_TOP_P=0.9
|
| 73 |
-
INFERENCE_CHAT_MODEL_ID=
|
| 74 |
# Temporary chat-only override for experiments (clear to roll back instantly).
|
| 75 |
# Example: Qwen/Qwen3-32B
|
| 76 |
INFERENCE_CHAT_MODEL_TEMP_OVERRIDE=
|
|
@@ -90,7 +95,7 @@ CHAT_STREAM_CONTINUATION_TAIL_CHARS=900
|
|
| 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=
|
| 94 |
|
| 95 |
# retry behavior
|
| 96 |
INFERENCE_MAX_RETRIES=3
|
|
|
|
| 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=deepseek
|
| 10 |
INFERENCE_PRO_ENABLED=true
|
| 11 |
+
INFERENCE_PRO_PROVIDER=deepseek
|
| 12 |
+
INFERENCE_GPU_PROVIDER=deepseek
|
| 13 |
+
INFERENCE_CPU_PROVIDER=deepseek
|
| 14 |
INFERENCE_ENABLE_PROVIDER_FALLBACK=true
|
| 15 |
INFERENCE_PRO_PRIORITY_TASKS=chat,verify_solution
|
| 16 |
INFERENCE_PRO_ROUTE_HEADER_NAME=
|
|
|
|
| 30 |
INFERENCE_LOCAL_SPACE_GENERATE_PATH=/gradio_api/call/generate
|
| 31 |
INFERENCE_LOCAL_SPACE_TIMEOUT_SEC=180
|
| 32 |
|
| 33 |
+
# HF_TOKEN kept for Hugging Face Space deployment and dataset push only
|
| 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 |
+
# DeepSeek timeout settings
|
|
|
|
| 41 |
INFERENCE_HF_TIMEOUT_SEC=90
|
| 42 |
INFERENCE_INTERACTIVE_TIMEOUT_SEC=55
|
| 43 |
INFERENCE_BACKGROUND_TIMEOUT_SEC=120
|
|
|
|
| 69 |
|
| 70 |
# model defaults
|
| 71 |
# Global default model for all tasks.
|
| 72 |
+
INFERENCE_MODEL_ID=deepseek-chat
|
| 73 |
INFERENCE_ENFORCE_QWEN_ONLY=true
|
| 74 |
+
INFERENCE_QWEN_LOCK_MODEL=deepseek-chat
|
| 75 |
INFERENCE_MAX_NEW_TOKENS=8192
|
| 76 |
INFERENCE_TEMPERATURE=0.2
|
| 77 |
INFERENCE_TOP_P=0.9
|
| 78 |
+
INFERENCE_CHAT_MODEL_ID=deepseek-chat
|
| 79 |
# Temporary chat-only override for experiments (clear to roll back instantly).
|
| 80 |
# Example: Qwen/Qwen3-32B
|
| 81 |
INFERENCE_CHAT_MODEL_TEMP_OVERRIDE=
|
|
|
|
| 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=deepseek-chat
|
| 99 |
|
| 100 |
# retry behavior
|
| 101 |
INFERENCE_MAX_RETRIES=3
|
datasets/sample_curriculum.json
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,8 +16,16 @@ Exit codes:
|
|
| 16 |
import sys
|
| 17 |
import os
|
| 18 |
|
| 19 |
-
# Add backend to path
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
def main() -> int:
|
| 23 |
"""Run pre-deployment checks."""
|
|
|
|
| 16 |
import sys
|
| 17 |
import os
|
| 18 |
|
| 19 |
+
# Add repo root to path (for services/ delegation) AND backend to path
|
| 20 |
+
_repo_root = os.path.dirname(os.path.abspath(__file__))
|
| 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."""
|
rag/__init__.py
CHANGED
|
@@ -5,11 +5,19 @@ from .curriculum_rag import (
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
"build_lesson_query",
|
| 20 |
+
"format_retrieved_chunks",
|
| 21 |
+
"summarize_retrieval_confidence",
|
| 22 |
+
"reset_vectorstore_singleton",
|
| 23 |
+
]
|
rag/curriculum_rag.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
from
|
| 4 |
|
| 5 |
-
from
|
| 6 |
|
| 7 |
|
| 8 |
def _to_where(
|
|
@@ -10,6 +12,10 @@ 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,6 +26,14 @@ def _to_where(
|
|
| 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,7 +42,6 @@ def _to_where(
|
|
| 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,12 +51,23 @@ def retrieve_curriculum_context(
|
|
| 38 |
quarter: int | None = None,
|
| 39 |
content_domain: str | None = None,
|
| 40 |
chunk_type: str | None = None,
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,22 +83,41 @@ def retrieve_curriculum_context(
|
|
| 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 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
| 75 |
return rows
|
| 76 |
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
def build_lesson_query(
|
| 79 |
topic: str,
|
| 80 |
subject: str,
|
|
@@ -93,30 +136,120 @@ def build_lesson_query(
|
|
| 93 |
return " | ".join(parts)
|
| 94 |
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
def format_retrieved_chunks(curriculum_chunks: list[dict]) -> str:
|
| 97 |
-
|
| 98 |
for i, chunk in enumerate(curriculum_chunks, start=1):
|
| 99 |
-
|
| 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(
|
| 105 |
|
| 106 |
|
| 107 |
-
def summarize_retrieval_confidence(curriculum_chunks: list[dict]) -> Dict[str,
|
| 108 |
if not curriculum_chunks:
|
| 109 |
-
return {"confidence": 0.0, "band": "low"}
|
| 110 |
|
| 111 |
-
top_scores = [float(
|
| 112 |
score = sum(top_scores) / max(1, len(top_scores))
|
| 113 |
-
if score >= 0.72
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
|
| 122 |
def build_lesson_prompt(
|
|
@@ -129,39 +262,57 @@ 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
|
| 136 |
-
"Generate JSON
|
|
|
|
| 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 '
|
| 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
|
| 147 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
"Rules:\n"
|
| 149 |
-
"-
|
| 150 |
-
"-
|
| 151 |
-
"-
|
| 152 |
-
"-
|
| 153 |
-
"-
|
|
|
|
|
|
|
| 154 |
)
|
| 155 |
|
| 156 |
|
| 157 |
def build_problem_generation_prompt(topic: str, difficulty: str, curriculum_chunks: list[dict]) -> str:
|
| 158 |
-
|
| 159 |
for i, chunk in enumerate(curriculum_chunks, start=1):
|
| 160 |
-
|
| 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(
|
| 165 |
|
| 166 |
return (
|
| 167 |
"Generate one practice problem strictly aligned to the retrieved DepEd competency scope.\n"
|
|
@@ -184,7 +335,7 @@ def build_analysis_curriculum_context(weak_topics: list[str], subject: str) -> l
|
|
| 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())
|
|
|
|
| 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, Tuple
|
| 8 |
|
| 9 |
|
| 10 |
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 |
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 |
|
| 43 |
|
| 44 |
def _distance_to_score(distance: float) -> float:
|
|
|
|
| 45 |
return round(1.0 / (1.0 + max(distance, 0.0)), 4)
|
| 46 |
|
| 47 |
|
|
|
|
| 51 |
quarter: int | None = None,
|
| 52 |
content_domain: str | None = None,
|
| 53 |
chunk_type: str | None = None,
|
| 54 |
+
module_id: str | None = None,
|
| 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, module_id, lesson_id, competency_code, storage_path)
|
| 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 |
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 |
+
"content": str(content or ""),
|
| 88 |
+
"subject": str(md.get("subject") or "unknown"),
|
| 89 |
+
"quarter": int(md.get("quarter") or 0),
|
| 90 |
+
"content_domain": str(md.get("content_domain") or "general"),
|
| 91 |
+
"chunk_type": str(md.get("chunk_type") or "concept"),
|
| 92 |
+
"source_file": str(md.get("source_file") or ""),
|
| 93 |
+
"storage_path": str(md.get("storage_path") or ""),
|
| 94 |
+
"module_id": str(md.get("module_id") or ""),
|
| 95 |
+
"lesson_id": str(md.get("lesson_id") or ""),
|
| 96 |
+
"competency_code": str(md.get("competency_code") or ""),
|
| 97 |
+
"page": int(md.get("page") or 0),
|
| 98 |
+
"score": _distance_to_score(distance),
|
| 99 |
+
})
|
| 100 |
return rows
|
| 101 |
|
| 102 |
|
| 103 |
+
def build_exact_lesson_query(
|
| 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(
|
| 122 |
topic: str,
|
| 123 |
subject: str,
|
|
|
|
| 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 |
+
refs = []
|
| 210 |
for i, chunk in enumerate(curriculum_chunks, start=1):
|
| 211 |
+
refs.append(
|
| 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(refs) if refs else "No curriculum context retrieved."
|
| 217 |
|
| 218 |
|
| 219 |
+
def summarize_retrieval_confidence(curriculum_chunks: list[dict]) -> Dict[str, any]:
|
| 220 |
if not curriculum_chunks:
|
| 221 |
+
return {"confidence": 0.0, "band": "low", "chunkCount": 0}
|
| 222 |
|
| 223 |
+
top_scores = [float(c.get("score") or 0.0) for c in curriculum_chunks[:5]]
|
| 224 |
score = sum(top_scores) / max(1, len(top_scores))
|
| 225 |
+
band = "high" if score >= 0.72 else "medium" if score >= 0.5 else "low"
|
| 226 |
+
return {"confidence": round(score, 3), "band": band, "chunkCount": len(curriculum_chunks)}
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def organize_chunks_by_section(chunks: list[dict]) -> Dict[str, List[dict]]:
|
| 230 |
+
"""Organize retrieved chunks into lesson section categories."""
|
| 231 |
+
sections: Dict[str, List[dict]] = {
|
| 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 |
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 DepEd-aligned Grade 11-12 mathematics instructional designer.\n"
|
| 272 |
+
"Generate a lesson in JSON format. Use ONLY the retrieved curriculum evidence below.\n"
|
| 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 'Grade 11-12'}\n"
|
| 281 |
f"Module/unit: {module_unit or 'n/a'}\n\n"
|
| 282 |
"[CURRICULUM CONTEXT]\n"
|
| 283 |
f"{refs_text}\n\n"
|
| 284 |
+
"Return ONLY valid JSON with this exact structure. All 7 sections are required:\n"
|
| 285 |
+
"{\n"
|
| 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 |
+
"- content in introduction, key_concepts, important_notes, summary: use paragraph/bullet text grounded in retrieved chunks\n"
|
| 299 |
+
"- examples must reflect actual content from the retrieved curriculum (real formulas, real contexts)\n"
|
| 300 |
+
"- practiceProblems should be derivable from worked examples\n"
|
| 301 |
+
"- callouts: type is 'important', 'tip', or 'warning'\n"
|
| 302 |
+
"- video section: content is a brief sentence, leave videoId empty (will be filled by backend)\n"
|
| 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 |
+
refs = []
|
| 310 |
for i, chunk in enumerate(curriculum_chunks, start=1):
|
| 311 |
+
refs.append(
|
| 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(refs) if refs else "No curriculum context retrieved."
|
| 316 |
|
| 317 |
return (
|
| 318 |
"Generate one practice problem strictly aligned to the retrieved DepEd competency scope.\n"
|
|
|
|
| 335 |
top_k=2,
|
| 336 |
)
|
| 337 |
for row in rows:
|
| 338 |
+
key = f"{row.get('source_file')}::{row.get('page')}::{row.get('content', '')[:80]}"
|
| 339 |
if key not in dedup:
|
| 340 |
dedup[key] = row
|
| 341 |
+
return list(dedup.values())
|
rag/firebase_storage_loader.py
CHANGED
|
@@ -45,15 +45,6 @@ def _init_firebase_storage() -> Tuple[any, any]:
|
|
| 45 |
return None, None
|
| 46 |
|
| 47 |
sa_json = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON")
|
| 48 |
-
# Also check HF Spaces secret mount path
|
| 49 |
-
if not sa_json:
|
| 50 |
-
secret_path = "/secret/FIREBASE_SERVICE_ACCOUNT_JSON"
|
| 51 |
-
if Path(secret_path).exists():
|
| 52 |
-
try:
|
| 53 |
-
sa_json = Path(secret_path).read_text(encoding="utf-8").strip()
|
| 54 |
-
except Exception:
|
| 55 |
-
pass
|
| 56 |
-
|
| 57 |
sa_file = os.getenv("FIREBASE_SERVICE_ACCOUNT_FILE")
|
| 58 |
bucket_name = os.getenv("FIREBASE_STORAGE_BUCKET", "mathpulse-ai-2026.firebasestorage.app")
|
| 59 |
|
|
|
|
| 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 |
|
rag/pdf_ingestion.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,6 +12,12 @@ _VECTORSTORE_LOCK = Lock()
|
|
| 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,7 +34,7 @@ def _resolve_vectorstore_dir() -> Path:
|
|
| 28 |
|
| 29 |
def get_vectorstore_components(
|
| 30 |
collection_name: str = "curriculum_chunks",
|
| 31 |
-
model_name: str = "BAAI/bge-
|
| 32 |
):
|
| 33 |
global _VECTORSTORE_SINGLETON
|
| 34 |
if _VECTORSTORE_SINGLETON is None:
|
|
@@ -37,7 +43,10 @@ def get_vectorstore_components(
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 41 |
embedder = SentenceTransformer(model_name)
|
| 42 |
_VECTORSTORE_SINGLETON = (client, collection, embedder)
|
| 43 |
return _VECTORSTORE_SINGLETON
|
|
|
|
| 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 |
|
| 35 |
def get_vectorstore_components(
|
| 36 |
collection_name: str = "curriculum_chunks",
|
| 37 |
+
model_name: str = "BAAI/bge-base-en-v1.5",
|
| 38 |
):
|
| 39 |
global _VECTORSTORE_SINGLETON
|
| 40 |
if _VECTORSTORE_SINGLETON is None:
|
|
|
|
| 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
|
requirements.txt
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 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
|
|
@@ -17,6 +18,12 @@ joblib==1.4.2
|
|
| 17 |
scipy==1.15.1
|
| 18 |
numpy==2.2.1
|
| 19 |
firebase-admin>=6.2.0
|
| 20 |
-
openai>=1.12.0
|
| 21 |
redis[hiredis]>=5.0.0
|
| 22 |
PyYAML>=6.0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 18 |
scipy==1.15.1
|
| 19 |
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
|
routes/admin_model_routes.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/diagnostic.py
ADDED
|
@@ -0,0 +1,797 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,6 +2,8 @@ from __future__ import annotations
|
|
| 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,21 +11,28 @@ 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 25 |
except Exception:
|
| 26 |
-
firebase_firestore = None
|
| 27 |
|
| 28 |
logger = logging.getLogger("mathpulse.rag")
|
| 29 |
router = APIRouter(prefix="/api/rag", tags=["rag"])
|
|
@@ -41,7 +50,12 @@ def _get_inference_client():
|
|
| 41 |
return _inference_client
|
| 42 |
|
| 43 |
|
| 44 |
-
async def _generate_text(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
request = InferenceRequest(
|
| 46 |
messages=[
|
| 47 |
{"role": "system", "content": "You are a precise DepEd-aligned curriculum assistant."},
|
|
@@ -51,6 +65,7 @@ async def _generate_text(prompt: str, task_type: str, max_new_tokens: int = 900)
|
|
| 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,6 +103,21 @@ def _log_rag_usage(
|
|
| 88 |
logger.warning("rag_usage logging skipped: %s", exc)
|
| 89 |
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
class RagLessonRequest(BaseModel):
|
| 92 |
topic: str
|
| 93 |
subject: str
|
|
@@ -97,6 +127,10 @@ class RagLessonRequest(BaseModel):
|
|
| 97 |
moduleUnit: Optional[str] = None
|
| 98 |
learnerLevel: Optional[str] = None
|
| 99 |
userId: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
|
| 102 |
class RagProblemRequest(BaseModel):
|
|
@@ -115,6 +149,8 @@ class RagAnalysisContextRequest(BaseModel):
|
|
| 115 |
|
| 116 |
@router.get("/health")
|
| 117 |
async def rag_health():
|
|
|
|
|
|
|
| 118 |
try:
|
| 119 |
health = get_vectorstore_health()
|
| 120 |
return {
|
|
@@ -122,6 +158,8 @@ async def rag_health():
|
|
| 122 |
"chunkCount": health["chunkCount"],
|
| 123 |
"subjects": health["subjects"],
|
| 124 |
"lastIngested": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
|
|
| 125 |
}
|
| 126 |
except Exception as exc:
|
| 127 |
return {
|
|
@@ -129,68 +167,273 @@ async def rag_health():
|
|
| 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 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
retrieval_summary = summarize_retrieval_confidence(chunks)
|
| 165 |
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
return {
|
| 176 |
-
|
| 177 |
"retrievalConfidence": retrieval_summary.get("confidence", 0.0),
|
| 178 |
"retrievalBand": retrieval_summary.get("band", "low"),
|
| 179 |
-
"
|
| 180 |
-
"needsReview":
|
| 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,19 +446,20 @@ async def rag_generate_problem(request: Request, payload: RagProblemRequest):
|
|
| 203 |
top_k=5,
|
| 204 |
)
|
| 205 |
prompt = build_problem_generation_prompt(payload.topic, payload.difficulty, chunks)
|
| 206 |
-
raw = await _generate_text(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
|
| 208 |
-
parsed
|
| 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,4 +511,4 @@ async def rag_analysis_context(request: Request, payload: RagAnalysisContextRequ
|
|
| 267 |
chunks=chunks,
|
| 268 |
)
|
| 269 |
|
| 270 |
-
return {"curriculumContext": "\n".join(lines)}
|
|
|
|
| 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 |
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, reset_vectorstore_singleton
|
| 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 |
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 |
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 |
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 |
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 |
|
| 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 |
"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 |
"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 |
+
# โโ Step 1: Retrieve curriculum chunks โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 270 |
+
try:
|
| 271 |
+
chunks, retrieval_mode = retrieve_lesson_pdf_context(
|
| 272 |
+
topic=build_lesson_query(
|
| 273 |
+
payload.topic,
|
| 274 |
+
payload.subject,
|
| 275 |
+
payload.quarter,
|
| 276 |
+
lesson_title=payload.lessonTitle,
|
| 277 |
+
competency=payload.learningCompetency,
|
| 278 |
+
module_unit=payload.moduleUnit,
|
| 279 |
+
learner_level=payload.learnerLevel,
|
| 280 |
+
),
|
| 281 |
+
subject=payload.subject,
|
| 282 |
+
quarter=payload.quarter,
|
| 283 |
+
lesson_title=payload.lessonTitle,
|
| 284 |
+
competency=payload.learningCompetency,
|
| 285 |
+
module_id=payload.moduleId,
|
| 286 |
+
lesson_id=payload.lessonId,
|
| 287 |
+
competency_code=payload.competencyCode,
|
| 288 |
+
storage_path=payload.storagePath,
|
| 289 |
+
top_k=8,
|
| 290 |
+
)
|
| 291 |
+
except Exception as exc:
|
| 292 |
+
import traceback
|
| 293 |
+
logger.error(f"RAG retrieval error: {type(exc).__name__}: {exc}\n{traceback.format_exc()}")
|
| 294 |
+
raise HTTPException(
|
| 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 |
+
try:
|
| 401 |
+
_log_rag_usage(
|
| 402 |
+
request,
|
| 403 |
+
event_type="lesson",
|
| 404 |
+
topic=build_lesson_query(payload.topic, payload.subject, payload.quarter, lesson_title=payload.lessonTitle),
|
| 405 |
+
subject=payload.subject,
|
| 406 |
+
quarter=payload.quarter,
|
| 407 |
+
chunks=chunks,
|
| 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 |
+
**parsed_lesson,
|
| 418 |
"retrievalConfidence": retrieval_summary.get("confidence", 0.0),
|
| 419 |
"retrievalBand": retrieval_summary.get("band", "low"),
|
| 420 |
+
"retrievalMode": retrieval_mode,
|
| 421 |
+
"needsReview": needs_review,
|
| 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 |
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 = _strip_thinking_and_parse(raw)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
chunks=chunks,
|
| 512 |
)
|
| 513 |
|
| 514 |
+
return {"curriculumContext": "\n".join(lines)}
|
routes/video_routes.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,10 +1,11 @@
|
|
| 1 |
"""
|
| 2 |
Download vectorstore directory from Firebase Storage at container startup.
|
| 3 |
-
Run: python
|
| 4 |
"""
|
| 5 |
|
| 6 |
from __future__ import annotations
|
| 7 |
|
|
|
|
| 8 |
import logging
|
| 9 |
import os
|
| 10 |
import sys
|
|
@@ -12,17 +13,66 @@ from pathlib import Path
|
|
| 12 |
|
| 13 |
logger = logging.getLogger("mathpulse.download_vectorstore")
|
| 14 |
|
| 15 |
-
|
|
|
|
| 16 |
|
| 17 |
-
from hf_space_test.rag.firebase_storage_loader import _init_firebase_storage
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
def download_vectorstore(dest_dir: Path, prefix: str = REMOTE_PREFIX):
|
| 24 |
-
|
| 25 |
-
_, bucket = _init_firebase_storage()
|
| 26 |
if bucket is None:
|
| 27 |
logger.warning("Firebase Storage not available, vectorstore download skipped")
|
| 28 |
return False
|
|
@@ -35,6 +85,7 @@ def download_vectorstore(dest_dir: Path, prefix: str = REMOTE_PREFIX):
|
|
| 35 |
return False
|
| 36 |
|
| 37 |
downloaded = 0
|
|
|
|
| 38 |
errors = 0
|
| 39 |
|
| 40 |
for blob in blobs:
|
|
@@ -46,6 +97,10 @@ def download_vectorstore(dest_dir: Path, prefix: str = REMOTE_PREFIX):
|
|
| 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,10 +108,20 @@ def download_vectorstore(dest_dir: Path, prefix: str = REMOTE_PREFIX):
|
|
| 53 |
logger.error("Failed to download %s: %s", blob.name, e)
|
| 54 |
errors += 1
|
| 55 |
|
| 56 |
-
logger.info("Download complete: %d
|
| 57 |
return errors == 0
|
| 58 |
|
| 59 |
|
| 60 |
if __name__ == "__main__":
|
| 61 |
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
Download vectorstore directory from Firebase Storage at container startup.
|
| 3 |
+
Run: python /app/scripts/download_vectorstore_from_firebase.py
|
| 4 |
"""
|
| 5 |
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
+
import json
|
| 9 |
import logging
|
| 10 |
import os
|
| 11 |
import sys
|
|
|
|
| 13 |
|
| 14 |
logger = logging.getLogger("mathpulse.download_vectorstore")
|
| 15 |
|
| 16 |
+
REMOTE_PREFIX = "vectorstore/"
|
| 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 |
+
try:
|
| 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 |
+
firebase_admin.initialize_app(creds, {"storageBucket": bucket_name})
|
| 60 |
+
_FIREBASE_INITIALIZED = True
|
| 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 |
+
bucket = _init_firebase()
|
|
|
|
| 76 |
if bucket is None:
|
| 77 |
logger.warning("Firebase Storage not available, vectorstore download skipped")
|
| 78 |
return False
|
|
|
|
| 85 |
return False
|
| 86 |
|
| 87 |
downloaded = 0
|
| 88 |
+
skipped = 0
|
| 89 |
errors = 0
|
| 90 |
|
| 91 |
for blob in blobs:
|
|
|
|
| 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 |
logger.error("Failed to download %s: %s", blob.name, e)
|
| 109 |
errors += 1
|
| 110 |
|
| 111 |
+
logger.info("Download complete: %d downloaded, %d skipped, %d errors", downloaded, skipped, errors)
|
| 112 |
return errors == 0
|
| 113 |
|
| 114 |
|
| 115 |
if __name__ == "__main__":
|
| 116 |
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
| 117 |
+
dest_dir = _resolve_dest_dir()
|
| 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")
|
scripts/ingest_curriculum.py
CHANGED
|
@@ -1,244 +1,159 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import json
|
|
|
|
| 4 |
import os
|
| 5 |
-
import
|
| 6 |
-
from collections import Counter
|
| 7 |
-
from datetime import datetime, timezone
|
| 8 |
from pathlib import Path
|
| 9 |
-
from typing import Dict, List
|
| 10 |
|
| 11 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 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 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
metadata = {
|
| 193 |
"subject": subject,
|
| 194 |
"quarter": quarter,
|
| 195 |
-
"content_domain":
|
| 196 |
"chunk_type": chunk_type,
|
| 197 |
-
"source_file":
|
| 198 |
-
"page":
|
| 199 |
}
|
| 200 |
-
chunk_id = f"{pdf_file.stem}-{page_number}-{idx}"
|
| 201 |
|
| 202 |
-
documents.append(
|
| 203 |
metadatas.append(metadata)
|
| 204 |
ids.append(chunk_id)
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
}
|
| 228 |
-
(
|
| 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 |
-
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import argparse
|
| 4 |
+
import hashlib
|
| 5 |
import json
|
| 6 |
+
import logging
|
| 7 |
import os
|
| 8 |
+
import sys
|
|
|
|
|
|
|
| 9 |
from pathlib import Path
|
| 10 |
+
from typing import Any, Dict, List
|
| 11 |
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
from rag.vectorstore_loader import (
|
| 15 |
+
get_vectorstore_components,
|
| 16 |
+
reset_vectorstore_singleton,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
)
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _resolve_data_dir(raw: str | None) -> Path:
|
| 23 |
+
if raw:
|
| 24 |
+
p = Path(raw)
|
| 25 |
+
if p.is_absolute():
|
| 26 |
+
return p
|
| 27 |
+
p = Path.cwd() / raw
|
| 28 |
+
if p.exists():
|
| 29 |
+
return p
|
| 30 |
+
default = Path(__file__).resolve().parents[1] / "datasets"
|
| 31 |
+
return default
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _iter_json_files(data_dir: Path):
|
| 35 |
+
for file in sorted(data_dir.rglob("*")):
|
| 36 |
+
if file.suffix not in {".json", ".jsonl"}:
|
| 37 |
+
continue
|
| 38 |
+
yield file
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _load_records(file_path: Path) -> List[Dict[str, Any]]:
|
| 42 |
+
records: List[Dict[str, Any]] = []
|
| 43 |
+
try:
|
| 44 |
+
raw = file_path.read_text(encoding="utf-8").strip()
|
| 45 |
+
if file_path.suffix == ".jsonl":
|
| 46 |
+
for lineno, line in enumerate(raw.splitlines(), start=1):
|
| 47 |
+
line = line.strip()
|
| 48 |
+
if not line:
|
| 49 |
+
continue
|
| 50 |
+
try:
|
| 51 |
+
records.append(json.loads(line))
|
| 52 |
+
except json.JSONDecodeError:
|
| 53 |
+
logger.warning("Skipping malformed JSONL line %s:%d", file_path.name, lineno)
|
| 54 |
+
else:
|
| 55 |
+
parsed = json.loads(raw)
|
| 56 |
+
if isinstance(parsed, list):
|
| 57 |
+
records.extend(parsed)
|
| 58 |
+
elif isinstance(parsed, dict):
|
| 59 |
+
records.append(parsed)
|
| 60 |
+
except Exception as exc:
|
| 61 |
+
logger.warning("Failed to parse %s: %s", file_path.name, exc)
|
| 62 |
+
return records
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _build_id(source_file: str, page: int, content: str) -> str:
|
| 66 |
+
key = f"{source_file}::{page}::{content[:120]}"
|
| 67 |
+
return hashlib.sha256(key.encode()).hexdigest()[:40]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
|
| 70 |
def main() -> None:
|
| 71 |
+
parser = argparse.ArgumentParser(description="Ingest DepEd SHS curriculum JSON/JSONL into ChromaDB")
|
| 72 |
+
parser.add_argument("--data-dir", default=None, help="Directory containing .json/.jsonl files")
|
| 73 |
+
parser.add_argument("--reset", action="store_true", help="Reset the vectorstore singleton before ingestion")
|
| 74 |
+
args = parser.parse_args()
|
| 75 |
+
|
| 76 |
+
data_dir = _resolve_data_dir(args.data_dir)
|
| 77 |
+
logger.info("Ingesting from: %s", data_dir)
|
| 78 |
+
|
| 79 |
+
if args.reset:
|
| 80 |
+
reset_vectorstore_singleton()
|
| 81 |
+
_, collection, _ = get_vectorstore_components()
|
| 82 |
+
try:
|
| 83 |
+
collection.delete(ids=collection.get(include=[])["ids"])
|
| 84 |
+
except Exception:
|
| 85 |
+
pass
|
| 86 |
+
reset_vectorstore_singleton()
|
| 87 |
+
|
| 88 |
+
total_processed = 0
|
| 89 |
+
total_upserted = 0
|
| 90 |
+
total_errors = 0
|
| 91 |
+
|
| 92 |
+
_, collection, embedder = get_vectorstore_components()
|
| 93 |
+
|
| 94 |
+
for file_path in _iter_json_files(data_dir):
|
| 95 |
+
records = _load_records(file_path)
|
| 96 |
+
documents: List[str] = []
|
| 97 |
+
metadatas: List[Dict[str, Any]] = []
|
| 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": content_domain,
|
| 123 |
"chunk_type": chunk_type,
|
| 124 |
+
"source_file": source_file,
|
| 125 |
+
"page": page,
|
| 126 |
}
|
|
|
|
| 127 |
|
| 128 |
+
documents.append(content)
|
| 129 |
metadatas.append(metadata)
|
| 130 |
ids.append(chunk_id)
|
| 131 |
+
embeddings_list.append(embedding)
|
| 132 |
+
|
| 133 |
+
except Exception as exc:
|
| 134 |
+
total_errors += 1
|
| 135 |
+
logger.warning("Error processing record in %s: %s", file_path.name, exc)
|
| 136 |
+
|
| 137 |
+
if documents:
|
| 138 |
+
try:
|
| 139 |
+
collection.upsert(
|
| 140 |
+
ids=ids,
|
| 141 |
+
documents=documents,
|
| 142 |
+
metadatas=metadatas,
|
| 143 |
+
embeddings=embeddings_list,
|
| 144 |
+
)
|
| 145 |
+
total_upserted += len(documents)
|
| 146 |
+
logger.info("Upserted %d chunks from %s", len(documents), file_path.name)
|
| 147 |
+
except Exception as exc:
|
| 148 |
+
total_errors += len(documents)
|
| 149 |
+
logger.warning("Failed to upsert batch from %s: %s", file_path.name, exc)
|
| 150 |
+
|
| 151 |
+
print(f"=== Ingestion Summary ===")
|
| 152 |
+
print(f"Total records processed: {total_processed}")
|
| 153 |
+
print(f"Total chunks upserted: {total_upserted}")
|
| 154 |
+
print(f"Total errors: {total_errors}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
|
| 157 |
if __name__ == "__main__":
|
| 158 |
+
logging.basicConfig(level=logging.INFO)
|
| 159 |
+
main()
|
scripts/ingest_from_storage.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 +1,44 @@
|
|
| 1 |
"""Backend service helpers for inference, logging, and integrations."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
]
|
services/ai_client.py
CHANGED
|
@@ -1,10 +1,9 @@
|
|
| 1 |
import os
|
| 2 |
-
from openai import OpenAI,
|
| 3 |
from functools import lru_cache
|
| 4 |
|
| 5 |
__all__ = [
|
| 6 |
"get_deepseek_client",
|
| 7 |
-
"get_async_deepseek_client",
|
| 8 |
"CHAT_MODEL",
|
| 9 |
"REASONER_MODEL",
|
| 10 |
"DEEPSEEK_BASE_URL",
|
|
@@ -27,14 +26,3 @@ def get_deepseek_client() -> OpenAI:
|
|
| 27 |
api_key=api_key,
|
| 28 |
base_url=DEEPSEEK_BASE_URL,
|
| 29 |
)
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
@lru_cache(maxsize=1)
|
| 33 |
-
def get_async_deepseek_client() -> AsyncOpenAI:
|
| 34 |
-
api_key = os.getenv("DEEPSEEK_API_KEY")
|
| 35 |
-
if not api_key:
|
| 36 |
-
raise ValueError("DEEPSEEK_API_KEY environment variable not set")
|
| 37 |
-
return AsyncOpenAI(
|
| 38 |
-
api_key=api_key,
|
| 39 |
-
base_url=DEEPSEEK_BASE_URL,
|
| 40 |
-
)
|
|
|
|
| 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",
|
|
|
|
| 26 |
api_key=api_key,
|
| 27 |
base_url=DEEPSEEK_BASE_URL,
|
| 28 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
services/curriculum_service.py
CHANGED
|
@@ -165,15 +165,15 @@ def get_subjects(grade_level: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
| 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:
|
|
@@ -181,13 +181,13 @@ def get_subjects(grade_level: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
| 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]
|
|
@@ -197,7 +197,7 @@ def get_subjects(grade_level: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
| 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()
|
|
@@ -207,7 +207,7 @@ def get_subject(subject_id: str) -> Optional[Dict[str, Any]]:
|
|
| 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:
|
|
|
|
| 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:
|
|
|
|
| 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]
|
|
|
|
| 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()
|
|
|
|
| 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:
|
services/inference_client.py
CHANGED
|
@@ -6,13 +6,13 @@ import random
|
|
| 6 |
from threading import Lock
|
| 7 |
from dataclasses import dataclass
|
| 8 |
from pathlib import Path
|
| 9 |
-
from typing import Any,
|
| 10 |
|
| 11 |
import requests
|
| 12 |
import yaml
|
| 13 |
-
from openai import OpenAI,
|
| 14 |
|
| 15 |
-
from .ai_client import get_deepseek_client,
|
| 16 |
from .logging_utils import configure_structured_logging, log_model_call
|
| 17 |
|
| 18 |
LOGGER = configure_structured_logging("mathpulse.inference")
|
|
@@ -254,11 +254,11 @@ class InferenceClient:
|
|
| 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 |
|
|
@@ -271,13 +271,6 @@ class InferenceClient:
|
|
| 271 |
primary = primary_cfg
|
| 272 |
|
| 273 |
self.provider = "deepseek"
|
| 274 |
-
self.cpu_provider = os.getenv("INFERENCE_CPU_PROVIDER", "deepseek").strip().lower() or self.provider
|
| 275 |
-
self.gpu_provider = os.getenv("INFERENCE_GPU_PROVIDER", "deepseek").strip().lower() or self.provider
|
| 276 |
-
# Pro provider not used in current setup
|
| 277 |
-
self.pro_enabled = False
|
| 278 |
-
self.pro_provider = self.provider
|
| 279 |
-
self.pro_priority_tasks: Set[str] = set()
|
| 280 |
-
self.enable_provider_fallback = False
|
| 281 |
self.ds_api_key = os.getenv("DEEPSEEK_API_KEY", "")
|
| 282 |
self.ds_base_url = os.getenv("DEEPSEEK_BASE_URL", DEEPSEEK_BASE_URL)
|
| 283 |
self.ds_chat_model = os.getenv("DEEPSEEK_MODEL", CHAT_MODEL)
|
|
@@ -402,7 +395,7 @@ class InferenceClient:
|
|
| 402 |
for task_key in list(self.task_model_map.keys()):
|
| 403 |
self.task_model_map[task_key] = env_model_id
|
| 404 |
LOGGER.info(
|
| 405 |
-
f"
|
| 406 |
)
|
| 407 |
LOGGER.info(
|
| 408 |
f" Task model mappings changed from: {original_map}"
|
|
@@ -420,13 +413,13 @@ class InferenceClient:
|
|
| 420 |
self.task_fallback_model_map = {
|
| 421 |
task_key: [] for task_key in self.task_model_map.keys()
|
| 422 |
}
|
| 423 |
-
LOGGER.info(f"
|
| 424 |
LOGGER.info(f" Cleared fallback models")
|
| 425 |
LOGGER.info(f" Task model mappings forced from: {lock_map_before}")
|
| 426 |
|
| 427 |
config_status = "from file" if config_path else "hardcoded defaults (no config file found)"
|
| 428 |
effective_chat_model_for_logs = self.chat_model_override or self.task_model_map.get("chat", self.default_model)
|
| 429 |
-
LOGGER.info(f"
|
| 430 |
LOGGER.info(f" Default model: {self.default_model}")
|
| 431 |
LOGGER.info(f" Chat model: {effective_chat_model_for_logs}")
|
| 432 |
LOGGER.info(f" Chat temp override ({TEMP_CHAT_MODEL_OVERRIDE_ENV}): {self.chat_model_temp_override or 'disabled'}")
|
|
@@ -494,9 +487,9 @@ class InferenceClient:
|
|
| 494 |
self._metrics[k] = v
|
| 495 |
elif isinstance(v, dict) and isinstance(self._metrics[k], dict):
|
| 496 |
self._metrics[k].update(v)
|
| 497 |
-
LOGGER.info("
|
| 498 |
except Exception as e:
|
| 499 |
-
LOGGER.warning(f"
|
| 500 |
|
| 501 |
def _persist_metrics(self, force: bool = False) -> None:
|
| 502 |
if not self.firestore:
|
|
@@ -514,7 +507,7 @@ class InferenceClient:
|
|
| 514 |
|
| 515 |
doc_ref.set(snapshot, merge=True)
|
| 516 |
except Exception as e:
|
| 517 |
-
LOGGER.warning(f"
|
| 518 |
|
| 519 |
def _record_attempt(self, *, task_type: str, provider: str, route: str, fallback_depth: int) -> None:
|
| 520 |
self._bump_metric("requests_total", 1)
|
|
@@ -558,7 +551,7 @@ class InferenceClient:
|
|
| 558 |
model_base = selected_model
|
| 559 |
|
| 560 |
LOGGER.info(
|
| 561 |
-
f"
|
| 562 |
f"selected_model={model_base} (primary)"
|
| 563 |
)
|
| 564 |
LOGGER.info(f" fallback_chain={model_chain[1:] if len(model_chain) > 1 else 'none'}")
|
|
@@ -579,13 +572,13 @@ class InferenceClient:
|
|
| 579 |
try:
|
| 580 |
result = self._call_deepseek(request_for_model, fallback_depth)
|
| 581 |
if fallback_depth > 0:
|
| 582 |
-
LOGGER.info(f"
|
| 583 |
return result
|
| 584 |
except Exception as exc:
|
| 585 |
last_error = exc
|
| 586 |
fallback_hint = f" (depth {fallback_depth})" if fallback_depth > 0 else ""
|
| 587 |
LOGGER.warning(
|
| 588 |
-
f"
|
| 589 |
f"model={model_name} error={exc.__class__.__name__}: {str(exc)[:100]}"
|
| 590 |
)
|
| 591 |
|
|
@@ -622,7 +615,7 @@ class InferenceClient:
|
|
| 622 |
lock_base = (effective_lock_model_id or "").split(":", 1)[0].strip()
|
| 623 |
if selected_base != lock_base:
|
| 624 |
LOGGER.warning(
|
| 625 |
-
f"
|
| 626 |
)
|
| 627 |
selected_model = effective_lock_model_id
|
| 628 |
model_selection_source = f"{model_selection_source}:model_lock"
|
|
@@ -681,10 +674,6 @@ class InferenceClient:
|
|
| 681 |
return self.interactive_timeout_sec
|
| 682 |
return self.background_timeout_sec
|
| 683 |
|
| 684 |
-
def _provider_chain_for_task(self, task_type: str) -> List[str]:
|
| 685 |
-
"""Return provider chain for task. All inference uses deepseek."""
|
| 686 |
-
return ["deepseek"]
|
| 687 |
-
|
| 688 |
def _messages_to_prompt(self, messages: List[Dict[str, str]]) -> str:
|
| 689 |
parts: List[str] = []
|
| 690 |
for msg in messages:
|
|
@@ -719,7 +708,7 @@ class InferenceClient:
|
|
| 719 |
task_type = req.task_type or "default"
|
| 720 |
|
| 721 |
LOGGER.debug(
|
| 722 |
-
f"
|
| 723 |
f"route={route} depth={fallback_depth}"
|
| 724 |
)
|
| 725 |
|
|
@@ -891,175 +880,6 @@ class InferenceClient:
|
|
| 891 |
|
| 892 |
raise RuntimeError(f"DeepSeek call failed after {max_retries} attempts")
|
| 893 |
|
| 894 |
-
async def _call_deepseek_stream(self, req: InferenceRequest, fallback_depth: int) -> AsyncIterator[str]:
|
| 895 |
-
"""Stream DeepSeek API with OpenAI-compatible chat completions. Yields content chunks."""
|
| 896 |
-
if not self.ds_api_key:
|
| 897 |
-
raise RuntimeError("DEEPSEEK_API_KEY is not set")
|
| 898 |
-
|
| 899 |
-
target_model = req.model or self.default_model
|
| 900 |
-
route = "deepseek"
|
| 901 |
-
task_type = req.task_type or "default"
|
| 902 |
-
|
| 903 |
-
LOGGER.debug(
|
| 904 |
-
f"๐ Streaming DeepSeek: task={task_type} model={target_model} route={route}"
|
| 905 |
-
)
|
| 906 |
-
|
| 907 |
-
timeout = self._timeout_for(req, "deepseek")
|
| 908 |
-
max_retries, backoff_sec = self._retry_profile(task_type)
|
| 909 |
-
|
| 910 |
-
client = get_async_deepseek_client()
|
| 911 |
-
|
| 912 |
-
params: Dict[str, Any] = {
|
| 913 |
-
"model": target_model,
|
| 914 |
-
"messages": req.messages,
|
| 915 |
-
"max_tokens": req.max_new_tokens or self.default_max_new_tokens,
|
| 916 |
-
}
|
| 917 |
-
if target_model == REASONER_MODEL:
|
| 918 |
-
params["max_tokens"] = req.max_new_tokens or 1024
|
| 919 |
-
else:
|
| 920 |
-
params["temperature"] = req.temperature
|
| 921 |
-
params["top_p"] = req.top_p
|
| 922 |
-
|
| 923 |
-
last_error: Optional[Exception] = None
|
| 924 |
-
for attempt in range(max_retries):
|
| 925 |
-
self._record_attempt(
|
| 926 |
-
task_type=task_type,
|
| 927 |
-
provider="deepseek",
|
| 928 |
-
route=route,
|
| 929 |
-
fallback_depth=fallback_depth,
|
| 930 |
-
)
|
| 931 |
-
start = time.perf_counter()
|
| 932 |
-
try:
|
| 933 |
-
stream = client.chat.completions.stream(**params, timeout=timeout)
|
| 934 |
-
async for chunk in stream:
|
| 935 |
-
if chunk.choices[0].delta.content:
|
| 936 |
-
yield chunk.choices[0].delta.content
|
| 937 |
-
|
| 938 |
-
latency_ms = (time.perf_counter() - start) * 1000
|
| 939 |
-
log_model_call(
|
| 940 |
-
LOGGER,
|
| 941 |
-
provider="deepseek",
|
| 942 |
-
model=target_model,
|
| 943 |
-
endpoint=self.ds_base_url,
|
| 944 |
-
latency_ms=latency_ms,
|
| 945 |
-
input_tokens=None,
|
| 946 |
-
output_tokens=None,
|
| 947 |
-
status="ok",
|
| 948 |
-
task_type=task_type,
|
| 949 |
-
request_tag=req.request_tag,
|
| 950 |
-
retry_attempt=attempt + 1,
|
| 951 |
-
fallback_depth=fallback_depth,
|
| 952 |
-
route=route,
|
| 953 |
-
)
|
| 954 |
-
self._bump_metric("requests_ok", 1)
|
| 955 |
-
return
|
| 956 |
-
|
| 957 |
-
except RateLimitError:
|
| 958 |
-
latency_ms = (time.perf_counter() - start) * 1000
|
| 959 |
-
if attempt < max_retries - 1:
|
| 960 |
-
log_model_call(
|
| 961 |
-
LOGGER,
|
| 962 |
-
provider="deepseek",
|
| 963 |
-
model=target_model,
|
| 964 |
-
endpoint=self.ds_base_url,
|
| 965 |
-
latency_ms=latency_ms,
|
| 966 |
-
input_tokens=None,
|
| 967 |
-
output_tokens=None,
|
| 968 |
-
status="error",
|
| 969 |
-
error_class="RateLimitError",
|
| 970 |
-
error_message="rate limited",
|
| 971 |
-
task_type=task_type,
|
| 972 |
-
request_tag=req.request_tag,
|
| 973 |
-
retry_attempt=attempt + 1,
|
| 974 |
-
fallback_depth=fallback_depth,
|
| 975 |
-
route=route,
|
| 976 |
-
)
|
| 977 |
-
self._bump_metric("retries_total", 1)
|
| 978 |
-
await asyncio.sleep(backoff_sec * (attempt + 1) * random.uniform(0.9, 1.2))
|
| 979 |
-
continue
|
| 980 |
-
self._bump_metric("requests_error", 1)
|
| 981 |
-
raise RuntimeError("DeepSeek API rate limit reached. Please try again shortly.")
|
| 982 |
-
|
| 983 |
-
except APITimeoutError:
|
| 984 |
-
latency_ms = (time.perf_counter() - start) * 1000
|
| 985 |
-
if attempt < max_retries - 1:
|
| 986 |
-
log_model_call(
|
| 987 |
-
LOGGER,
|
| 988 |
-
provider="deepseek",
|
| 989 |
-
model=target_model,
|
| 990 |
-
endpoint=self.ds_base_url,
|
| 991 |
-
latency_ms=latency_ms,
|
| 992 |
-
input_tokens=None,
|
| 993 |
-
output_tokens=None,
|
| 994 |
-
status="error",
|
| 995 |
-
error_class="APITimeoutError",
|
| 996 |
-
error_message="timeout",
|
| 997 |
-
task_type=task_type,
|
| 998 |
-
request_tag=req.request_tag,
|
| 999 |
-
retry_attempt=attempt + 1,
|
| 1000 |
-
fallback_depth=fallback_depth,
|
| 1001 |
-
route=route,
|
| 1002 |
-
)
|
| 1003 |
-
self._bump_metric("retries_total", 1)
|
| 1004 |
-
await asyncio.sleep(backoff_sec * (attempt + 1) * random.uniform(0.9, 1.2))
|
| 1005 |
-
continue
|
| 1006 |
-
self._bump_metric("requests_error", 1)
|
| 1007 |
-
raise RuntimeError("DeepSeek API timed out. Please retry.")
|
| 1008 |
-
|
| 1009 |
-
except APIError as e:
|
| 1010 |
-
latency_ms = (time.perf_counter() - start) * 1000
|
| 1011 |
-
if attempt < max_retries - 1:
|
| 1012 |
-
log_model_call(
|
| 1013 |
-
LOGGER,
|
| 1014 |
-
provider="deepseek",
|
| 1015 |
-
model=target_model,
|
| 1016 |
-
endpoint=self.ds_base_url,
|
| 1017 |
-
latency_ms=latency_ms,
|
| 1018 |
-
input_tokens=None,
|
| 1019 |
-
output_tokens=None,
|
| 1020 |
-
status="error",
|
| 1021 |
-
error_class="APIError",
|
| 1022 |
-
error_message=str(e)[:200],
|
| 1023 |
-
task_type=task_type,
|
| 1024 |
-
request_tag=req.request_tag,
|
| 1025 |
-
retry_attempt=attempt + 1,
|
| 1026 |
-
fallback_depth=fallback_depth,
|
| 1027 |
-
route=route,
|
| 1028 |
-
)
|
| 1029 |
-
self._bump_metric("retries_total", 1)
|
| 1030 |
-
await asyncio.sleep(backoff_sec * (attempt + 1) * random.uniform(0.9, 1.2))
|
| 1031 |
-
continue
|
| 1032 |
-
self._bump_metric("requests_error", 1)
|
| 1033 |
-
raise RuntimeError(f"DeepSeek API error: {str(e)}")
|
| 1034 |
-
|
| 1035 |
-
except Exception as exc:
|
| 1036 |
-
latency_ms = (time.perf_counter() - start) * 1000
|
| 1037 |
-
self._bump_metric("requests_error", 1)
|
| 1038 |
-
last_error = exc
|
| 1039 |
-
log_model_call(
|
| 1040 |
-
LOGGER,
|
| 1041 |
-
provider="deepseek",
|
| 1042 |
-
model=target_model,
|
| 1043 |
-
endpoint=self.ds_base_url,
|
| 1044 |
-
latency_ms=latency_ms,
|
| 1045 |
-
input_tokens=None,
|
| 1046 |
-
output_tokens=None,
|
| 1047 |
-
status="error",
|
| 1048 |
-
error_class=exc.__class__.__name__,
|
| 1049 |
-
error_message=str(exc)[:200],
|
| 1050 |
-
task_type=task_type,
|
| 1051 |
-
request_tag=req.request_tag,
|
| 1052 |
-
retry_attempt=attempt + 1,
|
| 1053 |
-
fallback_depth=fallback_depth,
|
| 1054 |
-
route=route,
|
| 1055 |
-
)
|
| 1056 |
-
if attempt < max_retries - 1:
|
| 1057 |
-
await asyncio.sleep(backoff_sec * (attempt + 1) * random.uniform(0.9, 1.2))
|
| 1058 |
-
continue
|
| 1059 |
-
raise
|
| 1060 |
-
|
| 1061 |
-
raise last_error or RuntimeError(f"DeepSeek stream failed after {max_retries} attempts")
|
| 1062 |
-
|
| 1063 |
def _call_local_space(self, req: InferenceRequest, *, provider: str, route: str, fallback_depth: int) -> str:
|
| 1064 |
target_model = req.model or self.default_model
|
| 1065 |
url = f"{self.local_space_url.rstrip('/')}{self.local_generate_path}"
|
|
@@ -1225,4 +1045,4 @@ def is_sequential_model(model_id: str = "") -> bool:
|
|
| 1225 |
lock = _RUNTIME_OVERRIDES.get("INFERENCE_LOCK_MODEL_ID", "")
|
| 1226 |
if lock == REASONER_MODEL:
|
| 1227 |
return True
|
| 1228 |
-
return False
|
|
|
|
| 6 |
from threading import Lock
|
| 7 |
from dataclasses import dataclass
|
| 8 |
from pathlib import Path
|
| 9 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 10 |
|
| 11 |
import requests
|
| 12 |
import yaml
|
| 13 |
+
from openai import OpenAI, APIError, RateLimitError, APITimeoutError
|
| 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")
|
|
|
|
| 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"??? Loaded config from {config_path}")
|
| 258 |
break
|
| 259 |
|
| 260 |
if not config_path:
|
| 261 |
+
LOGGER.warning(f"?????? Config file not found. Checked: {[str(p) for p in config_paths]}")
|
| 262 |
LOGGER.warning(f" CWD: {Path.cwd()}")
|
| 263 |
LOGGER.warning(f" Using hardcoded defaults")
|
| 264 |
|
|
|
|
| 271 |
primary = primary_cfg
|
| 272 |
|
| 273 |
self.provider = "deepseek"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
self.ds_api_key = os.getenv("DEEPSEEK_API_KEY", "")
|
| 275 |
self.ds_base_url = os.getenv("DEEPSEEK_BASE_URL", DEEPSEEK_BASE_URL)
|
| 276 |
self.ds_chat_model = os.getenv("DEEPSEEK_MODEL", CHAT_MODEL)
|
|
|
|
| 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"???? INFERENCE_MODEL_ID env var override applied: {env_model_id}"
|
| 399 |
)
|
| 400 |
LOGGER.info(
|
| 401 |
f" Task model mappings changed from: {original_map}"
|
|
|
|
| 413 |
self.task_fallback_model_map = {
|
| 414 |
task_key: [] for task_key in self.task_model_map.keys()
|
| 415 |
}
|
| 416 |
+
LOGGER.info(f"???? INFERENCE_ENFORCE_LOCK_MODEL enabled: locking all inference tasks to {self.lock_model_id}")
|
| 417 |
LOGGER.info(f" Cleared fallback models")
|
| 418 |
LOGGER.info(f" Task model mappings forced from: {lock_map_before}")
|
| 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"??? InferenceClient initialized {config_status}{env_override_note}")
|
| 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'}")
|
|
|
|
| 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:
|
|
|
|
| 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)
|
|
|
|
| 551 |
model_base = selected_model
|
| 552 |
|
| 553 |
LOGGER.info(
|
| 554 |
+
f"???? request_tag={request_tag} task={effective_task} source={model_selection_source} "
|
| 555 |
f"selected_model={model_base} (primary)"
|
| 556 |
)
|
| 557 |
LOGGER.info(f" fallback_chain={model_chain[1:] if len(model_chain) > 1 else 'none'}")
|
|
|
|
| 572 |
try:
|
| 573 |
result = self._call_deepseek(request_for_model, fallback_depth)
|
| 574 |
if fallback_depth > 0:
|
| 575 |
+
LOGGER.info(f"??? Fallback succeeded at depth={fallback_depth} model={model_name}")
|
| 576 |
return result
|
| 577 |
except Exception as exc:
|
| 578 |
last_error = exc
|
| 579 |
fallback_hint = f" (depth {fallback_depth})" if fallback_depth > 0 else ""
|
| 580 |
LOGGER.warning(
|
| 581 |
+
f"?????? Attempt failed{fallback_hint}: task={request_for_model.task_type} "
|
| 582 |
f"model={model_name} error={exc.__class__.__name__}: {str(exc)[:100]}"
|
| 583 |
)
|
| 584 |
|
|
|
|
| 615 |
lock_base = (effective_lock_model_id or "").split(":", 1)[0].strip()
|
| 616 |
if selected_base != lock_base:
|
| 617 |
LOGGER.warning(
|
| 618 |
+
f"?????? Model lock replaced requested model {selected_model} with {effective_lock_model_id}"
|
| 619 |
)
|
| 620 |
selected_model = effective_lock_model_id
|
| 621 |
model_selection_source = f"{model_selection_source}:model_lock"
|
|
|
|
| 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:
|
|
|
|
| 708 |
task_type = req.task_type or "default"
|
| 709 |
|
| 710 |
LOGGER.debug(
|
| 711 |
+
f"???? Calling DeepSeek: task={task_type} model={target_model} "
|
| 712 |
f"route={route} depth={fallback_depth}"
|
| 713 |
)
|
| 714 |
|
|
|
|
| 880 |
|
| 881 |
raise RuntimeError(f"DeepSeek call failed after {max_retries} attempts")
|
| 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 |
url = f"{self.local_space_url.rstrip('/')}{self.local_generate_path}"
|
|
|
|
| 1045 |
lock = _RUNTIME_OVERRIDES.get("INFERENCE_LOCK_MODEL_ID", "")
|
| 1046 |
if lock == REASONER_MODEL:
|
| 1047 |
return True
|
| 1048 |
+
return False
|
services/question_bank_service.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,7 +185,6 @@ class UserProvisioningService:
|
|
| 185 |
"level": 1,
|
| 186 |
"currentXP": 0,
|
| 187 |
"totalXP": 0,
|
| 188 |
-
"streak": 0,
|
| 189 |
"atRiskSubjects": [],
|
| 190 |
"hasTakenDiagnostic": False,
|
| 191 |
}
|
|
|
|
| 185 |
"level": 1,
|
| 186 |
"currentXP": 0,
|
| 187 |
"totalXP": 0,
|
|
|
|
| 188 |
"atRiskSubjects": [],
|
| 189 |
"hasTakenDiagnostic": False,
|
| 190 |
}
|
services/variance_engine.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,1017 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,12 +11,33 @@ fi
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,12 +47,27 @@ else
|
|
| 26 |
echo "INFO: Curriculum ingestion script not found at ${_ingest_script}; skipping (curriculum is optional)"
|
| 27 |
fi
|
| 28 |
|
| 29 |
-
|
| 30 |
-
if [ -f "${
|
|
|
|
|
|
|
| 31 |
echo "INFO: Downloading vectorstore from Firebase Storage..."
|
| 32 |
-
python "${
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
else
|
| 34 |
-
echo "INFO: Vectorstore download script not found at ${
|
| 35 |
fi
|
| 36 |
|
| 37 |
exec uvicorn main:app --host 0.0.0.0 --port 7860 --workers 1
|
|
|
|
| 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 |
+
_has_pdfs=false
|
| 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 |
echo "INFO: Curriculum ingestion script not found at ${_ingest_script}; skipping (curriculum is optional)"
|
| 48 |
fi
|
| 49 |
|
| 50 |
+
_vectorstore_download_script="/app/scripts/download_vectorstore_from_firebase.py"
|
| 51 |
+
if [ -f "${_vectorstore_download_script}" ]; then
|
| 52 |
+
echo "INFO: Vectorstore files present before download:"
|
| 53 |
+
ls -la "${VECTORSTORE_DIR}/"
|
| 54 |
echo "INFO: Downloading vectorstore from Firebase Storage..."
|
| 55 |
+
python "${_vectorstore_download_script}" && _result=0 || _result=1
|
| 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 ${_vectorstore_download_script}; skipping"
|
| 71 |
fi
|
| 72 |
|
| 73 |
exec uvicorn main:app --host 0.0.0.0 --port 7860 --workers 1
|
startup_validation.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
Startup validation for MathPulse AI backend.
|
| 3 |
-
Rebuild: 2026-05-10 - DeepSeek API secrets injected
|
| 4 |
|
| 5 |
This module validates all critical dependencies and configurations BEFORE
|
| 6 |
the FastAPI app starts, preventing indefinite restart loops.
|
|
@@ -31,7 +30,7 @@ def validate_imports() -> None:
|
|
| 31 |
import uvicorn # noqa
|
| 32 |
import pydantic # noqa
|
| 33 |
logger.info(" โ FastAPI, Uvicorn, Pydantic OK")
|
| 34 |
-
|
| 35 |
# Backend services (use ABSOLUTE imports like deployed code)
|
| 36 |
from services.inference_client import (
|
| 37 |
InferenceClient, create_default_client, is_sequential_model,
|
|
@@ -40,24 +39,24 @@ def validate_imports() -> None:
|
|
| 40 |
_MODEL_PROFILES,
|
| 41 |
) # noqa
|
| 42 |
logger.info(" โ InferenceClient imports OK")
|
| 43 |
-
|
| 44 |
from automation_engine import automation_engine # noqa
|
| 45 |
logger.info(" โ automation_engine imports OK")
|
| 46 |
-
|
| 47 |
from analytics import compute_competency_analysis # noqa
|
| 48 |
logger.info(" โ analytics imports OK")
|
| 49 |
-
|
| 50 |
# Firebase
|
| 51 |
try:
|
| 52 |
import firebase_admin # noqa
|
| 53 |
logger.info(" โ firebase_admin imports OK")
|
| 54 |
except ImportError:
|
| 55 |
logger.warning(" โ firebase_admin not available (OK if Firebase not needed)")
|
| 56 |
-
|
| 57 |
# ML & inference
|
| 58 |
from services.ai_client import get_deepseek_client, CHAT_MODEL, REASONER_MODEL # noqa
|
| 59 |
logger.info(" โ DeepSeek AI client imports OK")
|
| 60 |
-
|
| 61 |
logger.info("โ
All critical imports validated")
|
| 62 |
except ImportError as e:
|
| 63 |
raise StartupError(
|
|
@@ -78,7 +77,7 @@ def validate_imports() -> None:
|
|
| 78 |
def validate_environment() -> None:
|
| 79 |
"""Verify required environment variables are set."""
|
| 80 |
logger.info("๐ Validating environment variables...")
|
| 81 |
-
|
| 82 |
# CRITICAL: DEEPSEEK_API_KEY for inference
|
| 83 |
ds_api_key = os.environ.get("DEEPSEEK_API_KEY")
|
| 84 |
if not ds_api_key:
|
|
@@ -89,11 +88,11 @@ def validate_environment() -> None:
|
|
| 89 |
)
|
| 90 |
else:
|
| 91 |
logger.info(" โ DEEPSEEK_API_KEY is set")
|
| 92 |
-
|
| 93 |
# Check inference provider config
|
| 94 |
inference_provider = os.getenv("INFERENCE_PROVIDER", "deepseek")
|
| 95 |
logger.info(f" โ INFERENCE_PROVIDER: {inference_provider}")
|
| 96 |
-
|
| 97 |
# Check model IDs
|
| 98 |
chat_model = os.getenv("INFERENCE_CHAT_MODEL_ID") or os.getenv("INFERENCE_MODEL_ID") or "deepseek-chat"
|
| 99 |
logger.info(f" โ Chat model configured: {chat_model}")
|
|
@@ -116,9 +115,9 @@ def validate_environment() -> None:
|
|
| 116 |
logger.warning(
|
| 117 |
" โ Chat hard trigger is enabled while strict chat lock is on; hard escalation will be bypassed"
|
| 118 |
)
|
| 119 |
-
|
| 120 |
_validate_embedding_model()
|
| 121 |
-
|
| 122 |
logger.info("โ
Environment variables OK")
|
| 123 |
|
| 124 |
|
|
@@ -242,26 +241,26 @@ def validate_file_structure() -> None:
|
|
| 242 |
logger.info(
|
| 243 |
f" โน Optional build file not present at runtime: {joined}"
|
| 244 |
)
|
| 245 |
-
|
| 246 |
logger.info("โ
File structure OK")
|
| 247 |
|
| 248 |
|
| 249 |
def validate_inference_client_config() -> None:
|
| 250 |
"""Validate InferenceClient can load its config."""
|
| 251 |
logger.info("๐ Validating InferenceClient configuration...")
|
| 252 |
-
|
| 253 |
try:
|
| 254 |
# Try to create the client (this will load config from YAML)
|
| 255 |
from services.inference_client import create_default_client
|
| 256 |
client = create_default_client()
|
| 257 |
-
|
| 258 |
# Verify critical attributes
|
| 259 |
if not hasattr(client, 'task_model_map'):
|
| 260 |
raise StartupError("โ InferenceClient missing task_model_map attribute")
|
| 261 |
-
|
| 262 |
if not hasattr(client, 'task_provider_map'):
|
| 263 |
raise StartupError("โ InferenceClient missing task_provider_map attribute")
|
| 264 |
-
|
| 265 |
# Check that required tasks are mapped
|
| 266 |
required_tasks = ['chat', 'verify_solution', 'lesson_generation', 'quiz_generation']
|
| 267 |
for task in required_tasks:
|
|
@@ -285,9 +284,9 @@ def validate_inference_client_config() -> None:
|
|
| 285 |
"โ Chat strict model lock is enabled but effective chat model chain is not singular.\n"
|
| 286 |
" Check INFERENCE_CHAT_STRICT_MODEL_ONLY and routing.task_fallback_model_map.chat\n"
|
| 287 |
)
|
| 288 |
-
|
| 289 |
logger.info("โ
InferenceClient configuration OK")
|
| 290 |
-
|
| 291 |
except StartupError:
|
| 292 |
raise
|
| 293 |
except Exception as e:
|
|
@@ -334,13 +333,13 @@ def _validate_model_config_fields(config_path: str) -> None:
|
|
| 334 |
|
| 335 |
def run_all_validations() -> None:
|
| 336 |
"""Run comprehensive startup validation.
|
| 337 |
-
|
| 338 |
If any check fails, exits with clear error message visible in logs.
|
| 339 |
"""
|
| 340 |
logger.info("=" * 70)
|
| 341 |
logger.info("๐ STARTUP VALIDATION - Checking all critical dependencies")
|
| 342 |
logger.info("=" * 70)
|
| 343 |
-
|
| 344 |
strict_mode = os.getenv("STARTUP_VALIDATION_STRICT", "false").strip().lower() in {"1", "true", "yes", "on"}
|
| 345 |
|
| 346 |
try:
|
|
@@ -349,11 +348,11 @@ def run_all_validations() -> None:
|
|
| 349 |
validate_environment()
|
| 350 |
validate_config_files()
|
| 351 |
validate_inference_client_config()
|
| 352 |
-
|
| 353 |
logger.info("=" * 70)
|
| 354 |
logger.info("โ
ALL STARTUP VALIDATIONS PASSED")
|
| 355 |
logger.info("=" * 70)
|
| 356 |
-
|
| 357 |
except StartupError as e:
|
| 358 |
logger.error("=" * 70)
|
| 359 |
logger.error(str(e))
|
|
@@ -372,4 +371,4 @@ def run_all_validations() -> None:
|
|
| 372 |
logger.warning(
|
| 373 |
"โ ๏ธ Continuing startup after unexpected validation error because "
|
| 374 |
"STARTUP_VALIDATION_STRICT is disabled."
|
| 375 |
-
)
|
|
|
|
| 1 |
"""
|
| 2 |
Startup validation for MathPulse AI backend.
|
|
|
|
| 3 |
|
| 4 |
This module validates all critical dependencies and configurations BEFORE
|
| 5 |
the FastAPI app starts, preventing indefinite restart loops.
|
|
|
|
| 30 |
import uvicorn # noqa
|
| 31 |
import pydantic # noqa
|
| 32 |
logger.info(" โ FastAPI, Uvicorn, Pydantic OK")
|
| 33 |
+
|
| 34 |
# Backend services (use ABSOLUTE imports like deployed code)
|
| 35 |
from services.inference_client import (
|
| 36 |
InferenceClient, create_default_client, is_sequential_model,
|
|
|
|
| 39 |
_MODEL_PROFILES,
|
| 40 |
) # noqa
|
| 41 |
logger.info(" โ InferenceClient imports OK")
|
| 42 |
+
|
| 43 |
from automation_engine import automation_engine # noqa
|
| 44 |
logger.info(" โ automation_engine imports OK")
|
| 45 |
+
|
| 46 |
from analytics import compute_competency_analysis # noqa
|
| 47 |
logger.info(" โ analytics imports OK")
|
| 48 |
+
|
| 49 |
# Firebase
|
| 50 |
try:
|
| 51 |
import firebase_admin # noqa
|
| 52 |
logger.info(" โ firebase_admin imports OK")
|
| 53 |
except ImportError:
|
| 54 |
logger.warning(" โ firebase_admin not available (OK if Firebase not needed)")
|
| 55 |
+
|
| 56 |
# ML & inference
|
| 57 |
from services.ai_client import get_deepseek_client, CHAT_MODEL, REASONER_MODEL # noqa
|
| 58 |
logger.info(" โ DeepSeek AI client imports OK")
|
| 59 |
+
|
| 60 |
logger.info("โ
All critical imports validated")
|
| 61 |
except ImportError as e:
|
| 62 |
raise StartupError(
|
|
|
|
| 77 |
def validate_environment() -> None:
|
| 78 |
"""Verify required environment variables are set."""
|
| 79 |
logger.info("๐ Validating environment variables...")
|
| 80 |
+
|
| 81 |
# CRITICAL: DEEPSEEK_API_KEY for inference
|
| 82 |
ds_api_key = os.environ.get("DEEPSEEK_API_KEY")
|
| 83 |
if not ds_api_key:
|
|
|
|
| 88 |
)
|
| 89 |
else:
|
| 90 |
logger.info(" โ DEEPSEEK_API_KEY is set")
|
| 91 |
+
|
| 92 |
# Check inference provider config
|
| 93 |
inference_provider = os.getenv("INFERENCE_PROVIDER", "deepseek")
|
| 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 "deepseek-chat"
|
| 98 |
logger.info(f" โ Chat model configured: {chat_model}")
|
|
|
|
| 115 |
logger.warning(
|
| 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 |
|
|
|
|
| 241 |
logger.info(
|
| 242 |
f" โน Optional build file not present at runtime: {joined}"
|
| 243 |
)
|
| 244 |
+
|
| 245 |
logger.info("โ
File structure OK")
|
| 246 |
|
| 247 |
|
| 248 |
def validate_inference_client_config() -> None:
|
| 249 |
"""Validate InferenceClient can load its config."""
|
| 250 |
logger.info("๐ Validating InferenceClient configuration...")
|
| 251 |
+
|
| 252 |
try:
|
| 253 |
# Try to create the client (this will load config from YAML)
|
| 254 |
from services.inference_client import create_default_client
|
| 255 |
client = create_default_client()
|
| 256 |
+
|
| 257 |
# Verify critical attributes
|
| 258 |
if not hasattr(client, 'task_model_map'):
|
| 259 |
raise StartupError("โ InferenceClient missing task_model_map attribute")
|
| 260 |
+
|
| 261 |
if not hasattr(client, 'task_provider_map'):
|
| 262 |
raise StartupError("โ InferenceClient missing task_provider_map attribute")
|
| 263 |
+
|
| 264 |
# Check that required tasks are mapped
|
| 265 |
required_tasks = ['chat', 'verify_solution', 'lesson_generation', 'quiz_generation']
|
| 266 |
for task in required_tasks:
|
|
|
|
| 284 |
"โ Chat strict model lock is enabled but effective chat model chain is not singular.\n"
|
| 285 |
" Check INFERENCE_CHAT_STRICT_MODEL_ONLY and routing.task_fallback_model_map.chat\n"
|
| 286 |
)
|
| 287 |
+
|
| 288 |
logger.info("โ
InferenceClient configuration OK")
|
| 289 |
+
|
| 290 |
except StartupError:
|
| 291 |
raise
|
| 292 |
except Exception as e:
|
|
|
|
| 333 |
|
| 334 |
def run_all_validations() -> None:
|
| 335 |
"""Run comprehensive startup validation.
|
| 336 |
+
|
| 337 |
If any check fails, exits with clear error message visible in logs.
|
| 338 |
"""
|
| 339 |
logger.info("=" * 70)
|
| 340 |
logger.info("๐ STARTUP VALIDATION - Checking all critical dependencies")
|
| 341 |
logger.info("=" * 70)
|
| 342 |
+
|
| 343 |
strict_mode = os.getenv("STARTUP_VALIDATION_STRICT", "false").strip().lower() in {"1", "true", "yes", "on"}
|
| 344 |
|
| 345 |
try:
|
|
|
|
| 348 |
validate_environment()
|
| 349 |
validate_config_files()
|
| 350 |
validate_inference_client_config()
|
| 351 |
+
|
| 352 |
logger.info("=" * 70)
|
| 353 |
logger.info("โ
ALL STARTUP VALIDATIONS PASSED")
|
| 354 |
logger.info("=" * 70)
|
| 355 |
+
|
| 356 |
except StartupError as e:
|
| 357 |
logger.error("=" * 70)
|
| 358 |
logger.error(str(e))
|
|
|
|
| 371 |
logger.warning(
|
| 372 |
"โ ๏ธ Continuing startup after unexpected validation error because "
|
| 373 |
"STARTUP_VALIDATION_STRICT is disabled."
|
| 374 |
+
)
|
test_full_rag.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,8 +4,7 @@ Comprehensive tests for all FastAPI endpoints.
|
|
| 4 |
|
| 5 |
Tests cover:
|
| 6 |
- Successful requests with valid data
|
| 7 |
-
-
|
| 8 |
-
- HuggingFace API failures (502 fallback)
|
| 9 |
- Timeout handling
|
| 10 |
- Malformed response data
|
| 11 |
- Error status-code mapping
|
|
@@ -85,8 +84,9 @@ mock_ae.ContentUpdatePayload = _ContentUpdatePayload
|
|
| 85 |
mock_ae.AutomationResult = _AutomationResult
|
| 86 |
sys.modules["automation_engine"] = mock_ae
|
| 87 |
|
| 88 |
-
# Override
|
| 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,8 +97,7 @@ 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 |
-
|
| 101 |
-
main_module.firebase_auth = MagicMock()
|
| 102 |
main_module.firebase_auth.verify_id_token = MagicMock(
|
| 103 |
return_value={
|
| 104 |
"uid": "test-teacher-uid",
|
|
@@ -113,33 +112,22 @@ client = TestClient(app, headers={"Authorization": "Bearer test-auth-token"})
|
|
| 113 |
# โโโ Fixtures โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 114 |
|
| 115 |
|
| 116 |
-
|
| 117 |
-
"
|
| 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
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
]
|
| 140 |
-
mock_client.zero_shot_classification.return_value = classification
|
| 141 |
-
|
| 142 |
-
return mock_client
|
| 143 |
|
| 144 |
|
| 145 |
# โโโ Health & Root โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
@@ -521,43 +509,36 @@ class TestChatEndpoint:
|
|
| 521 |
mock_stream_async.assert_not_called()
|
| 522 |
|
| 523 |
|
| 524 |
-
class
|
| 525 |
-
@patch("
|
| 526 |
-
def
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 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", "
|
| 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,15 +549,15 @@ 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_strict_model_only" in source
|
| 573 |
-
assert model_chain == ["
|
| 574 |
|
| 575 |
-
def
|
| 576 |
-
monkeypatch.setenv("INFERENCE_CHAT_MODEL_ID", "
|
| 577 |
monkeypatch.setenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true")
|
| 578 |
-
monkeypatch.setenv("
|
| 579 |
-
monkeypatch.setenv("
|
| 580 |
|
| 581 |
client = InferenceClient()
|
| 582 |
req = InferenceRequest(
|
|
@@ -587,16 +568,16 @@ class TestInferenceRouting:
|
|
| 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 == "
|
| 591 |
assert "chat_override_env" in source
|
| 592 |
-
assert model_chain == ["
|
| 593 |
|
| 594 |
-
def
|
| 595 |
-
monkeypatch.setenv("INFERENCE_CHAT_MODEL_ID", "
|
| 596 |
-
monkeypatch.setenv("INFERENCE_CHAT_MODEL_TEMP_OVERRIDE", "
|
| 597 |
monkeypatch.setenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true")
|
| 598 |
-
monkeypatch.setenv("
|
| 599 |
-
monkeypatch.setenv("
|
| 600 |
|
| 601 |
client = InferenceClient()
|
| 602 |
req = InferenceRequest(
|
|
@@ -607,14 +588,14 @@ class TestInferenceRouting:
|
|
| 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 == "
|
| 611 |
assert "chat_temp_override_env" in source
|
| 612 |
-
assert model_chain == ["
|
| 613 |
|
| 614 |
-
def
|
| 615 |
-
monkeypatch.setenv("INFERENCE_CHAT_MODEL_TEMP_OVERRIDE", "
|
| 616 |
-
monkeypatch.setenv("
|
| 617 |
-
monkeypatch.setenv("
|
| 618 |
|
| 619 |
client = InferenceClient()
|
| 620 |
req = InferenceRequest(
|
|
@@ -625,114 +606,18 @@ class TestInferenceRouting:
|
|
| 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 == "
|
| 629 |
assert "chat_temp_override_env" not in source
|
| 630 |
-
assert model_chain == ["
|
| 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.
|
| 734 |
-
def test_predict_risk_success(self,
|
| 735 |
-
|
| 736 |
response = client.post("/api/predict-risk", json={
|
| 737 |
"engagementScore": 80,
|
| 738 |
"avgQuizScore": 75,
|
|
@@ -746,7 +631,7 @@ class TestRiskPrediction:
|
|
| 746 |
|
| 747 |
def test_predict_risk_invalid_score_range(self):
|
| 748 |
response = client.post("/api/predict-risk", json={
|
| 749 |
-
"engagementScore": 150,
|
| 750 |
"avgQuizScore": 75,
|
| 751 |
"attendance": 90,
|
| 752 |
"assignmentCompletion": 85,
|
|
@@ -768,11 +653,11 @@ class TestRiskPrediction:
|
|
| 768 |
})
|
| 769 |
assert response.status_code == 422
|
| 770 |
|
| 771 |
-
@patch("main.
|
| 772 |
-
def
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
response = client.post("/api/predict-risk", json={
|
| 777 |
"engagementScore": 80,
|
| 778 |
"avgQuizScore": 75,
|
|
@@ -781,9 +666,9 @@ class TestRiskPrediction:
|
|
| 781 |
})
|
| 782 |
assert response.status_code == 502
|
| 783 |
|
| 784 |
-
@patch("main.
|
| 785 |
-
def test_batch_risk_prediction(self,
|
| 786 |
-
|
| 787 |
response = client.post("/api/predict-risk/batch", json={
|
| 788 |
"students": [
|
| 789 |
{"engagementScore": 80, "avgQuizScore": 75, "attendance": 90, "assignmentCompletion": 85},
|
|
@@ -821,8 +706,8 @@ class TestLearningPath:
|
|
| 821 |
assert response.status_code == 422
|
| 822 |
|
| 823 |
@patch("main.call_hf_chat")
|
| 824 |
-
def
|
| 825 |
-
mock_chat.side_effect = Exception("
|
| 826 |
response = client.post("/api/learning-path", json={
|
| 827 |
"weaknesses": ["algebra"],
|
| 828 |
"gradeLevel": "Grade 11",
|
|
@@ -1180,6 +1065,14 @@ class TestUploadClassRecordsGuardrails:
|
|
| 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,15 +1221,24 @@ class TestAsyncGenerationTasks:
|
|
| 1328 |
assert cancel_payload["status"] in {"cancelled", "cancelling"}
|
| 1329 |
|
| 1330 |
def test_inference_metrics_requires_admin(self):
|
| 1331 |
-
|
| 1332 |
-
|
| 1333 |
-
|
| 1334 |
-
|
| 1335 |
-
|
| 1336 |
-
|
| 1337 |
-
|
| 1338 |
-
|
| 1339 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1340 |
response = client.get("/api/ops/inference-metrics")
|
| 1341 |
assert response.status_code == 200
|
| 1342 |
payload = response.json()
|
|
@@ -1566,6 +1468,14 @@ class _FakeFirestoreModule:
|
|
| 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,6 +1518,14 @@ class TestRecentCourseMaterials:
|
|
| 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 |
{
|
|
|
|
| 4 |
|
| 5 |
Tests cover:
|
| 6 |
- Successful requests with valid data
|
| 7 |
+
- AI inference API failures (502 fallback)
|
|
|
|
| 8 |
- Timeout handling
|
| 9 |
- Malformed response data
|
| 10 |
- Error status-code mapping
|
|
|
|
| 84 |
mock_ae.AutomationResult = _AutomationResult
|
| 85 |
sys.modules["automation_engine"] = mock_ae
|
| 86 |
|
| 87 |
+
# Override tokens so client init doesn't fail
|
| 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 |
main_module._firebase_ready = True
|
| 98 |
main_module._init_firebase_admin = lambda: None
|
| 99 |
main_module.firebase_firestore = None
|
| 100 |
+
main_module.firebase_auth = MagicMock()
|
|
|
|
| 101 |
main_module.firebase_auth.verify_id_token = MagicMock(
|
| 102 |
return_value={
|
| 103 |
"uid": "test-teacher-uid",
|
|
|
|
| 112 |
# โโโ Fixtures โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 113 |
|
| 114 |
|
| 115 |
+
def make_deepseek_risk_mock(
|
| 116 |
+
risk_label: str = "low risk academically stable",
|
| 117 |
+
confidence: float = 0.85,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
):
|
| 119 |
+
"""Create a mock DeepSeek client for risk prediction tests."""
|
| 120 |
+
mock_ds = MagicMock()
|
| 121 |
+
mock_choice = MagicMock()
|
| 122 |
+
mock_choice.message.content = json.dumps({
|
| 123 |
+
"risk_label": risk_label,
|
| 124 |
+
"confidence": confidence,
|
| 125 |
+
"reasoning": "Mock risk assessment."
|
| 126 |
+
})
|
| 127 |
+
mock_ds.chat.completions.create.return_value = MagicMock(
|
| 128 |
+
choices=[mock_choice]
|
| 129 |
+
)
|
| 130 |
+
return mock_ds
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
|
| 133 |
# โโโ Health & Root โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
|
|
| 509 |
mock_stream_async.assert_not_called()
|
| 510 |
|
| 511 |
|
| 512 |
+
class TestChatTransport:
|
| 513 |
+
@patch("services.ai_client.get_deepseek_client")
|
| 514 |
+
def test_call_hf_chat_uses_deepseek_api(self, mock_ds_fn):
|
| 515 |
+
mock_ds = MagicMock()
|
| 516 |
+
mock_choice = MagicMock()
|
| 517 |
+
mock_choice.message.content = "x = 2 or x = 3"
|
| 518 |
+
mock_ds.chat.completions.create.return_value = MagicMock(
|
| 519 |
+
choices=[mock_choice]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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", "deepseek-chat")
|
| 541 |
monkeypatch.setenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true")
|
|
|
|
|
|
|
| 542 |
|
| 543 |
client = InferenceClient()
|
| 544 |
req = InferenceRequest(
|
|
|
|
| 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 == "deepseek-chat"
|
| 553 |
assert "chat_strict_model_only" in source
|
| 554 |
+
assert model_chain == ["deepseek-chat"]
|
| 555 |
|
| 556 |
+
def test_chat_env_override_wins_under_model_lock(self, monkeypatch):
|
| 557 |
+
monkeypatch.setenv("INFERENCE_CHAT_MODEL_ID", "deepseek-chat")
|
| 558 |
monkeypatch.setenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true")
|
| 559 |
+
monkeypatch.setenv("INFERENCE_ENFORCE_LOCK_MODEL", "true")
|
| 560 |
+
monkeypatch.setenv("INFERENCE_LOCK_MODEL_ID", "deepseek-reasoner")
|
| 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 == "deepseek-chat"
|
| 572 |
assert "chat_override_env" in source
|
| 573 |
+
assert model_chain == ["deepseek-chat"]
|
| 574 |
|
| 575 |
+
def test_chat_temp_override_wins_under_model_lock(self, monkeypatch):
|
| 576 |
+
monkeypatch.setenv("INFERENCE_CHAT_MODEL_ID", "deepseek-reasoner")
|
| 577 |
+
monkeypatch.setenv("INFERENCE_CHAT_MODEL_TEMP_OVERRIDE", "deepseek-chat")
|
| 578 |
monkeypatch.setenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true")
|
| 579 |
+
monkeypatch.setenv("INFERENCE_ENFORCE_LOCK_MODEL", "true")
|
| 580 |
+
monkeypatch.setenv("INFERENCE_LOCK_MODEL_ID", "deepseek-reasoner")
|
| 581 |
|
| 582 |
client = InferenceClient()
|
| 583 |
req = InferenceRequest(
|
|
|
|
| 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 == "deepseek-chat"
|
| 592 |
assert "chat_temp_override_env" in source
|
| 593 |
+
assert model_chain == ["deepseek-chat"]
|
| 594 |
|
| 595 |
+
def test_chat_temp_override_does_not_change_non_chat_task_under_lock(self, monkeypatch):
|
| 596 |
+
monkeypatch.setenv("INFERENCE_CHAT_MODEL_TEMP_OVERRIDE", "deepseek-chat")
|
| 597 |
+
monkeypatch.setenv("INFERENCE_ENFORCE_LOCK_MODEL", "true")
|
| 598 |
+
monkeypatch.setenv("INFERENCE_LOCK_MODEL_ID", "deepseek-reasoner")
|
| 599 |
|
| 600 |
client = InferenceClient()
|
| 601 |
req = InferenceRequest(
|
|
|
|
| 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 == "deepseek-reasoner"
|
| 610 |
assert "chat_temp_override_env" not in source
|
| 611 |
+
assert model_chain == ["deepseek-reasoner"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 612 |
|
| 613 |
|
| 614 |
# โโโ Risk Prediction โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 615 |
|
| 616 |
|
| 617 |
class TestRiskPrediction:
|
| 618 |
+
@patch("main.get_deepseek_client")
|
| 619 |
+
def test_predict_risk_success(self, mock_ds_fn):
|
| 620 |
+
mock_ds_fn.return_value = make_deepseek_risk_mock()
|
| 621 |
response = client.post("/api/predict-risk", json={
|
| 622 |
"engagementScore": 80,
|
| 623 |
"avgQuizScore": 75,
|
|
|
|
| 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 |
})
|
| 654 |
assert response.status_code == 422
|
| 655 |
|
| 656 |
+
@patch("main.get_deepseek_client")
|
| 657 |
+
def test_predict_risk_ai_failure(self, mock_ds_fn):
|
| 658 |
+
mock_client = MagicMock()
|
| 659 |
+
mock_client.chat.completions.create.side_effect = Exception("AI down")
|
| 660 |
+
mock_ds_fn.return_value = mock_client
|
| 661 |
response = client.post("/api/predict-risk", json={
|
| 662 |
"engagementScore": 80,
|
| 663 |
"avgQuizScore": 75,
|
|
|
|
| 666 |
})
|
| 667 |
assert response.status_code == 502
|
| 668 |
|
| 669 |
+
@patch("main.get_deepseek_client")
|
| 670 |
+
def test_batch_risk_prediction(self, mock_ds_fn):
|
| 671 |
+
mock_ds_fn.return_value = make_deepseek_risk_mock()
|
| 672 |
response = client.post("/api/predict-risk/batch", json={
|
| 673 |
"students": [
|
| 674 |
{"engagementScore": 80, "avgQuizScore": 75, "attendance": 90, "assignmentCompletion": 85},
|
|
|
|
| 706 |
assert response.status_code == 422
|
| 707 |
|
| 708 |
@patch("main.call_hf_chat")
|
| 709 |
+
def test_learning_path_ai_failure(self, mock_chat):
|
| 710 |
+
mock_chat.side_effect = Exception("AI service down")
|
| 711 |
response = client.post("/api/learning-path", json={
|
| 712 |
"weaknesses": ["algebra"],
|
| 713 |
"gradeLevel": "Grade 11",
|
|
|
|
| 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 |
assert cancel_payload["status"] in {"cancelled", "cancelling"}
|
| 1222 |
|
| 1223 |
def test_inference_metrics_requires_admin(self):
|
| 1224 |
+
# Test with a non-admin mock to verify role check works
|
| 1225 |
+
with patch.object(main_module.firebase_auth, "verify_id_token", return_value={
|
| 1226 |
+
"uid": "teacher-uid",
|
| 1227 |
+
"email": "teacher@example.com",
|
| 1228 |
+
"role": "teacher",
|
| 1229 |
+
}):
|
| 1230 |
+
response = client.get("/api/ops/inference-metrics")
|
| 1231 |
+
assert response.status_code == 403
|
| 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 |
|
| 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 |
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 |
{
|
tests/test_hf_monitoring_routes.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"]
|
tests/test_model_profiles.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
from unittest.mock import patch
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
| 10 |
+
from services import inference_client as inf_client
|
| 11 |
+
from services.inference_client import (
|
| 12 |
+
_MODEL_PROFILES,
|
| 13 |
+
get_current_runtime_config,
|
| 14 |
+
get_model_for_task,
|
| 15 |
+
is_sequential_model,
|
| 16 |
+
model_supports_thinking,
|
| 17 |
+
reset_runtime_overrides,
|
| 18 |
+
set_runtime_model_override,
|
| 19 |
+
set_runtime_model_profile,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
REQUIRED_PROFILE_KEYS = {
|
| 24 |
+
"INFERENCE_MODEL_ID", "INFERENCE_CHAT_MODEL_ID",
|
| 25 |
+
"HF_QUIZ_MODEL_ID", "HF_RAG_MODEL_ID", "INFERENCE_LOCK_MODEL_ID",
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class TestModelProfiles:
|
| 30 |
+
def test_profiles_have_all_keys(self):
|
| 31 |
+
for name, profile in _MODEL_PROFILES.items():
|
| 32 |
+
assert REQUIRED_PROFILE_KEYS == set(profile.keys()), \
|
| 33 |
+
f"Profile '{name}' missing or extra keys"
|
| 34 |
+
|
| 35 |
+
def test_dev_uses_chat_model(self):
|
| 36 |
+
dev = _MODEL_PROFILES["dev"]
|
| 37 |
+
for key, value in dev.items():
|
| 38 |
+
assert "deepseek-chat" in value, f"dev/{key} = {value}, expected deepseek-chat"
|
| 39 |
+
|
| 40 |
+
def test_prod_chat_is_chat_model(self):
|
| 41 |
+
assert "deepseek-chat" in _MODEL_PROFILES["prod"]["INFERENCE_CHAT_MODEL_ID"]
|
| 42 |
+
|
| 43 |
+
def test_prod_rag_is_reasoner(self):
|
| 44 |
+
assert "deepseek-reasoner" in _MODEL_PROFILES["prod"]["HF_RAG_MODEL_ID"]
|
| 45 |
+
|
| 46 |
+
def test_budget_uses_chat_model_everywhere(self):
|
| 47 |
+
budget = _MODEL_PROFILES["budget"]
|
| 48 |
+
for key, value in budget.items():
|
| 49 |
+
assert "deepseek-chat" in value, f"budget/{key} = {value}"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class TestRuntimeOverrides:
|
| 53 |
+
|
| 54 |
+
def setup_method(self):
|
| 55 |
+
reset_runtime_overrides()
|
| 56 |
+
|
| 57 |
+
def teardown_method(self):
|
| 58 |
+
reset_runtime_overrides()
|
| 59 |
+
|
| 60 |
+
def test_set_profile_populates_overrides(self):
|
| 61 |
+
set_runtime_model_profile("dev")
|
| 62 |
+
assert inf_client._RUNTIME_PROFILE == "dev"
|
| 63 |
+
assert inf_client._RUNTIME_OVERRIDES["INFERENCE_MODEL_ID"] == "deepseek-chat"
|
| 64 |
+
assert inf_client._RUNTIME_OVERRIDES["INFERENCE_CHAT_MODEL_ID"] == "deepseek-chat"
|
| 65 |
+
|
| 66 |
+
def test_set_profile_replaces_all_overrides(self):
|
| 67 |
+
set_runtime_model_profile("dev")
|
| 68 |
+
set_runtime_model_profile("prod")
|
| 69 |
+
assert inf_client._RUNTIME_OVERRIDES["INFERENCE_CHAT_MODEL_ID"] == "deepseek-chat"
|
| 70 |
+
assert inf_client._RUNTIME_OVERRIDES["INFERENCE_LOCK_MODEL_ID"] == "deepseek-chat"
|
| 71 |
+
|
| 72 |
+
def test_set_profile_unknown_raises(self):
|
| 73 |
+
with pytest.raises(ValueError, match="Unknown profile"):
|
| 74 |
+
set_runtime_model_profile("nonexistent")
|
| 75 |
+
|
| 76 |
+
def test_single_override_sets_key(self):
|
| 77 |
+
set_runtime_model_override("HF_RAG_MODEL_ID", "custom/model")
|
| 78 |
+
assert inf_client._RUNTIME_OVERRIDES["HF_RAG_MODEL_ID"] == "custom/model"
|
| 79 |
+
|
| 80 |
+
def test_reset_clears_overrides(self):
|
| 81 |
+
set_runtime_model_profile("dev")
|
| 82 |
+
reset_runtime_overrides()
|
| 83 |
+
assert inf_client._RUNTIME_PROFILE == ""
|
| 84 |
+
assert inf_client._RUNTIME_OVERRIDES == {}
|
| 85 |
+
|
| 86 |
+
def test_override_layers_on_profile(self):
|
| 87 |
+
set_runtime_model_profile("dev")
|
| 88 |
+
set_runtime_model_override("HF_RAG_MODEL_ID", "custom/model")
|
| 89 |
+
assert inf_client._RUNTIME_OVERRIDES["HF_RAG_MODEL_ID"] == "custom/model"
|
| 90 |
+
assert inf_client._RUNTIME_OVERRIDES["INFERENCE_MODEL_ID"] == "deepseek-chat"
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class TestGetCurrentRuntimeConfig:
|
| 94 |
+
|
| 95 |
+
def setup_method(self):
|
| 96 |
+
reset_runtime_overrides()
|
| 97 |
+
|
| 98 |
+
def teardown_method(self):
|
| 99 |
+
reset_runtime_overrides()
|
| 100 |
+
|
| 101 |
+
def test_returns_resolved_dict_with_all_keys(self):
|
| 102 |
+
set_runtime_model_profile("dev")
|
| 103 |
+
config = get_current_runtime_config()
|
| 104 |
+
assert config["profile"] == "dev"
|
| 105 |
+
for key in REQUIRED_PROFILE_KEYS:
|
| 106 |
+
assert key in config["resolved"], f"Missing {key}"
|
| 107 |
+
|
| 108 |
+
def test_override_takes_priority_over_profile(self):
|
| 109 |
+
set_runtime_model_profile("dev")
|
| 110 |
+
set_runtime_model_override("INFERENCE_CHAT_MODEL_ID", "custom/chat")
|
| 111 |
+
config = get_current_runtime_config()
|
| 112 |
+
assert config["resolved"]["INFERENCE_CHAT_MODEL_ID"] == "custom/chat"
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class TestGetModelForTask:
|
| 116 |
+
|
| 117 |
+
def setup_method(self):
|
| 118 |
+
reset_runtime_overrides()
|
| 119 |
+
|
| 120 |
+
def teardown_method(self):
|
| 121 |
+
reset_runtime_overrides()
|
| 122 |
+
|
| 123 |
+
@patch.dict(os.environ, {"INFERENCE_ENFORCE_LOCK_MODEL": "false"})
|
| 124 |
+
def test_returns_profile_default_for_rag(self):
|
| 125 |
+
set_runtime_model_profile("prod")
|
| 126 |
+
model = get_model_for_task("rag_lesson")
|
| 127 |
+
assert "deepseek-reasoner" in model
|
| 128 |
+
|
| 129 |
+
@patch.dict(os.environ, {"INFERENCE_ENFORCE_LOCK_MODEL": "false"})
|
| 130 |
+
def test_returns_profile_default_for_chat(self):
|
| 131 |
+
set_runtime_model_profile("prod")
|
| 132 |
+
model = get_model_for_task("chat")
|
| 133 |
+
assert "deepseek-chat" in model
|
| 134 |
+
|
| 135 |
+
@patch.dict(os.environ, {"INFERENCE_ENFORCE_LOCK_MODEL": "false"})
|
| 136 |
+
def test_returns_runtime_override_for_chat(self):
|
| 137 |
+
set_runtime_model_override("INFERENCE_CHAT_MODEL_ID", "custom/chat")
|
| 138 |
+
model = get_model_for_task("chat")
|
| 139 |
+
assert model == "custom/chat"
|
| 140 |
+
|
| 141 |
+
@patch.dict(os.environ, {"INFERENCE_ENFORCE_LOCK_MODEL": "true"})
|
| 142 |
+
def test_enforce_qwen_overrides_task(self):
|
| 143 |
+
set_runtime_model_profile("prod")
|
| 144 |
+
model = get_model_for_task("rag_lesson")
|
| 145 |
+
assert "deepseek-chat" in model
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
class TestIsSequentialModel:
|
| 149 |
+
|
| 150 |
+
def setup_method(self):
|
| 151 |
+
reset_runtime_overrides()
|
| 152 |
+
|
| 153 |
+
def teardown_method(self):
|
| 154 |
+
reset_runtime_overrides()
|
| 155 |
+
|
| 156 |
+
def test_reasoner_is_sequential(self):
|
| 157 |
+
assert is_sequential_model("deepseek-reasoner") is True
|
| 158 |
+
|
| 159 |
+
def test_chat_is_not_sequential(self):
|
| 160 |
+
assert is_sequential_model("deepseek-chat") is False
|
| 161 |
+
|
| 162 |
+
def test_empty_string_checks_env(self):
|
| 163 |
+
result = is_sequential_model("")
|
| 164 |
+
assert result is True or result is False
|
| 165 |
+
|
| 166 |
+
@patch.dict(os.environ, {"INFERENCE_MODEL_ID": "deepseek-reasoner"})
|
| 167 |
+
def test_env_model_reasoner_is_sequential(self):
|
| 168 |
+
assert is_sequential_model("") is True
|
| 169 |
+
|
| 170 |
+
@patch.dict(os.environ, {"INFERENCE_MODEL_ID": "deepseek-chat"})
|
| 171 |
+
def test_env_model_chat_is_not_sequential(self):
|
| 172 |
+
assert is_sequential_model("") is False
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
class TestModelSupportsThinking:
|
| 176 |
+
|
| 177 |
+
def test_reasoner_supports_thinking(self):
|
| 178 |
+
assert model_supports_thinking("deepseek-reasoner") is True
|
| 179 |
+
|
| 180 |
+
def test_chat_does_not_support_thinking(self):
|
| 181 |
+
assert model_supports_thinking("deepseek-chat") is False
|
| 182 |
+
|
| 183 |
+
def test_unknown_does_not_support_thinking(self):
|
| 184 |
+
assert model_supports_thinking("meta-llama/Llama-3.1-8B-Instruct") is False
|
tests/test_quiz_battle.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for Quiz Battle RAG-powered question bank.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from unittest.mock import patch, MagicMock, AsyncMock
|
| 7 |
+
from datetime import datetime, timezone, timedelta
|
| 8 |
+
|
| 9 |
+
from fastapi.testclient import TestClient
|
| 10 |
+
|
| 11 |
+
# Mock firebase_admin before imports
|
| 12 |
+
import sys
|
| 13 |
+
from unittest.mock import MagicMock
|
| 14 |
+
|
| 15 |
+
_original_firebase_admin = sys.modules.get("firebase_admin")
|
| 16 |
+
|
| 17 |
+
firebase_mock = MagicMock()
|
| 18 |
+
sys.modules["firebase_admin"] = firebase_mock
|
| 19 |
+
sys.modules["firebase_admin.credentials"] = MagicMock()
|
| 20 |
+
sys.modules["google.cloud.firestore"] = MagicMock()
|
| 21 |
+
|
| 22 |
+
from main import app
|
| 23 |
+
|
| 24 |
+
client = TestClient(app)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@pytest.fixture(scope="module", autouse=True)
|
| 28 |
+
def _cleanup_firebase_mock():
|
| 29 |
+
"""Restore original firebase_admin module after all tests in this module."""
|
| 30 |
+
yield
|
| 31 |
+
if _original_firebase_admin is not None:
|
| 32 |
+
sys.modules["firebase_admin"] = _original_firebase_admin
|
| 33 |
+
elif "firebase_admin" in sys.modules:
|
| 34 |
+
del sys.modules["firebase_admin"]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# โโ PDF Ingestion Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 38 |
+
|
| 39 |
+
class TestPdfIngestion:
|
| 40 |
+
@pytest.mark.asyncio
|
| 41 |
+
async def test_ingest_pdf_skips_already_processed(self):
|
| 42 |
+
"""If pdf_processing_status says processed, skip re-ingestion."""
|
| 43 |
+
with patch("rag.pdf_ingestion.Client") as mock_firestore:
|
| 44 |
+
mock_doc = MagicMock()
|
| 45 |
+
mock_doc.exists = True
|
| 46 |
+
mock_doc.to_dict.return_value = {
|
| 47 |
+
"processed": True,
|
| 48 |
+
"question_count": 10,
|
| 49 |
+
"grade_level": 8,
|
| 50 |
+
"topic": "linear_equations",
|
| 51 |
+
"storage_path": "quiz_pdfs/grade_8/test.pdf",
|
| 52 |
+
"timestamp": datetime.now(timezone.utc),
|
| 53 |
+
}
|
| 54 |
+
# Make get() return an awaitable
|
| 55 |
+
async def async_get():
|
| 56 |
+
return mock_doc
|
| 57 |
+
mock_ref = MagicMock()
|
| 58 |
+
mock_ref.get = async_get
|
| 59 |
+
mock_firestore.return_value.collection.return_value.document.return_value = mock_ref
|
| 60 |
+
|
| 61 |
+
from rag.pdf_ingestion import ingest_pdf
|
| 62 |
+
result = await ingest_pdf("quiz_pdfs/grade_8/test.pdf", 8, "linear_equations")
|
| 63 |
+
assert result.processed is True
|
| 64 |
+
assert result.question_count == 10
|
| 65 |
+
|
| 66 |
+
@pytest.mark.asyncio
|
| 67 |
+
async def test_ingest_pdf_force_reingest(self):
|
| 68 |
+
"""If force_reingest=True, process even if already done."""
|
| 69 |
+
with patch("rag.pdf_ingestion.Client") as mock_firestore, \
|
| 70 |
+
patch("rag.pdf_ingestion._init_firebase_storage") as mock_storage, \
|
| 71 |
+
patch("rag.pdf_ingestion._extract_pdf_text") as mock_extract, \
|
| 72 |
+
patch("rag.pdf_ingestion._chunk_text") as mock_chunk, \
|
| 73 |
+
patch("rag.pdf_ingestion._generate_questions_for_chunk") as mock_gen, \
|
| 74 |
+
patch("rag.pdf_ingestion._save_questions_batch") as mock_save, \
|
| 75 |
+
patch("rag.pdf_ingestion._save_embeddings_batch") as mock_save_emb, \
|
| 76 |
+
patch("rag.pdf_ingestion._save_processing_manifest") as mock_save_status, \
|
| 77 |
+
patch("rag.pdf_ingestion.get_deepseek_client") as mock_deepseek:
|
| 78 |
+
|
| 79 |
+
mock_doc = MagicMock()
|
| 80 |
+
mock_doc.exists = True
|
| 81 |
+
mock_doc.to_dict.return_value = {"processed": True}
|
| 82 |
+
async def async_get():
|
| 83 |
+
return mock_doc
|
| 84 |
+
mock_ref = MagicMock()
|
| 85 |
+
mock_ref.get = async_get
|
| 86 |
+
mock_firestore.return_value.collection.return_value.document.return_value = mock_ref
|
| 87 |
+
mock_blob = MagicMock()
|
| 88 |
+
mock_blob.exists.return_value = True
|
| 89 |
+
mock_blob.download_as_bytes.return_value = b"pdf bytes"
|
| 90 |
+
mock_storage.return_value = (None, MagicMock())
|
| 91 |
+
mock_storage.return_value[1].blob.return_value = mock_blob
|
| 92 |
+
mock_extract.return_value = "Some math content"
|
| 93 |
+
mock_chunk.return_value = ["chunk1"]
|
| 94 |
+
mock_gen.return_value = [{
|
| 95 |
+
"question": "What is 2+2?",
|
| 96 |
+
"choices": ["A) 3", "B) 4", "C) 5", "D) 6"],
|
| 97 |
+
"correct_answer": "B",
|
| 98 |
+
"explanation": "Basic addition",
|
| 99 |
+
"topic": "linear_equations",
|
| 100 |
+
"difficulty": "easy",
|
| 101 |
+
"grade_level": 8,
|
| 102 |
+
"source_chunk_id": "chunk1",
|
| 103 |
+
}]
|
| 104 |
+
mock_save.return_value = 1
|
| 105 |
+
mock_deepseek.return_value = MagicMock()
|
| 106 |
+
|
| 107 |
+
from rag.pdf_ingestion import ingest_pdf
|
| 108 |
+
result = await ingest_pdf("quiz_pdfs/grade_8/test.pdf", 8, "linear_equations", force_reingest=True)
|
| 109 |
+
assert result.processed is True
|
| 110 |
+
assert result.question_count == 1
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# โโ Question Bank Service Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 114 |
+
|
| 115 |
+
class TestQuestionBankService:
|
| 116 |
+
@pytest.mark.asyncio
|
| 117 |
+
async def test_get_questions_for_battle(self):
|
| 118 |
+
"""Fetch questions with random ordering."""
|
| 119 |
+
with patch("services.question_bank_service._get_db") as mock_db:
|
| 120 |
+
mock_doc = MagicMock()
|
| 121 |
+
mock_doc.to_dict.return_value = {
|
| 122 |
+
"question": "What is 2+2?",
|
| 123 |
+
"choices": ["A) 3", "B) 4", "C) 5", "D) 6"],
|
| 124 |
+
"correct_answer": "B",
|
| 125 |
+
"difficulty": "easy",
|
| 126 |
+
"random_seed": 0.5,
|
| 127 |
+
}
|
| 128 |
+
mock_collection = MagicMock()
|
| 129 |
+
mock_collection.where.return_value.order_by.return_value.limit.return_value.stream.return_value = [mock_doc]
|
| 130 |
+
mock_collection.where.return_value.order_by.return_value.limit.return_value.stream.return_value = [mock_doc]
|
| 131 |
+
mock_db.return_value.collection.return_value = mock_collection
|
| 132 |
+
|
| 133 |
+
from services.question_bank_service import get_questions_for_battle
|
| 134 |
+
questions = await get_questions_for_battle(8, "linear_equations", 1)
|
| 135 |
+
assert len(questions) == 1
|
| 136 |
+
assert questions[0]["question"] == "What is 2+2?"
|
| 137 |
+
|
| 138 |
+
@pytest.mark.asyncio
|
| 139 |
+
async def test_cache_session_questions(self):
|
| 140 |
+
"""Cache questions for 24 hours."""
|
| 141 |
+
with patch("services.question_bank_service._get_db") as mock_db:
|
| 142 |
+
mock_session_ref = MagicMock()
|
| 143 |
+
mock_db.return_value.collection.return_value.document.return_value = mock_session_ref
|
| 144 |
+
|
| 145 |
+
from services.question_bank_service import cache_session_questions
|
| 146 |
+
await cache_session_questions(
|
| 147 |
+
"session_123",
|
| 148 |
+
[{"question": "Q1", "correct_answer": "A"}],
|
| 149 |
+
["uid1"],
|
| 150 |
+
8,
|
| 151 |
+
"linear_equations",
|
| 152 |
+
)
|
| 153 |
+
mock_session_ref.set.assert_called_once()
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# โโ Variance Engine Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 157 |
+
|
| 158 |
+
class TestVarianceEngine:
|
| 159 |
+
@pytest.mark.asyncio
|
| 160 |
+
async def test_apply_variance_uses_cache(self):
|
| 161 |
+
"""If cache exists, return cached questions."""
|
| 162 |
+
with patch("services.variance_engine.get_cached_session") as mock_cache:
|
| 163 |
+
mock_cache.return_value = [{"question": "Cached?", "correct_answer": "A"}]
|
| 164 |
+
from services.variance_engine import apply_variance
|
| 165 |
+
result = await apply_variance([], "session_123")
|
| 166 |
+
assert result[0]["question"] == "Cached?"
|
| 167 |
+
|
| 168 |
+
@pytest.mark.asyncio
|
| 169 |
+
async def test_apply_variance_fallback_shuffle(self):
|
| 170 |
+
"""If DeepSeek fails, fallback to pure Python shuffle."""
|
| 171 |
+
with patch("services.variance_engine.get_cached_session") as mock_cache, \
|
| 172 |
+
patch("services.variance_engine.get_deepseek_client") as mock_client, \
|
| 173 |
+
patch("services.variance_engine.cache_session_questions") as mock_save:
|
| 174 |
+
mock_cache.return_value = None
|
| 175 |
+
mock_client.return_value.chat.completions.create.side_effect = Exception("API error")
|
| 176 |
+
mock_save.return_value = None
|
| 177 |
+
|
| 178 |
+
from services.variance_engine import apply_variance
|
| 179 |
+
questions = [{
|
| 180 |
+
"question": "What is 2+2?",
|
| 181 |
+
"choices": ["A) 3", "B) 4", "C) 5", "D) 6"],
|
| 182 |
+
"correct_answer": "B",
|
| 183 |
+
"difficulty": "easy",
|
| 184 |
+
"topic": "math",
|
| 185 |
+
"grade_level": 8,
|
| 186 |
+
"source_chunk_id": "c1",
|
| 187 |
+
}]
|
| 188 |
+
result = await apply_variance(questions, "session_123")
|
| 189 |
+
assert len(result) == 1
|
| 190 |
+
assert result[0]["variance_applied"] == ["choice_shuffle"]
|
| 191 |
+
# Correct answer should still point to the right text
|
| 192 |
+
correct_index = ord(result[0]["correct_answer"]) - ord("A")
|
| 193 |
+
assert "4" in result[0]["choices"][correct_index]
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
# โโ Route Integration Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 197 |
+
|
| 198 |
+
class TestQuizBattleRoutes:
|
| 199 |
+
def test_generate_unauthorized(self):
|
| 200 |
+
"""Generate without auth should 401 or 403 depending on middleware."""
|
| 201 |
+
response = client.post("/api/quiz-battle/generate", json={
|
| 202 |
+
"grade_level": 8,
|
| 203 |
+
"topic": "linear_equations",
|
| 204 |
+
"question_count": 10,
|
| 205 |
+
"session_id": "test-session",
|
| 206 |
+
"player_ids": ["uid1"],
|
| 207 |
+
})
|
| 208 |
+
# Auth middleware may reject or allow in test env
|
| 209 |
+
assert response.status_code in (200, 401, 403)
|
| 210 |
+
|
| 211 |
+
def test_ingest_pdf_unauthorized(self):
|
| 212 |
+
"""Ingest-pdf without teacher role should 403."""
|
| 213 |
+
response = client.post("/api/quiz-battle/ingest-pdf", json={
|
| 214 |
+
"storage_path": "quiz_pdfs/grade_8/test.pdf",
|
| 215 |
+
"grade_level": 8,
|
| 216 |
+
"topic": "linear_equations",
|
| 217 |
+
})
|
| 218 |
+
assert response.status_code in (401, 403)
|
| 219 |
+
|
| 220 |
+
def test_bank_status_unauthorized(self):
|
| 221 |
+
"""Bank-status without teacher role should 403."""
|
| 222 |
+
response = client.get("/api/quiz-battle/bank-status")
|
| 223 |
+
assert response.status_code in (401, 403)
|